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

import { Fragment } from "react";
import { Pill } from "@/components/ui/Pill";
import { GradeBadge } from "@/components/ui/GradeBadge";
import { SortableTh } from "@/components/ui/SortableTh";
import { useTheme } from "@/components/theme/ThemeProvider";
import { GRADE_THRESHOLDS } from "@/lib/config";
import { CLEAN_TASK_STATUS_UNKNOWN_TEMPLATE } from "@/lib/config";
import { CLEAN_LOG_UNSCHEDULED_LABEL, cleanLogRowView } from "@/lib/format/clean-result";
import { formatHm, formatMd } from "@/lib/format/date";
import { precheckMinutesLabel, precheckStatusCell } from "@/lib/format/precheck-status";
import type { Lang } from "@/lib/i18n";
import { displayRate } from "@/lib/judge";
import type { ThemeName } from "@/lib/theme";
import { statusColor } from "@/lib/theme-status";
import { displayOrder, type SortState } from "@/lib/table-sort";
import type { CleanLogAdviceResponse, CleanLogItem, CleanLogSortKey } from "@/lib/types";
import styles from "./CleanLogTable.module.css";

interface CleanLogTableProps {
  rows: CleanLogItem[];
  /**
   * ★開いている行・アドバイスは `row_key` で持つ★(指示書BG §2-1)— 予定なしの行は `id` が null で重なるため `row_key` を使う。
   * 予定あり・予定なしの △× どちらも開ける(指示書BI)
   */
  expandedKey: string | null;
  adviceByKey: Record<string, CleanLogAdviceResponse | undefined>;
  adviceLoadingKey: string | null;
  /** アドバイスの取得に失敗した行(★「該当なし」と区別して出す★。指示書BC 2-1) */
  adviceFailedKeys: ReadonlySet<string>;
  showGradeInfo: boolean;
  onToggleGradeInfo: () => void;
  onToggleRow: (row: CleanLogItem) => void;
  onOpenAreas: (row: CleanLogItem) => void;
  /**
   * true のときはダッシュボード(F-01)用の5列表示にする
   * (施設名 / 予定 / 出発 / 完了率 / 判定)。
   * ★落とす列★は 機種・シリアル・日付・事前チェック1回目・2回目・清掃結果。
   * (出す列を並べると列を足すたびに書き換えが要るため、落とすほうを書く)
   * ★compact では日付を予定のセルに含める(D-159)★ — 「予定」のセルを `M/D HH:mm`(例 `10/5 19:00`)にする。
   * ダッシュボードの行は各清掃エリアの直近の清掃(7日以内)で、行ごとに日付が違うため。日付の列は足さない。
   * 足すのは「出発」1列だけと決めた — ユーザー判断(2026-09-16。D-143 §3 #3)。
   * ★compact でも行展開する★(モック `d-watch-mock.jsx:311` は compact を見ていない)。
   * かつて「ダッシュボードではアドバイスを一切出さない決まりがある」と書かれていたが、
   * ★そのような決まりはどの資料にも存在しなかった★(grep 0件。実装者の自己申告)。
   * ★出所の書けない根拠をここに書き足さないこと★。
   * ユーザー判断で展開を有効にした(2026-09-16 / D-143 §3 #6 / 指示書AO 段階5)。
   * F-02の詳細ログ表と表を分けると修正が2箇所に散るため、
   * 新規コンポーネントは作らずこのテーブルの prop で分岐する。
   */
  compact?: boolean;
  /** 並べ替え状態(詳細ログ画面のみ。ダッシュボードは渡さない) */
  sort?: SortState<CleanLogSortKey>;
  onSort?: (key: CleanLogSortKey) => void;
}

function adviceHead(maxLevel: number): { className: string; wrap: string; text: string } {
  if (maxLevel >= 3) {
    return {
      className: styles.headL3,
      wrap: styles.adviceWrapL3,
      text: "🔴 レベル3 — DFA介入を検討",
    };
  }
  if (maxLevel >= 2) {
    return {
      className: styles.headL2,
      wrap: styles.adviceWrapL2,
      text: "🔶 レベル2 — DFA運用担当がアクション",
    };
  }
  return {
    className: styles.headL1,
    wrap: styles.adviceWrapL1,
    text: "⚠ レベル1 — 現場確認が必要",
  };
}

/**
 * 「事前チェック1回目 / 2回目」のセルの中身(D-171 / D-178)。
 * ★列は「その清掃の何回目の事前チェックか」★(早い方が1回目)。★何分前かは Pill の横に小さく出す★
 * (JP「60分前」/ EN「60 min」。`precheckMinutesLabel()`)— 事前チェックの時刻は設定で変わる(例 60/30 → 60/10)ため、
 * 見出しに分数を書かない(ユーザー判断 2026-10-16「案1」。D-178。旧見出し「1h前ST / 30分前ST」)。
 * ★null(その回の事前チェックの予定が無い)は Pill を出さず「-」(色なし・控えめ)★。JP/EN 共通の記号なので `t()` に通さない。
 * ★Pill の値は生値のまま★(`t()` を通さない。D-077)。分数が null(本来は起きない)なら Pill だけ。
 */
function PrecheckStatus({
  theme,
  lang,
  status,
  minutes,
}: {
  theme: ThemeName;
  lang: Lang;
  status: string | null;
  minutes: number | null;
}) {
  const cell = precheckStatusCell(status);
  if (cell.kind === "none") {
    return <span className={styles.statusNone}>{cell.text}</span>;
  }
  const pill = <Pill text={cell.text} color={statusColor(theme, cell.text)} />;
  const minutesLabel = precheckMinutesLabel(lang, minutes);
  if (minutesLabel === null) return pill;
  // 列幅に収まらないとき(Pill の値が長いとき)は分数が Pill の下に回る(`.precheckCell` の flex-wrap)
  return (
    <div className={styles.precheckCell}>
      {pill}
      <span className={styles.precheckMinutes}>{minutesLabel}</span>
    </div>
  );
}

export function CleanLogTable({
  rows,
  expandedKey,
  adviceByKey,
  adviceLoadingKey,
  adviceFailedKeys,
  showGradeInfo,
  onToggleGradeInfo,
  onToggleRow,
  onOpenAreas,
  compact = false,
  sort,
  onSort,
}: CleanLogTableProps) {
  const { t, theme, lang } = useTheme();
  const sortable = onSort != null;

  return (
    <table className={`${styles.table} ${compact ? styles.compact : ""}`}>
      {/* ★列幅は <col> で固定する(`table-layout: fixed`)★ — 2026-10-01 ユーザー指摘
          「言語切り替え時に表などの位置も動いてしまいます」。中身(見出しの文字)から幅を
          自動で決めると、見出しが英語になったとき列ごとに幅が変わり表が横に崩れていた。
          ★全列に幅を付け、余りは全列に比で配る★(通常表示・縮小表示とも。D-167)。幅の値と根拠は CSS 側。
          ★<col> の並びは下の <th> / <td> と1対1★ — 列を足し引きするときは3箇所を揃えること */}
      <colgroup>
        <col className={styles.colFacility} />
        {!compact && <col className={styles.colModel} />}
        {!compact && <col className={styles.colSerial} />}
        {!compact && <col className={styles.colDate} />}
        <col className={styles.colScheduled} />
        {!compact && <col className={styles.colPrecheck1} />}
        {!compact && <col className={styles.colPrecheck2} />}
        <col className={styles.colDeparted} />
        {!compact && <col className={styles.colResult} />}
        <col className={styles.colRate} />
        <col className={styles.colGrade} />
      </colgroup>
      <thead>
        <tr>
          <th className={styles.th} style={{ textAlign: "left" }}>
            {t("施設名")}
          </th>
          {!compact &&
            (sortable ? (
              <SortableTh
                label={t("機種")}
                order={displayOrder(sort ?? null, "model_name", "scheduled_at")}
                onClick={() => onSort!("model_name")}
                ariaLabel={t("機種で並べ替え")}
                align="left"
              />
            ) : (
              <th className={styles.th} style={{ textAlign: "left" }}>
                {t("機種")}
              </th>
            ))}
          {!compact &&
            (sortable ? (
              <SortableTh
                label={t("シリアル")}
                order={displayOrder(sort ?? null, "serial_number", "scheduled_at")}
                onClick={() => onSort!("serial_number")}
                ariaLabel={t("シリアルで並べ替え")}
                align="left"
              />
            ) : (
              <th className={styles.th} style={{ textAlign: "left" }}>
                {t("シリアル")}
              </th>
            ))}
          {!compact &&
            (sortable ? (
              <SortableTh
                label={t("日付")}
                order={displayOrder(sort ?? null, "scheduled_at", "scheduled_at")}
                onClick={() => onSort!("scheduled_at")}
                ariaLabel={t("日付で並べ替え")}
                align="left"
              />
            ) : (
              <th className={styles.th} style={{ textAlign: "left" }}>
                {t("日付")}
              </th>
            ))}
          <th className={styles.th} style={{ textAlign: "left" }}>
            {t("予定")}
          </th>
          {!compact && (
            <th className={styles.th} style={{ textAlign: "left" }}>
              {t("事前チェック1回目")}
            </th>
          )}
          {!compact && (
            <th className={styles.th} style={{ textAlign: "left" }}>
              {t("事前チェック2回目")}
            </th>
          )}
          {sortable ? (
            <SortableTh
              label={t("出発")}
              order={displayOrder(sort ?? null, "departed", "scheduled_at")}
              onClick={() => onSort!("departed")}
              ariaLabel={t("出発で並べ替え")}
              align="center"
            />
          ) : (
            <th className={styles.th} style={{ textAlign: "center" }}>
              {t("出発")}
            </th>
          )}
          {!compact &&
            (sortable ? (
              <SortableTh
                label={t("清掃結果")}
                order={displayOrder(sort ?? null, "task_status", "scheduled_at")}
                onClick={() => onSort!("task_status")}
                ariaLabel={t("清掃結果で並べ替え")}
                align="left"
              />
            ) : (
              <th className={styles.th} style={{ textAlign: "left" }}>
                {t("清掃結果")}
              </th>
            ))}
          {sortable ? (
            <SortableTh
              label={t("完了率")}
              order={displayOrder(sort ?? null, "completion_rate", "scheduled_at")}
              onClick={() => onSort!("completion_rate")}
              ariaLabel={t("完了率で並べ替え")}
              align="right"
            />
          ) : (
            <th className={styles.th} style={{ textAlign: "right" }}>
              {t("完了率")}
            </th>
          )}
          {sortable ? (
            <SortableTh
              label={t("判定")}
              order={displayOrder(sort ?? null, "grade", "scheduled_at")}
              onClick={() => onSort!("grade")}
              ariaLabel={t("判定で並べ替え")}
              align="center"
            >
              <button
                type="button"
                className={styles.helpBtn}
                onClick={onToggleGradeInfo}
                aria-label={t("判定基準")}
              >
                ?
              </button>
            </SortableTh>
          ) : (
            <th className={styles.th} style={{ textAlign: "center" }}>
              {t("判定")}{" "}
              <button
                type="button"
                className={styles.helpBtn}
                onClick={onToggleGradeInfo}
                aria-label={t("判定基準")}
              >
                ?
              </button>
            </th>
          )}
        </tr>
      </thead>
      {showGradeInfo && (
        <caption style={{ captionSide: "bottom", padding: 0 }}>
          <div className={styles.legend}>
            <span className={styles.legendTitle}>判定基準(タスク完了率):</span>
            <span className={styles.legendOk}>
              ◎ {GRADE_THRESHOLDS.excellent}%以上
            </span>
            <span className={styles.legendOk}>
              ○ {GRADE_THRESHOLDS.good}%以上
            </span>
            {/* ★「10-69%」のような表記にしないこと★ — 69.5 がどこにも該当しない
                読み方になる。境界を含むか含まないかが読み取れる書き方にする(D-062)。
                ★`good - 1` のような計算もしない★(閾値が小数になった途端に嘘になる) */}
            <span className={styles.legendWn}>
              △ {GRADE_THRESHOLDS.poor}%以上{GRADE_THRESHOLDS.good}%未満
            </span>
            <span className={styles.legendNg}>
              × {GRADE_THRESHOLDS.poor}%未満
            </span>
            <span className={styles.legendNote}>(閾値はシステム設定で変更可能)</span>
            <button
              type="button"
              className={styles.legendClose}
              onClick={onToggleGradeInfo}
              aria-label={t("閉じる")}
            >
              ✕
            </button>
          </div>
        </caption>
      )}
      <tbody>
        {rows.length === 0 && (
          <tr>
            <td className={styles.empty} colSpan={compact ? 5 : 11}>
              {t("該当するログはありません")}
            </td>
          </tr>
        )}
        {rows.map((r) => {
          // ★1行の見え方は `cleanLogRowView()` の1か所で決める(CSV と共有)★。
          //   ★結果待ち(`result_pending`。D-173)★は 出発「…」・清掃結果「結果待ち」・完了率「-」・判定「-」・
          //   ★行を開けない★(`grade` は "×" のままなので、旗を見ないと開けてアドバイスが出る)。
          //   ★予定なしの行も △× なら開ける★(アドバイスは unscheduled 入口。エリア別詳細は `view.areaDetail`。指示書BI)
          const view = cleanLogRowView(r);
          const hasAdvice = view.expandable;
          const isExpanded = expandedKey === r.row_key;
          const advice = adviceByKey[r.row_key];
          const maxLevel = advice?.max_level ?? 0;
          const head = adviceHead(maxLevel);
          return (
            <Fragment key={r.row_key}>
              <tr
                className={`${styles.row} ${hasAdvice ? styles.rowClickable : ""} ${isExpanded ? styles.rowExpanded : ""}`}
                onClick={hasAdvice ? () => onToggleRow(r) : undefined}
                /* ★開ける行はキーボードでも開け閉めできる★(Tab → Enter / Space。表の行のまま role は変えない。指示書BC 5-4)。
                   行の中の部品(ボタン等)で押したキーは拾わない */
                tabIndex={hasAdvice ? 0 : undefined}
                aria-expanded={hasAdvice ? isExpanded : undefined}
                onKeyDown={
                  hasAdvice
                    ? (e) => {
                        if (e.target !== e.currentTarget) return;
                        if (e.key === "Enter" || e.key === " ") {
                          e.preventDefault();
                          onToggleRow(r);
                        }
                      }
                    : undefined
                }
              >
                <td className={styles.td}>{r.facility_name}</td>
                {/* ★有効なロボットがいない清掃エリアは null → 「-」★(指示書BB U2・D6。t() に通さない) */}
                {!compact && <td className={styles.tdMuted}>{r.model_name ?? "-"}</td>}
                {!compact && <td className={styles.tdSerial}>{r.serial_number ?? "-"}</td>}
                {!compact && <td className={styles.tdDate}>{formatMd(r.scheduled_at)}</td>}
                {/* ★compact では日付を予定のセルに含める★(例 `10/5 19:00`。D-159 / 指示書AV 段階5-3)—
                    ダッシュボードは「各清掃エリアの直近の清掃(7日以内)」なので行ごとに日付が違う。
                    ★列は増やさない★(日付列は compact では落としたまま)。compact でないときは時刻だけ(日付列が別にある) */}
                {/* ★予定外タスクの行は時刻の代わりに「予定なし」★(`t()` を通す。EN "No schedule"。指示書BG §2-1。
                    文言はユーザー指示 2026-11-04「予定なし、予定ありに統一して」)。
                    日付は `scheduled_at`(= `started_at`)の日付 */}
                <td className={styles.td}>
                  {compact
                    ? `${formatMd(r.scheduled_at)} ${r.unscheduled ? t(CLEAN_LOG_UNSCHEDULED_LABEL) : formatHm(r.scheduled_at)}`
                    : r.unscheduled
                      ? t(CLEAN_LOG_UNSCHEDULED_LABEL)
                      : formatHm(r.scheduled_at)}
                </td>
                {/* ★`status_1h` = 1回目・`status_30m` = 2回目★(フィールド名は互換のため旧名のまま。60分前・30分前とは限らない。D-178)。
                    ★null(その回の事前チェックの予定が無い)は Pill を出さず「-」★(ユーザー指示 2026-10-07。D-171)。
                    分岐は `precheckStatusCell()` の1か所。★`statusColor()` に null を渡さない★ */}
                {!compact && (
                  <td className={`${styles.td} ${styles.tdStatus}`}>
                    <PrecheckStatus
                      theme={theme}
                      lang={lang}
                      status={r.status_1h}
                      minutes={r.status_1h_minutes}
                    />
                  </td>
                )}
                {!compact && (
                  <td className={`${styles.td} ${styles.tdStatus}`}>
                    <PrecheckStatus
                      theme={theme}
                      lang={lang}
                      status={r.status_30m}
                      minutes={r.status_30m_minutes}
                    />
                  </td>
                )}
                {/* ★結果待ちは「…」(`--tx-m`。✓✗ の色を使わない。D-173)★ — 縮小表示には清掃結果列が無いので
                    `title` で「結果待ち」と分かるようにする */}
                {view.departed === "pending" ? (
                  <td className={`${styles.tdCenter} ${styles.departedPending}`} title={t("結果待ち")}>
                    …
                  </td>
                ) : (
                  <td
                    className={`${styles.tdCenter} ${view.departed === "ok" ? styles.departedOk : styles.departedNg}`}
                  >
                    {view.departed === "ok" ? "✓" : "✗"}
                  </td>
                )}
                {/* ★表示名を `t()` に通す★(`clean_task_reports.status` の表示名。第10回 N-2。D-155 項2)。
                    ★表に無い値(7 以上など)は「不明(7)」(EN では "Unknown (7)")★(ユーザー指示 2026-10-30)。
                    `null`(レポート行が無い)は「出発失敗」(EN では "Launch failed")。
                    ★結果待ち(D-173)は「結果待ち」(EN では "Pending")★。
                    ★事前チェック1回目・2回目(Pill)は変えない★(`t()` を通さない。生値のまま。D-077)。 */}
                {!compact && (
                  <td className={styles.td}>
                    {view.unknownStatus !== null
                      ? t(CLEAN_TASK_STATUS_UNKNOWN_TEMPLATE).replace("{id}", String(view.unknownStatus))
                      : t(view.resultLabel)}
                  </td>
                )}
                <td className={styles.tdRight}>{displayRate(view.completionRate)}</td>
                <td className={styles.tdCenter}>
                  <div className={styles.gradeCell}>
                    {/* ★結果待ちはバッジを出さず「-」(D-173)★ — JP/EN 共通の記号なので `t()` に通さない */}
                    {view.gradeShown ? (
                      <GradeBadge grade={r.grade} />
                    ) : (
                      <span className={styles.statusNone}>-</span>
                    )}
                    {/* ★アドバイスが無い行も ▼ の幅を空けておく★(見えない ▼ を置く)— ▼ の有無でバッジの位置が
                        ずれないように(ユーザー指示 2026-10-29「この△と◯などのズレを揃えて」) */}
                    {hasAdvice ? (
                      <span className={styles.chevron}>{isExpanded ? "▲" : "▼"}</span>
                    ) : (
                      <span className={`${styles.chevron} ${styles.chevronPlaceholder}`} aria-hidden="true">
                        ▼
                      </span>
                    )}
                  </div>
                </td>
              </tr>
              {isExpanded && (
                <tr>
                  <td colSpan={compact ? 5 : 11} className={styles.adviceCell}>
                    <div className={`${styles.adviceWrap} ${head.wrap}`}>
                      {adviceLoadingKey === r.row_key && !advice && !adviceFailedKeys.has(r.row_key) && (
                        <div className={styles.adviceText}>{t("読み込み中…")}</div>
                      )}
                      {!advice && adviceFailedKeys.has(r.row_key) && (
                        <div className={styles.adviceText}>{t("アドバイスの取得に失敗しました")}</div>
                      )}
                      {advice && advice.advices.length === 0 && (
                        <div className={styles.adviceText}>{t("該当するアドバイスはありません")}</div>
                      )}
                      {advice && advice.advices.length > 0 && (
                        <>
                          <div className={`${styles.adviceHead} ${head.className}`}>
                            {head.text}
                          </div>
                          {advice.advices.map((a) => (
                            <div key={a.rule} className={styles.adviceCard}>
                              <div className={styles.adviceTitle}>
                                {a.icon} {a.title}
                              </div>
                              <div className={styles.adviceText}>{a.estimated_cause}</div>
                              {a.recommended_actions.length > 0 && (
                                <ul className={styles.adviceActions}>
                                  {a.recommended_actions.map((action) => (
                                    <li key={action}>{action}</li>
                                  ))}
                                </ul>
                              )}
                            </div>
                          ))}
                        </>
                      )}
                      {view.areaDetail && (
                        <>
                          {/* ★エリア別詳細は予定のある行だけ出す(`view.areaDetail`。予定の id が要る)。アドバイスの有無・取得の成否にはよらない(取得は別の API。指示書BC 2-1 / BI)★ */}
                          <button
                            type="button"
                            className={styles.areaBtn}
                            onClick={(e) => {
                              e.stopPropagation();
                              onOpenAreas(r);
                            }}
                          >
                            {t("エリア別詳細を見る →")}
                          </button>
                        </>
                      )}
                    </div>
                  </td>
                </tr>
              )}
            </Fragment>
          );
        })}
      </tbody>
    </table>
  );
}