Newer
Older
client_dwatch / components / features / dashboard / RescueRequiredCard.tsx
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>
  );
}