Newer
Older
client_dwatch / components / features / clean-area / CleanAreaScreen.tsx
"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;
}

/** 取得の結果。★どの条件(粒度を含む)の結果かを持つ★(指示書BC 2-4。エラー一覧の FetchResult と同じ考え方) */
interface CleanAreaResult {
  filterQuery: string;
  groupBy: CleanAreaScope;
  data: CleanAreaResponse | null;
  failure: FetchFailure | 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);

  /** 取得の結果。★どの条件(粒度を含む)の結果かを持つ★(指示書BC 2-4。エラー一覧の FetchResult と同じ考え方) */
  const [result, setResult] = useState<CleanAreaResult | null>(null);

  /** ★粒度以外の条件★(検索ボタンで確定する: 期間・施設名・分類)。これが変わったときだけ画面全体を読み込み中にする */
  const filterQuery = useMemo(() => {
    const params = new URLSearchParams();
    // ★未指定は送らない★(`?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();
  }, [dateFrom, dateTo, facilityName, confirmedClassification]);

  const query = useMemo(() => {
    const params = new URLSearchParams(filterQuery);
    params.set("group_by", groupBy);
    return params.toString();
  }, [filterQuery, groupBy]);

  // ★取得中に条件を変えたら、遅れて届いた古い応答は捨てる★(片付けで cancelled にする)。
  // ★effect の中で同期的に state を変えない★ — 読み込み中は結果の条件と今の条件の食い違いで決める(指示書BC 2-4)
  useEffect(() => {
    let cancelled = false;
    void (async () => {
      try {
        const body = await apiFetch<CleanAreaResponse>(`/api/clean-area?${query}`);
        if (!cancelled) setResult({ filterQuery, groupBy, data: body, failure: null });
      } catch (e) {
        if (cancelled) return;
        // ★失敗したら前の結果を消す★ — 条件を変えた直後に古い合計が残ると
        //   「エラーと言いながら数字が出ている」状態になる
        setResult({
          filterQuery,
          groupBy,
          data: null,
          failure: { message: e instanceof ApiFetchError ? e.serverMessage : null },
        });
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [query, filterQuery, groupBy]);

  // ★検索の条件が変わった(または初回)→ 画面全体を読み込み中(今と同じ)★
  const loadingAll = result === null || result.filterQuery !== filterQuery;
  // ★粒度だけが変わった → 数値カード・注記・タブは残し、表だけ薄くする★(スクロールが飛ばないように)
  const loadingRows = !loadingAll && result.groupBy !== groupBy;
  const data = loadingAll ? null : result.data;
  const failure = loadingAll ? null : result.failure;
  // ★表の見出しと行の名前は、表示中のデータの粒度で出す★(タブの選択は押した `groupBy` のまま)
  const shownGroupBy = result?.groupBy ?? groupBy;

  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>

      {loadingAll && <div className={styles.loading}>{t("読み込み中…")}</div>}

      {!loadingAll && failure && (
        <div className={styles.error}>
          {failure.message ?? t("データの取得に失敗しました")}
        </div>
      )}

      {!loadingAll && !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>
          {/* ★★面積・完了率・実施回数のどれにも予定外の清掃(`clean_timetable_id` が NULL か 0)を含む★★
              (指示書BG §2-5。ユーザー指示 2026-10-29「エラー一覧以外も、予定外タスクを計算して出すようにしてください」)。
              ★旧: 面積だけが予定外を含み、完了率と実施回数は含まなかった(D-155 項3 / D-156 論点7)★。
              ★書かないと、清掃詳細ログの予定の件数と実施回数が合わないのを不具合と思う★ */}
          <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}>
                {loadingRows
                  ? t("読み込み中…")
                  : t("{n}件").replace("{n}", String(items.length))}
              </span>
            </div>

            {items.length === 0 ? (
              <div className={styles.empty}>{t("該当データがありません")}</div>
            ) : (
              <>
                {/* ★粒度を切り替えて取り直している間は前の行を薄く残す★(指示書BC 2-4) */}
                <div
                  className={
                    loadingRows ? `${styles.tableWrap} ${styles.tableLoading}` : styles.tableWrap
                  }
                  aria-busy={loadingRows}
                >
                  <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[shownGroupBy])}
                        </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>
  );
}