"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { AreaModal } from "@/components/features/detail-log/AreaModal";
import { CleanLogTable } from "@/components/features/detail-log/CleanLogTable";
import { Pill } from "@/components/ui/Pill";
import { StatCard } from "@/components/ui/StatCard";
import { useTheme } from "@/components/theme/ThemeProvider";
import { apiFetch, ApiFetchError } from "@/lib/api/client";
import type { AreaFilter } from "@/lib/clean-logs/areas";
import { DASHBOARD_LATEST_RUN, SUCCESS_RATE_TARGET } from "@/lib/config";
import { formatMd } from "@/lib/format/date";
import { displayRate } from "@/lib/judge";
import { THEME_TOKENS } from "@/lib/theme";
import type {
AreaCompletionsResponse,
CleanLogAdviceResponse,
CleanLogItem,
DashboardResponse,
} from "@/lib/types";
import { RescueRequiredCard } from "./RescueRequiredCard";
import { ScheduleProgressCard } from "./ScheduleProgressCard";
import { SecurityBanner } from "./SecurityBanner";
import styles from "./DashboardScreen.module.css";
/**
* エリアモーダルを開いている対象。
*
* ★`logId` は「エリアを特定するキー」ではなく、施設と日付を特定するための入口★
* (指示書Z 決定1)。施設名・日付はヘッダに出すためだけに持つ。
*
* ★`DetailLogScreen.tsx` の同名の型と同じ形をあえて各画面に持たせている★ —
* API契約(`d-watch-api.yaml`)のスキーマではなく画面内部の都合の型なので、
* `lib/types/`(YAMLのスキーマ名と揃える場所。`CLAUDE.md` §9.2)には置かない。
*/
interface AreaTarget {
logId: number;
facilityName: string;
dateLabel: string;
}
/**
* コンパクト版の清掃詳細ログに出す件数(★`problem_logs` の先頭5件★。D-159 / 指示書AV 段階5-1)。
* ★API は件数で切らない★(1清掃エリア最大1行なので、エリア数が自然な上限。指示書AV D11)。
* 要レスキューの初期表示(`RESCUE_INITIAL_VISIBLE`)はこの5件に揃えてある。
* 旧: `/api/clean-logs` に `per_page: "5"` を付けて取っていた
*/
const COMPACT_LOG_ROWS = 5;
export function DashboardScreen() {
const { t, theme } = useTheme();
const c = THEME_TOKENS[theme];
const router = useRouter();
const [data, setData] = useState<DashboardResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [adviceById, setAdviceById] = useState<
Record<number, CleanLogAdviceResponse | undefined>
>({});
const [adviceLoadingId, setAdviceLoadingId] = useState<number | null>(null);
const [areaTarget, setAreaTarget] = useState<AreaTarget | null>(null);
// 「判定 ?」の凡例。★ボタンは compact でも出ている★ので、状態を持たせないと
// 「押しても何も起きないボタン」になる(D-143 §3 #2 / 指示書AO 段階6)
const [showGradeInfo, setShowGradeInfo] = useState(false);
// ★既定は "problem"(YAML の default と同じ)★
const [areaFilter, setAreaFilter] = useState<AreaFilter>("problem");
const [areaData, setAreaData] = useState<AreaCompletionsResponse | null>(null);
const [areaLoading, setAreaLoading] = useState(false);
const [areaError, setAreaError] = useState<string | null>(null);
const fetchAll = useCallback(async () => {
setLoading(true);
setError(null);
try {
// ★`/api/dashboard` の1本だけ★(D-159 / 指示書AV D7)。コンパクト版ログは
// レスポンスの `problem_logs`(数値カードと★同じ `now`・同じ行の集合★の △×)を使う。
// ★`/api/clean-logs` を別に呼ばない★ — 2本に分けると、それぞれが別々に「現在」を取るので
// 境界(終了+落ち着き時間ちょうど付近)でカードと表の母集団がずれうる。
// トグルは持たせない(全件を見たいときは「全項目 →」で F-02 に遷移する切り分け)
const dashboard = await apiFetch<DashboardResponse>("/api/dashboard");
setData(dashboard);
} catch (e) {
// 失敗したら画面全体をエラー表示にする(部分表示はこの画面の要件にない)
setError(
(e instanceof ApiFetchError ? e.serverMessage : null) ??
"データの取得に失敗しました"
);
setData(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch 起点の初期表示
void fetchAll();
}, [fetchAll]);
/**
* 行を開く / 閉じる(`DetailLogScreen.tsx` からの移植。指示書AO 段階5)。
*
* ★compact でも展開する★ — かつて「ダッシュボードではアドバイスを一切出さない
* 決まりがある」とされていたが、そのような決まりはどの資料にも存在しなかった(grep 0件)。
* ユーザー判断で展開を有効にした(2026-09-16 / D-143 §3 #6)。
*/
async function toggleRow(row: CleanLogItem) {
if (expandedId === row.id) {
setExpandedId(null);
return;
}
setExpandedId(row.id);
if (adviceById[row.id]) return;
setAdviceLoadingId(row.id);
try {
const body = await apiFetch<CleanLogAdviceResponse>(
`/api/clean-logs/${row.id}/advice`
);
setAdviceById((prev) => ({ ...prev, [row.id]: body }));
} catch {
// 取得できなくても行は開く。一覧が持っている値で最小限の中身を埋める
setAdviceById((prev) => ({
...prev,
[row.id]: {
escalation_level: row.escalation_level,
consecutive_problem_days: row.consecutive_problem_days,
// ★一覧の行も同じ上限を通ってきている★(指示書AF 段階6。/clean-logs と
// /clean-logs/{id}/advice で連続日数が食い違わないようにするため)。
// 一覧が古くてフラグを持っていなければ「頭打ちではない」として扱う
consecutive_problem_days_capped:
row.consecutive_problem_days_capped ?? false,
max_level: 0,
advices: [],
},
}));
} finally {
setAdviceLoadingId(null);
}
}
/**
* エリアモーダルを開く。
*
* ★ここでは取得しない★ — 取得は下の `useEffect` に寄せてある。
* ★`filter` を切り替えたら**取り直す**必要がある★(絞り込みはサーバ側。指示書Z 決定8)ので、
* 「開いたとき」と「切り替えたとき」で取得経路が2本に分かれないようにした。
* ★開くたびに `filter` を既定(問題エリアのみ)に戻す★ — 前に見た行の選択が残ると、
* 別の行を開いたときに何を見ているのか分からなくなる。
*/
function openAreas(row: CleanLogItem) {
setAreaFilter("problem");
setAreaTarget({
logId: row.id,
facilityName: row.facility_name,
dateLabel: formatMd(row.scheduled_at),
});
}
const areaLogId = areaTarget?.logId ?? null;
useEffect(() => {
if (areaLogId === null) return;
// 取得中に別の行を開く / filter を切り替えると、古い応答が後から届いて
// 新しい表示を上書きしうるので破棄する
let cancelled = false;
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch 起点のモーダル更新
setAreaLoading(true);
setAreaError(null);
setAreaData(null);
void (async () => {
try {
const body = await apiFetch<AreaCompletionsResponse>(
`/api/clean-logs/${areaLogId}/areas?filter=${areaFilter}`
);
if (!cancelled) setAreaData(body);
} catch (e) {
// ★取得できなくてもモーダルは開いたままにする★ —
// ボタンを押して何も起きないのがいちばん困るため、失敗はモーダルの中に出す
if (!cancelled) {
setAreaError(
(e instanceof ApiFetchError ? e.serverMessage : null) ??
"データの取得に失敗しました"
);
}
} finally {
if (!cancelled) setAreaLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [areaLogId, areaFilter]);
// 「成功 / N件」の N は schedule_progress.planned とは別概念(D-049)。
// planned はスケジュールマスタ由来(当日)、こちらは summary の母集団の行数(成功+失敗+未実施+★結果待ち★)。
// ★結果待ちも N に入れる★(ユーザー判断 2026-10-07。D-173 / 表2 C8)— カード5枚の数の合計が N になる。
// ★成功率の分母には入らない★(API の `success_rate`。C5)ので、「成功 / N件」と成功率は一致しないことがある。
// ★直近モード(`date` なし。この画面)では、7日以内に終わった予定がある清掃エリアの数★
// (1清掃エリアにつき直近の1件。D-159)。日数は `DASHBOARD_LATEST_RUN.lookbackDays`。
// 施設数とは一致しない(1施設に複数の清掃エリアがある場合は施設数を上回る)。
// ラベルも「件」であって「施設」ではない。
const totalLogs = data
? data.summary.success +
data.summary.failed +
data.summary.not_executed +
data.summary.pending
: 0;
// ★先頭5件だけ出す★(並びは API のまま=データソースの順。画面で並べ替えない)
const compactLogs = data ? data.problem_logs.slice(0, COMPACT_LOG_ROWS) : [];
return (
<div className={styles.page}>
<div className={styles.header}>
<div>
<h1 className={styles.title}>{t("ダッシュボード")}</h1>
{/* ★モック(`d-watch-mock.jsx:476`)と文言が違う★ — D-159 でユーザーが変えた
(数値カードと清掃詳細ログは「各清掃エリアの直近の清掃(7日以内)」になったため。
旧: 当日の日付を添えた「昨夜の…」の見出し)。D-120 / D-121 と同じ扱いで、★モックは書き換えない★。
★日数は直書きしない★(`DASHBOARD_LATEST_RUN.lookbackDays` を {n} に差し込む。指示書AV 4-13)。
★このコメントを消さないこと★ — 次にモックと突き合わせた人が文言を戻してしまう */}
<div className={styles.subtitle}>
{t("直近の清掃結果サマリー(各清掃エリアの最後の清掃・{n}日以内)").replace(
"{n}",
String(DASHBOARD_LATEST_RUN.lookbackDays)
)}
</div>
</div>
<div className={styles.headerRight}>
{data && data.security_banner.length > 0 && (
<Pill text={`D-Sec ${data.security_banner.length}件`} color={c.wn} />
)}
</div>
</div>
{loading && <div className={styles.loading}>読み込み中…</div>}
{!loading && error && <div className={styles.error}>{error}</div>}
{!loading && !error && data && (
<>
<SecurityBanner alerts={data.security_banner} />
<div className={styles.statGrid}>
<StatCard
value={String(data.summary.success)}
label={t("成功")}
sub={`/ ${totalLogs}件`}
tone="ok"
/>
<StatCard
value={String(data.summary.failed)}
label={t("失敗")}
tone="ng"
onClick={() => router.push("/detail-log")}
/>
<StatCard
value={String(data.summary.not_executed)}
label={t("未実施")}
sub={t("予定時刻経過・実績なし")}
tone="wn"
onClick={() => router.push("/detail-log")}
/>
{/* ★結果待ち★(ユーザー判断 2026-10-07「ダッシュボードに『結果待ち』カードを足す」。D-173 / 表2 C8)—
レポートがまだ届いていない清掃(予定の終了から `RESULT_WAIT_MINUTES` 分以内)。失敗に数えない。
★モックに無いカード★(D-120 / D-121 と同じ扱いで、モックは書き換えない)。
★色は控えめに★: 中立の `mt`(成功率の `ac` と見分けるため。監督判断 2026-10-07)。 */}
<StatCard
value={String(data.summary.pending)}
label={t("結果待ち")}
sub={t("レポートの到着待ち")}
tone="mt"
/>
<StatCard
// "-" は displayRate() の既存の null 表記をそのまま使う(アプリ全体で
// completion_rate 等の null 表記と統一するため。ここだけ別の記号にしない)
value={displayRate(data.summary.success_rate)}
label={t("成功率")}
sub={`目標: ${SUCCESS_RATE_TARGET}%`}
tone="ac"
/>
</div>
{/*
要レスキュー店舗(ユーザー指示 2026-09-15 / 指示書AI 段階6)。
★位置は「数値カードの直後・清掃詳細ログの直前」で固定★ —
ユーザー指示の原文「要レスキュー店舗をダッシュボード上の詳細ログの上に表示」。
★★モックに対応する画面が無い(`CLAUDE.md` §5 の例外)★★ —
D-120 / D-121 と同じ扱いで、`d-watch-mock.jsx` は1文字も書き換えていない。
★このコメントを消さないこと★ — 次にモックと突き合わせた人が
「余計なものがある」と判断して消してしまう。理由は `RescueRequiredCard.tsx` の
docstring に書いてある。
★★数値カード(上)とは母集団が違う★★ — こちらは
★当日に清掃予定が無い施設も含む★(指示書AI §1-5)ので、件数は一致しない。
★数値カードは直近モード(各清掃エリアの直近の清掃・7日以内。D-159)、
要レスキューは当日基準★(基準日は当日のまま。指示書AV 4-1)。
*/}
<RescueRequiredCard items={data.rescue_required} />
<div className={styles.card}>
<div className={styles.cardHeader}>
<span className={styles.cardTitle}>{t("清掃詳細ログ")}</span>
<Link href="/detail-log" className={styles.allLink}>
{t("全項目 →")}
</Link>
</div>
<div className={styles.tableWrap}>
<CleanLogTable
rows={compactLogs}
compact
expandedId={expandedId}
adviceById={adviceById}
adviceLoadingId={adviceLoadingId}
showGradeInfo={showGradeInfo}
onToggleGradeInfo={() => setShowGradeInfo((v) => !v)}
onToggleRow={toggleRow}
onOpenAreas={openAreas}
/>
</div>
</div>
<div
className={styles.bottomGrid}
style={{
gridTemplateColumns: data.failure_breakdown ? "1fr 1fr" : "1fr",
}}
>
<ScheduleProgressCard progress={data.schedule_progress} />
{/*
failure_breakdown は M2 では返さない(D-048)。undefined のときは
このブロックごと非表示にする。M3 で値が来たら自動的に表示へ戻る作りにしておく
(プロパティを消してしまうと、値が来たときにここを書き直す必要が出る)
*/}
{data.failure_breakdown && (
<div className={`${styles.card} ${styles.panel}`}>
<div className={styles.failureHeading}>失敗の内訳</div>
{data.failure_breakdown.map((f) => (
<div key={f.type} className={styles.failureRow}>
<span>{f.label}</span>
<span>{f.count}</span>
</div>
))}
</div>
)}
</div>
</>
)}
{/* ★エリア別詳細はモーダルで開く(`/detail-log` へ遷移させない)★ —
モックは compact でもモーダルを開く(`d-watch-mock.jsx:358` は `LogTable` の
中にあり `compact` で分岐していない)。遷移にすると開いていた行が失われる。
★`components/features/detail-log/AreaModal` を import して使う(複製しない)★ */}
{areaTarget && (
<AreaModal
facilityName={areaTarget.facilityName}
dateLabel={areaTarget.dateLabel}
filter={areaFilter}
onChangeFilter={setAreaFilter}
data={areaData}
loading={areaLoading}
error={areaError}
onClose={() => setAreaTarget(null)}
/>
)}
</div>
);
}