"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>
);
}