import { useState } from "react";
import { useTheme } from "@/components/theme/ThemeProvider";
import { rescueVisibility } from "@/lib/dashboard/rescue-visibility";
import { displayRate } from "@/lib/judge";
import type { DashboardResponse, RescueReason, RescueRequiredItem } from "@/lib/types";
import styles from "./RescueRequiredCard.module.css";
/**
* 要レスキュー店舗カード(F-01。ユーザー指示 2026-09-15 / 指示書AI 段階6)。
*
* ★★モック(`d-watch-mock.jsx`)に対応する画面がありません★★ —
* `CLAUDE.md` §5 は「モックが画面レイアウト・文言・遷移の正」と定めていますが、
* ★本カードはその例外★です(D-120 / D-121 と同じ扱い)。ユーザー指示の原文(2026-09-15):
*
* > 要レスキュー店舗をダッシュボード上の詳細ログの上に表示
* > 定義は出発失敗3日異常、連続問題レベル3、完了率急落前日比OO%、未実施3日連続
*
* ★★`d-watch-mock.jsx` は1文字も書き換えていません★★(モックは原型として保存する)。
* ★この注記を消さないこと★ — 次にモックと突き合わせた人が
* 「モックに無い=余計なもの」と判断して消してしまいます。
*
* ★★日本語を作るのはこの層★★(D-111)— API が返すのは識別子(`reasons`)と数値だけで、
* 文言は `t()` を通して組み立てる。★判定層(`lib/judge/rescue.ts`)で和文を作ると
* EN 切替のときに日本語が残ります★。
*
* ★★0件でもカードごと消さない(実装者判断。指示書AI §8-3 が実装者に委ねている)★★ —
* これは ★毎朝かならず見に来る確認項目★(`CLAUDE.md` §1 の「翌朝確認型」)なので、
* ★「確認した結果0件だった」と「表示が出ていない/壊れている」を区別できる必要がある★。
* ブロックごと隠すと、API が落ちたときと見分けがつかない
* (★理由を書かずに消すと不具合と区別できない★ — D-053 が UI を消したのと同じ理由)。
* ★`SecurityBanner` が0件で `null` を返すのとは扱いが違う★ — あちらは
* 「異常が起きたときだけ出る警告帯」で、常設の確認項目ではない。
*
* ★★件数表示(`items.length`)は総数★★(指示書AJ 段階1)— API は件数で切らなくなった
* (`lib/db/rescue-mapping.ts`)ので、これが正しい該当件数になる。
* ★以前は API 側で5件に切っていたため、7件該当していても「5件」と出ていた★。
* ★行数を絞るのはこの画面の仕事★で、判定は `rescueVisibility()`(`lib/` の純粋関数)に
* 置いてある — ★`vitest.config.ts` の `include` が `lib/**` と `app/**` なので、
* このファイルに書いたテストは走らないため★(D-120 項2)。
*/
type Translate = (text: string) => string;
/**
* 理由の識別子 → 文言(★数値は画面側で差し込む。方式A。D-111★)。
*
* ★★`Record<RescueReason, ...>` にしてあるのは、理由が増えたときに
* ここを書き忘れると `tsc` が落ちるようにするため★★(`switch` の書き漏れは
* 静かに `undefined` を返す)。
*/
const REASON_LABEL: Record<
RescueReason,
(item: RescueRequiredItem, t: Translate) => string
> = {
not_executed_streak: (item, t) =>
t("{n}日連続で未実施").replace("{n}", String(item.not_executed_days)),
escalation_l3: (item, t) =>
t("連続問題レベル3({n}日連続)").replace(
"{n}",
String(item.consecutive_problem_days)
),
completion_drop: (item, t) =>
// ★`drop_ratio` は「算出できた低下の比」。急落と判定された行では必ず入るが、
// ★`null` を 0% と書かない★(「算出できない」と「0%低下」は別の事実)
item.drop_ratio === null
? t("完了率が前回比で急落")
: t("完了率が前回比{n}%低下").replace(
"{n}",
// ★表示のためだけに丸める(判定は済んでいる。`CLAUDE.md` §4.3)★
String(Math.round(item.drop_ratio * 100))
),
};
/** 理由ごとの色。★色そのものは CSS変数(`.module.css`)が持つ★(JS 側で色を持たない) */
const REASON_TONE: Record<RescueReason, string> = {
not_executed_streak: styles.toneNg,
escalation_l3: styles.toneNg,
completion_drop: styles.toneWn,
};
interface RescueRequiredCardProps {
items: DashboardResponse["rescue_required"];
}
export function RescueRequiredCard({ items }: RescueRequiredCardProps) {
const { t } = useTheme();
// ★開閉は画面のローカル state★(★URL に持たせない★。指示書AJ §4-4 / 罠4 —
// D-131 申し送り⑤「ページ番号を URL に持たせる件」と混ざらないようにする)
const [expanded, setExpanded] = useState(false);
const { visible, remaining, canToggle } = rescueVisibility(items, expanded);
return (
<div className={styles.card}>
<div className={styles.cardHeader}>
<span className={styles.cardTitle}>{t("要レスキュー店舗")}</span>
{items.length > 0 && (
<span className={styles.count}>
{t("{n}件").replace("{n}", String(items.length))}
</span>
)}
</div>
{items.length === 0 ? (
<div className={styles.empty}>{t("要レスキューの店舗はありません")}</div>
) : (
<ul className={styles.list}>
{/* ★並べるのは `visible`(初期は先頭5件)。★件数表示は上の `items.length`(総数)★ */}
{visible.map((item) => (
<li key={item.facility_id} className={styles.row}>
<div className={styles.main}>
<span className={styles.facility}>{item.facility_name}</span>
<span className={styles.reasons}>
{/* ★該当した理由は全部出す(1つに丸めない)★ —
「なぜレスキューが要るのか」が運用担当の判断材料そのもの */}
{item.reasons.map((reason) => (
<span
key={reason}
className={`${styles.reason} ${REASON_TONE[reason]}`}
>
{REASON_LABEL[reason](item, t)}
</span>
))}
</span>
</div>
<div className={styles.meta}>
{/* ★判定日はリクエストの日付とは限らない★ —
当該日に予定が無い施設は、それ以前で最も新しいスケジュール存在日。
★出さないと「なぜ今日の一覧に無い施設が出ているのか」が分からない★ */}
<span>
{t("判定日")} {item.evaluated_on}
</span>
<span>
{/* ★丸めるのはここだけ(API は生値を返す)★ */}
{t("完了率")} {displayRate(item.completion_rate)}
{item.previous_completion_rate !== null && (
<span className={styles.previous}>
({t("前回")} {displayRate(item.previous_completion_rate)})
</span>
)}
</span>
</div>
</li>
))}
</ul>
)}
{/* ★総数が初期表示件数を超えるときだけ出す★(ちょうど同数なら出さない。指示書AJ 表1-9)。
★開いたら「閉じる」に切り替える★ — 開きっぱなしで戻せないのは不便(罠5) */}
{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>
);
}