"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTheme } from "@/components/theme/ThemeProvider";
import { apiFetch, ApiFetchError } from "@/lib/api/client";
import { CLEAN_AREA_GROUP_NONE_LABEL } from "@/lib/clean-area/aggregate";
import { CLEAN_AREA_SCOPES } from "@/lib/clean-area/query";
import { cleanAreaVisibility } from "@/lib/clean-area/visibility";
import { AREA_UNIT } from "@/lib/config";
import { formatArea, formatNumber } from "@/lib/format/number";
import { openDatePicker } from "@/components/ui/date-picker";
import { useClassificationFilter } from "@/lib/hooks/use-classification-filter";
import { displayRate } from "@/lib/judge";
import {
ALL_OPTION_VALUE,
appendClassificationParams,
EMPTY_SELECTION,
type ClassificationSelection,
} from "@/lib/masters/options";
import type { CleanAreaResponse, CleanAreaRow, CleanAreaScope } from "@/lib/types";
import styles from "./CleanAreaScreen.module.css";
/**
* 清掃面積(指示書AN ★段階5★ / §8)。
*
* ★★モック(`d-watch-mock.jsx`)に対応する画面がありません★★ —
* `CLAUDE.md` §5 は「モックが画面レイアウト・文言・遷移の正」と定めていますが、
* ★本画面はその例外★です(D-120 / D-121 / D-137 と同じ扱い)。
* 実測で `d-watch-mock.jsx` に「面積」「㎡」「累計」のヒットは0件でした。
* 根拠はユーザーの明示指示(2026-09-16)で、要件の原文は:
*
* > ・総清掃面積(指定期間での絞り込みも)
* > モニタリングに新項目追加、累計の清掃総面積表示、ホールディングス、
* > 各グループ(ブランド)、店舗、指定期間での絞り込み
*
* 「既存画面への追加か新画面か」を確認したところ、回答は ★「新画面」★ でした。
*
* ★★`d-watch-mock.jsx` は1文字も書き換えていません★★(モックは原型として保存する)。
* ★この注記を消さないこと★ — 次にモックと突き合わせた人が
* 「モックに無い=余計なもの」と判断して消してしまいます。
*
* ------------------------------------------------------------------------
* ★★この画面が持たないもの★★
* ------------------------------------------------------------------------
* - ★集計★ … `lib/clean-area/aggregate.ts`(API が済ませる。★フロントで再計算しない★。§9.6)
* - ★何件見せるか★ … `cleanAreaVisibility()`(`lib/clean-area/visibility.ts`。★段階2で新設済み★)
* - ★単位の文字列★ … `AREA_UNIT`(`lib/config`)。★`"㎡"` を直書きしない★(§2-4 / 落とし穴6)
* - ★分類の選択肢★ … `useClassificationFilter()`。★固定の選択肢を書かない★(D-053)
*
* ★丸めるのはこの層だけ★(`CLAUDE.md` §4.3 / §9.3)— API は生値を返す。
*/
/**
* 粒度タブのラベル。
*
* ★`Record<CleanAreaScope, string>` にしてあるのは、粒度が増えたときに
* ★ここを書き忘れると `tsc` が落ちる★ようにするため(`RescueRequiredCard` の
* `REASON_LABEL` と同じ作法)。★並び順は `CLEAN_AREA_SCOPES`(=YAML の enum)が持つ★ので
* ここには書かない(2箇所で順序を持たない)。
*
* ★「店舗」はユーザーの言葉★(指示書AN §8-3 の画面構成)。用語集(`CLAUDE.md` §8)は
* `facilities` を「施設」と呼ぶが、★分類の見出し語は「ホールディングス / 顧客 / 店舗」で
* 既に揃えてある★(指示書AK §2-5)のでそれに合わせる。
*/
const SCOPE_LABEL: Record<CleanAreaScope, string> = {
group: "ホールディングス",
customer: "顧客",
facility: "店舗",
};
/** 取得の失敗。`message` はサーバが返した文言(無ければ画面の既定文を出す) */
interface FetchFailure {
message: string | null;
}
export function CleanAreaScreen() {
const { t } = useTheme();
// ★入力中(draft)と確定を分ける★(指示書AN §8-4。`ReportsScreen.tsx` と同じ形)—
// ★この画面にモックは無い★が、条件が4つ(ホールディングス・屋号・施設名・期間2つ)あり、
// ★1つ変えるたびに集計クエリが走る★のは無駄が大きい。
// ★「検索」ボタンで一度に確定させる★のはレポート画面(モック:679)と同じ作法。
const [draftFacilityName, setDraftFacilityName] = useState("");
const [draftDateFrom, setDraftDateFrom] = useState("");
const [draftDateTo, setDraftDateTo] = useState("");
const [facilityName, setFacilityName] = useState("");
// ★期間の既定は「未指定」=全期間★(指示書AN §2-2)— ★開いた瞬間が「累計」★になる。
// ★`todayJst()` 等で初期値を入れないこと★(入れると既定が「今日」になり、
// ★累計を見るために毎回クリアする画面★になる)。
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const classification = useClassificationFilter(t);
const [confirmedClassification, setConfirmedClassification] =
useState<ClassificationSelection>(EMPTY_SELECTION);
// ★粒度タブは押した瞬間に効く★(検索ボタンを待たない)— 絞り込み条件ではなく
// 「同じ母集団をどう割るか」の切替で、レポート画面の週次/月次トグルと同じ性質。
const [groupBy, setGroupBy] = useState<CleanAreaScope>("facility");
// ★「もっと表示」の開閉は画面のローカル state★(★URL に持たせない★。要レスキューと同じ)
const [expanded, setExpanded] = useState(false);
const [data, setData] = useState<CleanAreaResponse | null>(null);
const [failure, setFailure] = useState<FetchFailure | null>(null);
const [loading, setLoading] = useState(true);
const query = useMemo(() => {
const params = new URLSearchParams();
params.set("group_by", groupBy);
// ★未指定は送らない★(`?date_from=` の空文字も API 側で `null` に正規化されるが、
// ★「指定していない」ことがクエリ文字列から読めるほうがログを追いやすい★)
if (dateFrom) params.set("date_from", dateFrom);
if (dateTo) params.set("date_to", dateTo);
if (facilityName) params.set("facility_name", facilityName);
// ★クエリを手書きしない★ — `group_id=0` を漏らさない判断が
// `appendClassificationParams()` に入っている(D-011 / 指示書AK)
appendClassificationParams(params, confirmedClassification);
return params.toString();
}, [groupBy, dateFrom, dateTo, facilityName, confirmedClassification]);
useEffect(() => {
let cancelled = false;
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch 起点の取り直し
setLoading(true);
setFailure(null);
void (async () => {
try {
const body = await apiFetch<CleanAreaResponse>(`/api/clean-area?${query}`);
if (!cancelled) setData(body);
} catch (e) {
if (cancelled) return;
// ★失敗したら前の結果を消す★ — 条件を変えた直後に古い合計が残ると
// 「エラーと言いながら数字が出ている」状態になる
setData(null);
setFailure({
message: e instanceof ApiFetchError ? e.serverMessage : null,
});
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [query]);
const handleSearch = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
setFacilityName(draftFacilityName.trim());
setDateFrom(draftDateFrom);
setDateTo(draftDateTo);
setConfirmedClassification(classification.draft);
// ★条件が変われば件数も変わるので初期表示件数に戻す★
setExpanded(false);
},
[draftFacilityName, draftDateFrom, draftDateTo, classification.draft]
);
const changeScope = useCallback((next: CleanAreaScope) => {
setGroupBy(next);
// ★粒度を変えたら「もっと表示」を畳む★(他画面の慣習。レポートの期間トグルと同じ)—
// ★行の意味が変わるので、開いたままにすると別の粒度の展開状態を引き継いで見える★
setExpanded(false);
}, []);
const items = useMemo(() => data?.items ?? [], [data]);
const { visible, remaining, canToggle } = cleanAreaVisibility(items, expanded);
/**
* 行の表示名。
*
* ★★「グループなし」だけは API の `name` を使わない★★(指示書AN §5-3 B-3 / ユーザー指示)—
* API は `scope === "group"` かつ `id === null` の行に生文字列を載せてくるが、
* ★その文字列をそのまま `t()` に通すと、万一同名の実在する屋号名があったときに
* 誤訳する★(`t()` のキーは日本語原文なので、実在名と衝突しうる)。
* ★`id === null && scope === "group"` を条件にして、画面側の翻訳キー
* (`CLEAN_AREA_GROUP_NONE_LABEL`)を使う★。★`name` の値は無視する★。
* 定数を `lib/clean-area/aggregate.ts` から取っているのは、
* ★文言を変えるときに直すのが1行で済む★ようにするため。
*/
const rowLabel = useCallback(
(row: CleanAreaRow): string =>
row.scope === "group" && row.id === null
? t(CLEAN_AREA_GROUP_NONE_LABEL)
: row.name,
[t]
);
/** 行の所属(施設なら屋号、屋号ならホールディングス)。粒度がグループのときは出さない */
const rowParent = useCallback(
(row: CleanAreaRow): string | null => {
if (row.scope === "facility") return row.customer_name;
if (row.scope === "customer") {
return row.group_name ?? t(CLEAN_AREA_GROUP_NONE_LABEL);
}
return null;
},
[t]
);
const total = data?.total ?? null;
return (
<div className={styles.page}>
<div className={styles.header}>
<h1 className={styles.title}>{t("清掃面積")}</h1>
</div>
<form className={styles.toolbar} onSubmit={handleSearch}>
{/* ★分類の絞り込み★
・選択肢は `/groups` と `/customers` から取る(★固定の選択肢を書かない★)
・★取得に失敗した/屋号が0件のときは出さない★(`available` が false)
・★option のラベルを `t()` に通さないこと★ — フック側で `t()` 済みで、
屋号名まで通すと辞書の対訳に誤って当たる */}
{classification.available && (
<>
{classification.hasGroups && (
<select
/* ★幅を固定する★(選んだ名前の長さで幅が変わらないように)。★切れた名前はホバーで読めるよう、
選択中の表示名を title に出す★(ユーザー指示 2026-10-06。D-162) */
className={`${styles.select} ${styles.masterSelect}`}
aria-label={t("ホールディングス")}
title={
classification.groupOptions.find(
(o) =>
o.value ===
String(classification.draft.groupId ?? ALL_OPTION_VALUE),
)?.label
}
value={
classification.draft.groupId === null
? ALL_OPTION_VALUE
: String(classification.draft.groupId)
}
onChange={(e) => classification.selectGroup(e.target.value)}
>
{classification.groupOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
)}
<select
/* 幅の固定と title は上のホールディングスと同じ(D-162) */
className={`${styles.select} ${styles.masterSelect}`}
aria-label={t("顧客")}
title={
classification.customerOptions.find(
(o) =>
o.value ===
String(classification.draft.customerId ?? ALL_OPTION_VALUE),
)?.label
}
value={
classification.draft.customerId === null
? ALL_OPTION_VALUE
: String(classification.draft.customerId)
}
onChange={(e) => classification.selectCustomer(e.target.value)}
>
{classification.customerOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</>
)}
<input
className={styles.input}
placeholder="施設名"
aria-label={t("施設名")}
value={draftFacilityName}
onChange={(e) => setDraftFacilityName(e.target.value)}
/>
<input
type="date"
/* ★枠のどこを押してもカレンダーを開く★(ユーザー指示 2026-10-06。D-162) */
onClick={openDatePicker}
className={styles.dateInput}
aria-label={t("開始日")}
value={draftDateFrom}
onChange={(e) => setDraftDateFrom(e.target.value)}
/>
<span className={styles.dateSep}>~</span>
<input
type="date"
onClick={openDatePicker}
className={styles.dateInput}
aria-label={t("終了日")}
value={draftDateTo}
onChange={(e) => setDraftDateTo(e.target.value)}
/>
<button type="submit" className={styles.searchBtn}>
{t("検索")}
</button>
</form>
{loading && <div className={styles.loading}>{t("読み込み中…")}</div>}
{!loading && failure && (
<div className={styles.error}>
{failure.message ?? t("データの取得に失敗しました")}
</div>
)}
{!loading && !failure && total && (
<>
{/* ---- 数値カード(全体合計。★粒度を変えても変わらない★) ---- */}
<div className={styles.cards}>
<div className={styles.card}>
<div className={styles.cardLabel}>{t("清掃面積")}</div>
<div className={styles.cardValue}>
{formatNumber(total.clean_area)}
{/* ★単位は `AREA_UNIT`(`lib/config`)から出す★ — ★直書きしない★ */}
<span className={styles.cardUnit}>{AREA_UNIT}</span>
</div>
</div>
<div className={styles.card}>
<div className={styles.cardLabel}>{t("清掃対象面積")}</div>
<div className={styles.cardValue}>
{formatNumber(total.task_area)}
<span className={styles.cardUnit}>{AREA_UNIT}</span>
</div>
</div>
<div className={styles.card}>
<div className={styles.cardLabel}>{t("完了率")}</div>
{/* ★丸めるのはここだけ★(API は生値を返す。`CLAUDE.md` §4.3)。
★分母0は `-`★(`displayRate` が `null` を `-` にする。0% と書かない) */}
<div className={styles.cardValue}>
{displayRate(total.completion_rate)}
</div>
</div>
<div className={styles.card}>
<div className={styles.cardLabel}>{t("清掃実施回数")}</div>
{/* ★単位(「回」)は付けない★ — 見出しが「清掃実施回数」で足りるうえ、
★EN では数詞が付かない★(辞書に空文字を入れると `translate()` が
falsy 判定で和文に戻る)。数だけを出す */}
<div className={styles.cardValue}>
{formatNumber(total.executed_count)}
</div>
</div>
</div>
{/* ★★「直近2年ぶん」を明記する★★(指示書AN §2-2 / 落とし穴10)—
★書かないと、3年目に数字が減ったときに問い合わせが来る★。
データの保持は2年(`docs/spec.md`)なので、期間未指定でも厳密には「累計」ではない。 */}
<div className={styles.note}>
{t(
"期間を指定しないときは全期間の合計です。ただしデータの保持は2年なので、実際には直近2年ぶんの合計になります。"
)}
</div>
{/* ★★面積と、完了率・実施回数で母集団が違う★★(D-155 項3 / D-156 論点7)—
面積は予定外の清掃(`clean_timetable_id` が NULL)を含み、完了率と実施回数は含まない。
★書かないと「面積はあるのに実施回数0」の施設を見た人が不具合と思う★ */}
<div className={styles.note}>
{t("清掃面積には予定外の清掃を含みます。完了率と実施回数は予定された本清掃だけです")}
</div>
{/* ---- 内訳 ---- */}
<div className={styles.breakdown}>
<div className={styles.breakdownHeader}>
<div className={styles.scopeTabs}>
{/* ★並び順は `CLEAN_AREA_SCOPES`(YAML の enum)が持つ★ */}
{CLEAN_AREA_SCOPES.map((scope) => (
<button
key={scope}
type="button"
className={`${styles.scopeTab} ${
groupBy === scope ? styles.scopeTabActive : ""
}`}
onClick={() => changeScope(scope)}
>
{t(SCOPE_LABEL[scope])}
</button>
))}
</div>
{/* ★件数は絞る前の総数★(`visible.length` ではない) */}
<span className={styles.count}>
{t("{n}件").replace("{n}", String(items.length))}
</span>
</div>
{items.length === 0 ? (
<div className={styles.empty}>{t("該当データがありません")}</div>
) : (
<>
<div className={styles.tableWrap}>
<table className={styles.table}>
{/* ★列幅は言語によらず固定する★(2026-10-01 ユーザー指摘「言語切り替え時に
表などの位置も動いてしまいます」)。`table-layout: fixed` と組で効く。
★名前の列(店舗 / 顧客 / ホールディングス)だけ幅を指定せず、残りを持たせる★ */}
<colgroup>
<col className={styles.colName} />
<col className={styles.colArea} />
<col className={styles.colArea} />
<col className={styles.colRate} />
<col className={styles.colCount} />
</colgroup>
<thead>
<tr>
<th className={styles.thName}>
{t(SCOPE_LABEL[groupBy])}
</th>
<th className={styles.thNum}>{t("清掃面積")}</th>
<th className={styles.thNum}>{t("清掃対象面積")}</th>
<th className={styles.thNum}>{t("完了率")}</th>
<th className={styles.thNum}>{t("清掃実施回数")}</th>
</tr>
</thead>
<tbody>
{/* ★並び替えはAPI(面積の降順 → IDの昇順)★。★画面で並べ替えない★ */}
{visible.map((row) => (
<tr key={`${row.scope}:${row.id ?? "none"}`}>
<td className={styles.tdName}>
<div className={styles.rowName}>{rowLabel(row)}</div>
{rowParent(row) !== null && (
<div className={styles.rowParent}>
{rowParent(row)}
</div>
)}
</td>
{/* ★単位は `formatArea()` が `AREA_UNIT` から付ける★ */}
<td className={styles.tdNum}>
{formatArea(row.clean_area)}
</td>
<td className={styles.tdNum}>
{formatArea(row.task_area)}
</td>
<td className={styles.tdNum}>
{displayRate(row.completion_rate)}
</td>
<td className={styles.tdNum}>
{formatNumber(row.executed_count)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* ★総数が初期表示件数を超えるときだけ出す★(ちょうど同数なら出さない)—
判定は `cleanAreaVisibility()`(`lib/` の純粋関数)が持つ。
★開いたら「閉じる」に切り替える★(開きっぱなしで戻せないのは不便) */}
{canToggle && (
<div className={styles.footer}>
<button
type="button"
className={styles.linkBtn}
onClick={() => setExpanded((prev) => !prev)}
>
{expanded
? t("閉じる")
: t("もっと表示(あと{n}件)").replace(
"{n}",
String(remaining)
)}
</button>
</div>
)}
</>
)}
</div>
</>
)}
</div>
);
}