Newer
Older
client_dwatch / components / features / detail-log / AreaModal.tsx
"use client";

import { useEffect } from "react";
import { useTheme } from "@/components/theme/ThemeProvider";
import { FilterButton } from "@/components/ui/FilterButton";
import { GradeBadge } from "@/components/ui/GradeBadge";
import { ProgressBar } from "@/components/ui/ProgressBar";
import type { AreaFilter } from "@/lib/clean-logs/areas";
import { CLEAN_RESULT_PENDING_LABEL } from "@/lib/format/clean-result";
import { displayRate, isProblemGrade } from "@/lib/judge";
import { THEME_TOKENS } from "@/lib/theme";
import type { AreaCompletionsResponse, Grade } from "@/lib/types";
import styles from "./AreaModal.module.css";

interface AreaModalProps {
  facilityName: string;
  dateLabel: string;
  /** ★サーバ側で絞るので、切り替えたら親が再取得する★(指示書Z 決定8) */
  filter: AreaFilter;
  onChangeFilter: (filter: AreaFilter) => void;
  /** 取得できていないあいだは null(`loading` / `error` を見て出し分ける) */
  data: AreaCompletionsResponse | null;
  loading: boolean;
  error: string | null;
  onClose: () => void;
}

/** バーの色(モックと同じ対応)。★判定そのものはAPIが返した `grade` を使う★ */
function barTone(grade: Grade): "ok" | "wn" | "ng" {
  if (grade === "◎" || grade === "○") return "ok";
  if (grade === "△") return "wn";
  return "ng";
}

/**
 * エリア別清掃完了率のモーダル(F-02 の「エリア別詳細を見る →」から開く)。
 *
 * ★★並び順・判定・件数はすべてAPIが返したものをそのまま使う★★(`CLAUDE.md` §9.6)—
 *   ★フロントで並べ替え・再判定をしないこと★。`filter` の絞り込みもサーバ側。
 *
 * ★行の完了率は1回ごとの完了率★(`runCompletionRate()`。`min(clean_area ÷ task_area × 100, 100)` を
 *   小数第1位まで切り捨て。D-155)。
 * ★全体率(ヘッダの「全体」)は行の完了率の単純平均(清掃1回=1票)★なので、
 *   ★★行の完了率を足し合わせて件数で割れば全体率と一致する★★(未実施=完了率が `null` の行は
 *   分子にも分母にも入れない)。計算は APIルート側の `buildAreaCompletions()`
 *   (`lib/clean-logs/areas.ts`)。★以前は全体だけ面積ベースの「意図した非対称」だった★
 *   (指示書Z 決定7)が、2026-09-30 に単純平均へ揃えた(D-151。指示書AQ §5-5)。
 *   ★2026-10-02 に、平均する材料を `clean_ratio` 列から1回ごとの完了率へ変えた(D-155。指示書AS)★。
 *
 * ★未実施のエリアは `completion_rate` が `null`★ → `displayRate()` が `-` を返し、
 *   バーは 0 幅、判定は `×`。★`0%` と表示しないこと★(D-107 落とし穴1)。
 */
export function AreaModal({
  facilityName,
  dateLabel,
  filter,
  onChangeFilter,
  data,
  loading,
  error,
  onClose,
}: AreaModalProps) {
  const { t, theme } = useTheme();
  const c = THEME_TOKENS[theme];

  // ★Esc で閉じる★(指示書BC 5-4。フォーカスの移動・背景のスクロールの停止は本書ではやらない — §7-8)
  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [onClose]);

  return (
    <div className={styles.overlay} role="dialog" aria-modal="true">
      <div className={styles.panel}>
        <div className={styles.header}>
          <div className={styles.headerRow}>
            <div>
              <div className={styles.title}>{t("エリア別清掃完了率")}</div>
              <div className={styles.meta}>
                {facilityName} | {dateLabel}
                {data && (
                  <>
                    {" "}
                    | {t("全体")} {displayRate(data.overall_completion_rate)}{" "}
                    {data.overall_grade}
                  </>
                )}
              </div>
            </div>
            <button type="button" className={styles.close} onClick={onClose} aria-label={t("閉じる")}>
              ✕
            </button>
          </div>
          <div className={styles.filters}>
            <FilterButton
              active={filter === "problem"}
              color={c.pr}
              onClick={() => onChangeFilter("problem")}
            >
              {t("問題エリアのみ")}
            </FilterButton>
            <FilterButton
              active={filter === "all"}
              color={c.pr}
              onClick={() => onChangeFilter("all")}
            >
              {t("全件")}
            </FilterButton>
          </div>
        </div>

        <div className={styles.body}>
          {loading && <div className={styles.message}>{t("読み込み中…")}</div>}
          {!loading && error && <div className={styles.message}>{error}</div>}
          {!loading && !error && data && (
            <>
              <div className={styles.countLine}>
                {filter === "problem"
                  ? t("問題エリア({n} / {m}エリア)")
                      .replace("{n}", String(data.problem_count))
                      .replace("{m}", String(data.total_count))
                  : t("全{n}エリア(完了率昇順)").replace(
                      "{n}",
                      String(data.total_count)
                    )}
              </div>
              {data.items.length === 0 && (
                <div className={styles.message}>
                  {/* ★「全部◎○だった」と「そもそもエリアが1つも無い」を区別する★ —
                      同じ文言にすると、データが取れていないことに気づけない */}
                  {data.total_count === 0
                    ? t("エリアのデータがありません")
                    : t("問題エリアはありません(全エリア◎○)")}
                </div>
              )}
              {data.items.map((item) => {
                // ★結果待ち(item.result_pending。指示書BB A3 / D-173)★ — レポートがまだ届いていないだけかもしれない。
                //   ★判定(grade は "×" のまま)を見せない・問題エリアの赤字にしない★(清掃詳細ログの結果待ちと同じ見せ方:
                //   完了率「-」・判定の札の代わりに「結果待ち」。`cleanLogRowView()` と同じ考え方)。
                // ★APIが返した `grade` / `result_pending` を見るだけ(閾値でここで判定し直さない)★
                const pending = item.result_pending;
                const problem = !pending && isProblemGrade(item.grade);
                return (
                  <div key={item.section_id} className={styles.row}>
                    <div
                      className={`${styles.areaName} ${problem ? styles.areaNameProblem : ""}`}
                      title={item.section_name}
                    >
                      {item.section_name}
                    </div>
                    <div className={styles.bar}>
                      {/* ★未実施・結果待ち(null)は 0 幅★ */}
                      <ProgressBar
                        percent={item.completion_rate ?? 0}
                        tone={barTone(item.grade)}
                      />
                    </div>
                    {/* ★丸めるのは表示のときだけ(`CLAUDE.md` §4.3)。未実施・結果待ちは `-`★。結果待ちは色を付けない */}
                    <div className={styles.rate} data-tone={pending ? undefined : barTone(item.grade)}>
                      {displayRate(item.completion_rate)}
                    </div>
                    {/* ★札と「結果待ち」は同じ固定幅の枠に入れる★ — 幅が行ごとに変わると完了率の列がずれる */}
                    <div className={styles.gradeSlot}>
                      {pending ? (
                        <span className={styles.pending}>{t(CLEAN_RESULT_PENDING_LABEL)}</span>
                      ) : (
                        <GradeBadge grade={item.grade} />
                      )}
                    </div>
                  </div>
                );
              })}
            </>
          )}
        </div>

        <div className={styles.footer}>
          <button type="button" className={styles.closeBtn} onClick={onClose}>
            {t("閉じる")}
          </button>
        </div>
      </div>
    </div>
  );
}