Newer
Older
client_dwatch / components / features / system / SystemScreen.tsx
"use client";

/**
 * システム管理(F-10)— 運用設定タブ
 *
 * ★編集できるのは「連続日数の表示上限」1つだけ★(指示書AF / ユーザー指示 2026-09-13 の A案)
 *   他の設定(事前チェック設定 / 完了率の判定閾値 / アドバイス エスカレーション閾値)は
 *   ★表示だけ★で、見出しに `編集不可` バッジを付けて見分けが付くようにしています。
 *   動かないトグル・入力欄・保存ボタンは置きません(触れるのに保存できないUIは事故のもと)。
 *
 * ★なぜ表示上限だけ先に編集できるのか★
 *   表示上限は ★判定に影響しません★(L1〜L3 と R1〜R4 は上限をかける前の生値で判定する)。
 *   他の4つは判定に使う閾値なので、変えると過去の判定結果と食い違います
 *   (昨日 ◎ だった清掃が今日は ○ になる)。広げる前にユーザー確認が要ります(指示書AF §H-1)。
 *
 * モック(d-watch-mock.jsx)には `ops` タブの定義が2箇所あり(:1221 と :1628)、
 * ここでは **:1628 側**を採用しています(1カード3セクション構成 / ×に「上記未満」表記 /
 * 「変更はDFAシステム管理者のみ」の注記あり)。
 *
 * ★判定閾値の文言は「以上」★(★D-139。2026-09-16★)
 *   ★★閾値ちょうどは**上位**の判定に入ります★★(90.0 は ◎、70.0 は ○、10.0 は △)。
 *   `lib/judge/grade.ts` の比較(`>=`)と食い違わせないこと。
 *
 *   ★経緯★: 以前は「モックは『◎基準%**以上**』と書いているが、確定仕様は『**超**』
 *   (閾値ちょうどは下位の判定になる)」として、モックの文言を退けていました。
 *   ★D-139 で境界の向きが逆になったため、いまは結果的にモックの「以上」が正しい★です。
 *   ★旧規則(『超』/ 下位の判定)の文言に戻さないこと★(D-020b は破棄)。
 *
 * ★閾値はすべて `lib/config` 由来。画面に数値をハードコードしない(CLAUDE.md §7)★
 * ★アドバイス文言は `GET /api/settings/advice` から取得する★
 *   `lib/config/advice-rules.ts` を直接 import すると既定値しか出せず、
 *   運用設定で編集済みの文言が画面に反映されません。
 */

import { useCallback, useEffect, useState } from "react";
import { useTheme } from "@/components/theme/ThemeProvider";
import { GradeBadge } from "@/components/ui/GradeBadge";
import { apiFetch, ApiFetchError } from "@/lib/api/client";
import {
  ESCALATION_THRESHOLDS,
  GRADE_THRESHOLDS,
  PRECHECK_MINUTES_BEFORE,
} from "@/lib/config";
import type { AdviceSetting, DisplayLimitSetting, Grade } from "@/lib/types";
import styles from "./SystemScreen.module.css";

interface AdviceListResponse {
  items: AdviceSetting[];
}

interface DisplayLimitListResponse {
  items: DisplayLimitSetting[];
}

/**
 * 画面が編集する表示上限のキー。
 *
 * ★`min` / `max` / `default` は**このキーで引いた API の返り値**を使うこと★ —
 * 画面に `3` / `30` / `365` を直書きすると、サーバ側の検証範囲とずれたときに
 * 「画面では入れられるのにサーバが422を返す」状態になります(`CLAUDE.md` §7 / 指示書AF 禁止事項4)。
 */
const CONSECUTIVE_DAYS_MAX_KEY = "consecutive_days_max";

type AdviceLevel = AdviceSetting["level"];

const LEVEL_LABEL: Record<AdviceLevel, string> = {
  1: "注意",
  2: "警告",
  3: "重大",
};

const LEVEL_CLASS: Record<AdviceLevel, string> = {
  1: styles.levelInfo,
  2: styles.levelWarn,
  3: styles.levelCritical,
};

const PHASE_LABEL: Record<AdviceSetting["phase"], string> = {
  A: "Phase A(完了率・連続日数)",
  B: "Phase B(状態・エラー種別)",
};

/**
 * ◎○△× の判定条件(CLAUDE.md §4.1)。
 * 上から順に評価するため、× は「上記のいずれにも当たらない場合」になる。
 *
 * ★境界は「以上」(D-139)★ — 90.0 は ◎、70.0 は ○。
 *   `lib/judge/grade.ts` の比較(`>=`)と文言を食い違わせないこと。
 */
const GRADE_RULES: { grade: Grade; condition: string }[] = [
  { grade: "◎", condition: `完了率 ${GRADE_THRESHOLDS.excellent}% 以上` },
  { grade: "○", condition: `完了率 ${GRADE_THRESHOLDS.good}% 以上` },
  { grade: "△", condition: `完了率 ${GRADE_THRESHOLDS.poor}% 以上` },
  {
    grade: "×",
    condition: `上記未満(完了率 ${GRADE_THRESHOLDS.poor}% 未満)、または未実施`,
  },
];

const ESCALATION_RULES: { level: AdviceLevel; days: number }[] = [
  { level: 2, days: ESCALATION_THRESHOLDS.level2Days },
  { level: 3, days: ESCALATION_THRESHOLDS.level3Days },
];

export function SystemScreen() {
  const { t } = useTheme();

  const [advice, setAdvice] = useState<AdviceSetting[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const fetchAdvice = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const body = await apiFetch<AdviceListResponse>("/api/settings/advice");
      setAdvice(body.items);
    } catch (e) {
      setError(
        (e instanceof ApiFetchError ? e.serverMessage : null) ??
          "アドバイス文言の取得に失敗しました"
      );
      setAdvice([]);
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect -- 初回マウント時の一覧取得
    void fetchAdvice();
  }, [fetchAdvice]);

  /**
   * 連続日数の表示上限(指示書AF 段階7)。
   *
   * ★`lib/config` を直接 import しないこと★ — 既定値しか出せず、運用設定で保存済みの値が
   *   画面に反映されません(アドバイス文言と同じ理由)。`min` / `max` も API から取ります。
   */
  const [limit, setLimit] = useState<DisplayLimitSetting | null>(null);
  const [limitLoading, setLimitLoading] = useState(true);
  const [limitLoadError, setLimitLoadError] = useState<string | null>(null);
  /** 入力欄は文字列。★`Number()` は送る直前に1回だけ★(途中の "" や "-" を数値にしない) */
  const [limitDraft, setLimitDraft] = useState("");
  const [limitSaving, setLimitSaving] = useState(false);
  const [limitSaveError, setLimitSaveError] = useState<string | null>(null);
  const [limitSaved, setLimitSaved] = useState(false);

  const fetchDisplayLimit = useCallback(async () => {
    setLimitLoading(true);
    setLimitLoadError(null);
    try {
      const body = await apiFetch<DisplayLimitListResponse>(
        "/api/settings/display-limits"
      );
      const item =
        body.items.find((i) => i.key === CONSECUTIVE_DAYS_MAX_KEY) ?? null;
      setLimit(item);
      setLimitDraft(item === null ? "" : String(item.value));
    } catch (e) {
      setLimitLoadError(
        (e instanceof ApiFetchError ? e.serverMessage : null) ??
          "表示上限の取得に失敗しました"
      );
      setLimit(null);
    } finally {
      setLimitLoading(false);
    }
  }, []);

  useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect -- 初回マウント時の取得
    void fetchDisplayLimit();
  }, [fetchDisplayLimit]);

  /**
   * 保存の共通部分(`DsLogScreen.tsx` の `commitNote()` に揃えてある)。
   *
   * ★レスポンスの値で state を差し替えること★ — 送った値ではありません。
   *   `customized` はストアを読まないと決まらず、サーバが弾いて既定値に倒れた場合も
   *   「実際に有効になった値」が返ります。
   * ★保存後に一覧(アドバイス文言)を取り直さないこと★ — 無関係な再取得で画面が
   *   ローディングに入れ替わり、読んでいた位置を見失います。
   */
  const patchLimit = useCallback(
    async (payload: { value: number } | { reset: true }) => {
      if (limit === null) return;
      setLimitSaving(true);
      setLimitSaveError(null);
      setLimitSaved(false);
      try {
        const saved = await apiFetch<DisplayLimitSetting>(
          "/api/settings/display-limits",
          {
            method: "PATCH",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ key: limit.key, ...payload }),
          }
        );
        setLimit(saved);
        setLimitDraft(String(saved.value));
        setLimitSaved(true);
      } catch (e) {
        // 失敗しても入力値は消さない(消すと打ち直しになる)
        setLimitSaveError(
          (e instanceof ApiFetchError ? e.serverMessage : null) ??
            "保存に失敗しました"
        );
      } finally {
        setLimitSaving(false);
      }
    },
    [limit]
  );

  const saveLimit = useCallback(() => {
    if (limit === null) return;
    const raw = limitDraft.trim();
    // ★`Number("") === 0` なので、空のまま送らないこと★
    //   (0 は範囲外なので 422 にはなるが、「未入力」を「0を指定した」にすり替えない)
    if (raw === "") {
      setLimitSaved(false);
      setLimitSaveError("日数を入力してください");
      return;
    }
    const value = Number(raw);
    // 変えていないなら叩かない。無駄な PATCH になる
    if (value === limit.value) {
      setLimitSaved(false);
      setLimitSaveError(null);
      return;
    }
    // ★画面側では範囲を見ない★ — 検証の正はサーバ(`validateConsecutiveDaysMax()`)で、
    //   `<input min max>` は開発者ツールで外せる補助でしかありません(指示書AF 禁止事項8)
    void patchLimit({ value });
  }, [limit, limitDraft, patchLimit]);

  const resetLimit = useCallback(() => {
    void patchLimit({ reset: true });
  }, [patchLimit]);

  return (
    <div className={styles.page}>
      <div className={styles.header}>
        <h1 className={styles.title}>{t("システム管理")}</h1>
        <div className={styles.subtitle}>
          {t("清掃監視の運用パラメータ。変更はDFAシステム管理者のみ")}
        </div>
      </div>

      <h2 className={styles.sectionTitle}>{t("運用設定")}</h2>
      <div className={styles.readonlyNotice}>
        <strong className={styles.readonlyStrong}>
          {t("この画面は一部の設定のみ編集できます。")}
        </strong>
        {t("編集できない設定には「編集不可」と表示します。")}
      </div>

      <div className={styles.card}>
        {/* 事前チェック設定 */}
        <section className={styles.block}>
          <div className={styles.blockTitle}>
            {t("事前チェック設定")}
            <span className={styles.lockBadge}>{t("編集不可")}</span>
          </div>
          <div className={styles.blockDesc}>
            {t("清掃開始予定時刻の何分前にロボット状態をチェックするか")}
          </div>
          <dl className={styles.defList}>
            {PRECHECK_MINUTES_BEFORE.map((minutes, i) => (
              <div key={minutes} className={styles.defRow}>
                <dt className={styles.defTerm}>
                  {t("第{n}回チェック").replace("{n}", String(i + 1))}
                </dt>
                <dd className={styles.defValue}>
                  {t("{n}分前").replace("{n}", String(minutes))}
                </dd>
              </div>
            ))}
          </dl>
        </section>

        {/* 完了率の判定閾値 */}
        <section className={styles.block}>
          <div className={styles.blockTitle}>
            {t("完了率の判定閾値")}
            <span className={styles.lockBadge}>{t("編集不可")}</span>
          </div>
          <div className={styles.blockDesc}>
            {t("清掃詳細ログの◎○△×判定に使用する閾値。上から順に評価します")}
          </div>
          <ul className={styles.gradeList}>
            {GRADE_RULES.map((rule) => (
              <li key={rule.grade} className={styles.gradeRow}>
                <GradeBadge grade={rule.grade} />
                <span className={styles.gradeCondition}>{rule.condition}</span>
              </li>
            ))}
          </ul>
          <div className={styles.note}>
            {t(
              "判定は丸める前の生値で行います(表示は小数第1位を四捨五入した整数)。境界値ちょうどは上位の判定になります。"
            )}
          </div>
        </section>

        {/* アドバイス エスカレーション閾値 */}
        <section className={styles.block}>
          <div className={styles.blockTitle}>
            {t("アドバイス エスカレーション閾値")}
            <span className={styles.lockBadge}>{t("編集不可")}</span>
          </div>
          <div className={styles.blockDesc}>
            {t("同一施設で問題(出発失敗 または △以下)が連続した場合のレベル引き上げ基準")}
          </div>
          <dl className={styles.defList}>
            {ESCALATION_RULES.map((rule) => (
              <div key={rule.level} className={styles.defRow}>
                <dt className={styles.defTerm}>
                  <span className={`${styles.levelBadge} ${LEVEL_CLASS[rule.level]}`}>
                    Lv{rule.level}
                  </span>
                </dt>
                <dd className={styles.defValue}>
                  {t("{n}日以上連続").replace("{n}", String(rule.days))}
                </dd>
              </div>
            ))}
          </dl>
          <div className={styles.note}>
            {t(
              "「連続」はカレンダー日ではなく、清掃スケジュールが存在する日の連続で数えます。清掃予定のない日は連続を途切れさせません。"
            )}
          </div>
        </section>

        {/* 連続日数の表示上限(★この画面で唯一編集できる設定★。指示書AF 段階7) */}
        <section className={styles.block}>
          <div className={styles.blockTitle}>
            {t("連続日数の表示上限")}
            {limit?.customized && (
              <span className={styles.customizedBadge}>{t("編集済み")}</span>
            )}
          </div>
          <div className={styles.blockDesc}>
            {t(
              "連続問題施設などで、連続日数がこの日数を超えたとき「{n}日以上」と表示します"
            )}
          </div>

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

          {!limitLoading && !limitLoadError && limit && (
            <>
              <div className={styles.limitRow}>
                <input
                  type="number"
                  className={styles.limitInput}
                  value={limitDraft}
                  /* ★min / max / step を数値で直書きしないこと★ — サーバの検証範囲
                     (GET /api/settings/display-limits の min / max)をそのまま使う */
                  min={limit.min}
                  max={limit.max}
                  step={1}
                  disabled={limitSaving}
                  aria-label={t("連続日数の表示上限")}
                  onChange={(e) => {
                    setLimitDraft(e.target.value);
                    // 打ち直し始めたら前回の結果表示は用済み
                    setLimitSaved(false);
                    setLimitSaveError(null);
                  }}
                />
                <span className={styles.limitUnit}>{t("日")}</span>
                <button
                  type="button"
                  className={styles.saveButton}
                  disabled={limitSaving}
                  onClick={saveLimit}
                >
                  {t("保存")}
                </button>
                {/* 既定値のままなら戻す先が無いので出さない */}
                {limit.customized && (
                  <button
                    type="button"
                    className={styles.resetButton}
                    disabled={limitSaving}
                    onClick={resetLimit}
                  >
                    {t("既定値に戻す")}
                  </button>
                )}
                {limitSaving && (
                  <span className={styles.limitStatus}>{t("保存中…")}</span>
                )}
                {!limitSaving && limitSaved && (
                  <span className={styles.limitSaved}>{t("保存しました")}</span>
                )}
              </div>
              {/* サーバーが返した文言は辞書に無ければ `translate()` が原文のまま返すため、
                  画面側の既定文言(保存に失敗しました)だけが EN に差し替わる */}
              {limitSaveError && (
                <div className={styles.limitError}>{t(limitSaveError)}</div>
              )}
            </>
          )}

          <div className={styles.note}>
            {t(
              "「連続」はカレンダー日ではなく、清掃スケジュールが存在する日の連続で数えます。清掃予定のない日は連続を途切れさせません。"
            )}
          </div>
        </section>

        {/* アドバイス文言 */}
        <section className={styles.block}>
          <div className={styles.blockTitle}>{t("アドバイス文言")}</div>
          <div className={styles.blockDesc}>
            {t("清掃詳細ログに表示される推定原因と推奨アクション")}
          </div>
          <div className={styles.note}>
            {t("発火条件(どのルールがいつ出るか)は仕様のため編集不可です。")}
          </div>

          {loading && <div className={styles.loading}>読み込み中…</div>}
          {!loading && error && <div className={styles.error}>{error}</div>}

          {!loading && !error && advice.length === 0 && (
            <div className={styles.empty}>{t("アドバイス文言は登録されていません")}</div>
          )}

          {!loading && !error && advice.length > 0 && (
            <div className={styles.adviceList}>
              {advice.map((item) => (
                <article key={item.rule} className={styles.adviceCard}>
                  <div className={styles.adviceHead}>
                    <span className={styles.adviceIcon} aria-hidden="true">
                      {item.icon}
                    </span>
                    <span className={styles.adviceRule}>{item.rule}</span>
                    <span className={`${styles.levelBadge} ${LEVEL_CLASS[item.level]}`}>
                      {t(LEVEL_LABEL[item.level])}
                    </span>
                    <span className={styles.phaseBadge}>{t(PHASE_LABEL[item.phase])}</span>
                    {item.customized && (
                      <span className={styles.customizedBadge}>{t("編集済み")}</span>
                    )}
                  </div>
                  <div className={styles.adviceTitle}>{item.title}</div>
                  <div className={styles.adviceText}>{item.text}</div>

                  <div className={styles.adviceFieldLabel}>{t("推奨アクション")}</div>
                  <ul className={styles.actionList}>
                    {item.actions.map((action) => (
                      <li key={action} className={styles.actionItem}>
                        {action}
                      </li>
                    ))}
                  </ul>

                  <div className={styles.conditionRow}>
                    <span className={styles.conditionLabel}>
                      {t("発火条件")}
                      <span className={styles.lockBadge}>{t("編集不可")}</span>
                    </span>
                    <span className={styles.conditionText}>{item.condition}</span>
                  </div>
                </article>
              ))}
            </div>
          )}
        </section>
      </div>
    </div>
  );
}