"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { useTheme } from "@/components/theme/ThemeProvider";
import { apiFetch } from "@/lib/api/client";
import { ESCALATION_THRESHOLDS, PAGINATION } from "@/lib/config";
import { todayJst } from "@/lib/format/date";
import {
clampWeek,
periodLabel,
prevPeriodLabel,
shiftPeriod,
weekOfMonth,
} from "@/lib/format/period";
import { formatRawLabel } from "@/lib/format/raw-label";
import { useClassificationFilter } from "@/lib/hooks/use-classification-filter";
import {
ALL_OPTION_VALUE,
appendClassificationParams,
EMPTY_SELECTION,
type ClassificationSelection,
} from "@/lib/masters/options";
import {
resolveReportBlocks,
toFetchOutcome,
type ReportBlocksView,
type ReportBlockView,
} from "@/lib/reports/block-visibility";
import { THEME_TOKENS } from "@/lib/theme";
import type {
ContinuousProblem,
ErrorBreakdownItem,
FacilityPerformance,
FacilityStatus,
ListResponse,
Meta,
Period,
ReportKpi,
} from "@/lib/types";
import { FacilityPerformanceTable } from "./FacilityPerformanceTable";
import { KpiCard } from "./KpiCard";
import styles from "./ReportsScreen.module.css";
/** 既定の表示件数。`lib/reports/query.ts` の `DEFAULT_LIMIT` と同じ値(モック:719 の slice(0,10)) */
const DEFAULT_LIMIT = 10;
/**
* 「全件表示」時に渡す limit。
*
* 9999 のような適当な大きい値ではなく `PAGINATION.maxPerPage` を渡している。
* `parseReportQuery()` が limit をこの値で上限クランプするため、それ以上を渡しても
* 結果は同じで「画面が期待する件数」と「サーバが返す件数」が食い違うだけになる。
*/
const SHOW_ALL_LIMIT = PAGINATION.maxPerPage;
/** ステータス絞り込みの選択肢 */
type StatusFilter = FacilityStatus | "all";
/**
* エラー種別ごとのバーの色。
*
* ★生値方針(D-077)★: `error_type` は変換しない生値で、閉じた集合ではない。
* ここに列挙しているのは `docs/handoff/analysis/error-type-mapping.md` の
* 既知24値のうち代表的なものだけで、**網羅ではない**。
* `ProgressBar` の tone は `ok|wn|ng|ac|pr` の5つしかないため、
* 「重大なもの=ng / 現場対応で直るもの=wn / 情報寄り=ac・pr」の3段に寄せた。
* ★このオブジェクトに無いキーは呼び出し側で `?? "pr"` にフォールバックすること★
* (未知の `error_type` は必ず来る。緑=okにしないのは未知のエラーを正常に見せないため)。
*/
const ERROR_TONE: Record<string, "ok" | "wn" | "ng" | "ac" | "pr"> = {
EmergencyStop: "ng",
LostLocalization: "ng",
RobotHardwareErrorToAdmin: "wn",
LowBatteryLevel: "ac",
CleanWaterEmpty: "wn",
TrashFull: "wn",
};
/**
* 4本の取得結果。★どのクエリの結果かを持つ★(指示書BC 段階1・D-183)—
* ・読み込み中は「手元の結果のクエリ ≠ 今のクエリ」で判定する(effect の中で同期的に loading を立てない)
* ・★期間・分類(`baseQuery`)が変わったときだけ画面全体を「読み込み中…」にする★(数字の意味が変わるため。今と同じ)
* ・★`limit`(全件表示)や施設の絞り込みだけが変わったときは、前の内容を薄く残す★(スクロールが飛ばないように)
* ・★全件表示の状態(`perfShowAll` / `contShowAll`)も結果と一緒に持つ★ — 取り直し中は表示中の結果に合わせて
* フッタ(「全N施設を表示 →」/「上位10件に戻す」・上限の注記)を出す(前の10件のまま「上限で切れた」と出さない)
*/
interface ReportsResult {
baseQuery: string;
facilityQuery: string;
continuousQuery: string;
perfShowAll: boolean;
contShowAll: boolean;
/**
* ★4本の結果を「ブロックごと」に持つ★(指示書AL 段階2 / §3-3)。
* 判定そのものは `lib/reports/block-visibility.ts`(純粋関数・テスト済み)が決める。
* ★ここに決定表を写さないこと★ — 画面は返ってきた `kind` で出し分けるだけ。
*/
blocks: ReportBlocksView;
kpi: ReportKpi | null;
facilityPerformance: FacilityPerformance[];
facilityMeta: Meta | null;
continuousProblems: ContinuousProblem[];
continuousMeta: Meta | null;
errorBreakdown: ErrorBreakdownItem[];
}
export function ReportsScreen() {
const { t, theme } = useTheme();
const c = THEME_TOKENS[theme];
// 初期期間は「今週」。帰属ルール(D-071。月境界で週を分割する)は weekOfMonth() だけが
// 知っているので、画面側で年月週を自前計算しない(指示書F 5b / 指示書H)。
const initialPeriod = useMemo(() => weekOfMonth(todayJst()), []);
const [period, setPeriod] = useState<Period>("weekly");
const [year, setYear] = useState(initialPeriod.year);
const [month, setMonth] = useState(initialPeriod.month);
// ★年次(yearly)のときも month は保持しておく★(月次・週次に戻したときに 1〜12 の値が要るため)。
// ★年次を表す month=0 は state に入れない★ — 入れると月次に戻したとき「0月」になる。
// API へ送る 0 は `baseQuery` で組み立てる(指示書AQ 落とし穴6)。
// monthly / yearly のときも週番号は保持しておく(週次に戻したときに 1〜6 の値が要るため)。
// ★型は `number`(`number | null` ではない)。月次・年次を表す 0 は state に入れない★
// 0 は API境界〜DB で「月の集計値」を表す予約値(D-072)で、`shiftPeriod` の monthly が
// 返してくる値。ここでは週次へ戻したときの候補として実在する週番号だけを保持する。
const [week, setWeek] = useState<number>(initialPeriod.week);
// ★フィルタは「入力中の値」と「検索ボタンで確定した値」を分けて持つ★
// モックには明示的な「検索」ボタンがある(:679)。入力のたびに fetch すると
// 1文字ごとに4本のAPIを叩くことになるため、確定済みの値だけを queryString に載せる。
const [draftFacilityName, setDraftFacilityName] = useState("");
const [draftSerial, setDraftSerial] = useState("");
const [draftStatus, setDraftStatus] = useState<StatusFilter>("all");
const [facilityName, setFacilityName] = useState("");
const [serial, setSerial] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
/**
* 分類(ホールディングス → 屋号)の絞り込み(指示書AK 段階7。D-053 を解除)。
*
* ★入力中(draft)はフックが持ち、確定値はこの画面が持つ★
* (`lib/hooks/use-classification-filter.ts` の分担表)。
* 他の条件と同じく「検索」ボタン(モック:679)で確定する。
* ★確定値は `baseQuery` に載るので4本すべてに効く★ —
* `/reports/kpi` は対象顧客が1社に定まらないと422を返す(段階6)ので、
* ★このセレクトが「どの屋号のKPIを見るか」を選ぶ手段そのもの★になる。
*/
const classification = useClassificationFilter(t);
const [confirmedClassification, setConfirmedClassification] =
useState<ClassificationSelection>(EMPTY_SELECTION);
const [contShowAll, setContShowAll] = useState(false);
const [perfShowAll, setPerfShowAll] = useState(false);
/** 4本の取得結果(どのクエリの結果かを持つ。`ReportsResult` の docstring)。初回の取得が終わるまで null */
const [result, setResult] = useState<ReportsResult | null>(null);
/**
* 屋号セレクト。★KPIが422のときに「屋号を選ぶ」から直接フォーカスする★ため。
* ★誘導を文言だけで終わらせない★(§3-4)— 押せば選ぶ場所へ飛ぶ。
*/
const customerSelectRef = useRef<HTMLSelectElement>(null);
const focusCustomerSelect = useCallback(() => {
customerSelectRef.current?.focus();
}, []);
/**
* 4本に共通する期間パラメータ。monthly / yearly のとき week は送らない(サーバが無視する契約)。
* ★yearly のときは month に必ず 0 を送る★ — API は年次で `month≠0` を422で弾く
* (`lib/reports/query.ts` の `MONTH_YEARLY`)。state の month(1〜12)は送らない。
*/
const baseQuery = useMemo(() => {
const params = new URLSearchParams();
params.set("period", period);
params.set("year", String(year));
params.set("month", period === "yearly" ? "0" : String(month));
if (period === "weekly") {
params.set("week", String(week));
}
// ★分類は4本すべてに効かせる★(施設別パフォーマンスだけでなく KPI・連続問題施設・
// エラー内訳も同じ母集団で見せる)。★クエリは手書きしない★ —
// `group_id=0` を漏らさない判断が `appendClassificationParams()` に入っている
appendClassificationParams(params, confirmedClassification);
return params.toString();
}, [period, year, month, week, confirmedClassification]);
/**
* 施設別パフォーマンスの絞り込み条件(確定済みの値だけ)。limit は含めない。
* ★表(`facilityQuery`)と CSV(`exportQuery`)は必ずここから作る★(D-163)—
* 条件を2か所に書くと、片方だけ直したときに「画面と CSV で施設が食い違う」状態になる。
*/
const facilityFilterQuery = useMemo(() => {
const params = new URLSearchParams(baseQuery);
if (facilityName) params.set("facility_name", facilityName);
if (serial) params.set("serial", serial);
params.set("status", status);
return params.toString();
}, [baseQuery, facilityName, serial, status]);
/** 表の取得用。★limit を足すのは表だけ★(上位10件 / 全件表示) */
const facilityQuery = useMemo(() => {
const params = new URLSearchParams(facilityFilterQuery);
params.set("limit", String(perfShowAll ? SHOW_ALL_LIMIT : DEFAULT_LIMIT));
return params.toString();
}, [facilityFilterQuery, perfShowAll]);
/**
* CSV の取得用。★limit を付けない★(D-163)— CSV は表の条件に一致する全施設を出す
* (表示中の上位10件だけではない)。サーバも limit を無視する契約。
*/
const exportQuery = facilityFilterQuery;
const continuousQuery = useMemo(() => {
const params = new URLSearchParams(baseQuery);
params.set("limit", String(contShowAll ? SHOW_ALL_LIMIT : DEFAULT_LIMIT));
return params.toString();
}, [baseQuery, contShowAll]);
// ★4本の取得。取得中に条件を変えたら、遅れて届いた古い応答は捨てる★(片付けで cancelled にする。
// 捨てないと前の期間の数字が新しい期間のラベルの下に出る。エラー一覧と同じ考え方。指示書BC 段階1・D-183)。
// ★effect の中で同期的に state を変えない★(読み込み中は結果のクエリと今のクエリの食い違いで決める)
useEffect(() => {
let cancelled = false;
void (async () => {
// ★★`Promise.all` ではなく `allSettled`★★(指示書AL 段階2 / §3-1)—
// ★1本の失敗で4本とも消えていた★のを、★成功した分は出す★形に変える。
// ★特にKPIは「対象顧客が1社に定まらない」と422になる★(`app/api/reports/kpi/route.ts`)が、
// ★残り3本は施設単位なので問題なく出せる★。
// ★`USE_FIXTURES=true` は屋号が4件あるため、これが開発中に毎回起きる★(§3-2)。
const [kpiResult, perfResult, contResult, errorResult] =
await Promise.allSettled([
apiFetch<ReportKpi>(`/api/reports/kpi?${baseQuery}`),
apiFetch<ListResponse<FacilityPerformance>>(
`/api/reports/facility-performance?${facilityQuery}`
),
apiFetch<ListResponse<ContinuousProblem>>(
`/api/reports/continuous-problems?${continuousQuery}`
),
// ★error-breakdown だけ meta を返さない★(集計結果であり全件返す。
// 一覧APIの例外。`d-watch-api.yaml` の /reports/error-breakdown の description 参照。
// ★生値方針(D-077)では種別数が固定されない。実DB接続後に件数を実測して再判断すること(D-088)★)
apiFetch<{ items: ErrorBreakdownItem[] }>(
`/api/reports/error-breakdown?${baseQuery}`
),
]);
if (cancelled) return;
const kpiOutcome = toFetchOutcome(kpiResult);
const perfOutcome = toFetchOutcome(perfResult);
const contOutcome = toFetchOutcome(contResult);
const errorOutcome = toFetchOutcome(errorResult);
setResult({
baseQuery,
facilityQuery,
continuousQuery,
perfShowAll,
contShowAll,
blocks: resolveReportBlocks({
kpi: kpiOutcome,
facilityPerformance: perfOutcome,
continuousProblems: contOutcome,
errorBreakdown: errorOutcome,
}),
// ★失敗した本のデータは必ず空に戻す★ — 前の期間の値が残ると
// 「エラーと言いながら古い数字が出ている」状態になる
kpi: kpiOutcome.ok ? kpiOutcome.value : null,
facilityPerformance: perfOutcome.ok ? perfOutcome.value.items : [],
facilityMeta: perfOutcome.ok ? perfOutcome.value.meta : null,
continuousProblems: contOutcome.ok ? contOutcome.value.items : [],
continuousMeta: contOutcome.ok ? contOutcome.value.meta : null,
errorBreakdown: errorOutcome.ok ? errorOutcome.value.items : [],
});
})();
return () => {
cancelled = true;
};
// ★`perfShowAll` / `contShowAll` は `facilityQuery` / `continuousQuery` の limit に入っているので、
// 依存に足しても取得の回数は変わらない★(結果に持たせるために読んでいる)
}, [baseQuery, facilityQuery, continuousQuery, perfShowAll, contShowAll]);
// ★期間・分類が変わった(または初回)→ 画面全体を読み込み中★(今までと同じ見た目)
const loading = result === null || result.baseQuery !== baseQuery;
// ★施設別パフォーマンス / 連続問題施設だけ取り直し中★ → 前の内容を薄く残す
const perfRefreshing = !loading && result.facilityQuery !== facilityQuery;
const contRefreshing = !loading && result.continuousQuery !== continuousQuery;
const blocks = result?.blocks ?? null;
// ★以下は表示中の結果の値★(loading のときは使わない)
const kpi = result?.kpi ?? null;
const facilityPerformance = result?.facilityPerformance ?? [];
const facilityMeta = result?.facilityMeta ?? null;
const continuousProblems = result?.continuousProblems ?? [];
const continuousMeta = result?.continuousMeta ?? null;
const errorBreakdown = result?.errorBreakdown ?? [];
// ★全件表示の状態も表示中の結果に合わせる★(`ReportsResult` の docstring)
const shownPerfShowAll = result?.perfShowAll ?? false;
const shownContShowAll = result?.contShowAll ?? false;
/** 期間が変わったら「全件表示」を畳む(モック:683-684 の onClick と同じ挙動) */
const changePeriod = useCallback((next: Period) => {
setPeriod(next);
setContShowAll(false);
setPerfShowAll(false);
}, []);
const navigatePeriod = useCallback(
(delta: -1 | 1) => {
const next = shiftPeriod(period, year, month, week, delta);
setYear(next.year);
if (period === "yearly") {
// ★年次の shiftPeriod は month=0 / week=0 を返す(年次を表す予約値)★。
// ★state の month / week には入れない★ — 入れると月次に戻したとき「0月」、
// 週次に戻したとき「第0週」になる。月は直前の値を残し、週だけは
// 移動先の年のその月に存在するとは限らないので最大週へ丸める
setWeek(clampWeek(next.year, month, week));
setContShowAll(false);
setPerfShowAll(false);
return;
}
setMonth(next.month);
// ★monthly の shiftPeriod は week に 0 を返す(D-072)★。週次へ戻したときのために
// 直前の値を残すが、移動先の月にその週が存在するとは限らないので、その月の最大週へ丸める。
// weekly のときは next.week が実際の週番号(1〜6)なので、そのまま使う
setWeek(
next.week === 0 ? clampWeek(next.year, next.month, week) : next.week
);
setContShowAll(false);
setPerfShowAll(false);
},
[period, year, month, week]
);
const handleSearch = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
setFacilityName(draftFacilityName.trim());
setSerial(draftSerial.trim());
setStatus(draftStatus);
// 絞り込み条件が変わると件数も変わるので上位10件に戻す
setPerfShowAll(false);
// ★分類だけは `baseQuery` に載る=連続問題施設の件数も変わる★ので、
// ★分類が実際に変わったときはそちらの「全件表示」も畳む★
// (施設名・シリアル・ステータスは施設別パフォーマンスにしか効かないため、
// それだけを変えたときに連続問題施設の展開を畳むことはしない)
const nextClassification = classification.draft;
setConfirmedClassification(nextClassification);
if (
nextClassification.groupId !== confirmedClassification.groupId ||
nextClassification.customerId !== confirmedClassification.customerId
) {
setContShowAll(false);
}
},
[
draftFacilityName,
draftSerial,
draftStatus,
classification.draft,
confirmedClassification,
]
);
const prevLabel = t(prevPeriodLabel(period));
const contRemaining = continuousMeta
? continuousMeta.total - continuousProblems.length
: 0;
/**
* ブロック内のエラー1行。★カードの中に出す★ので「どれが欠けたか」が画面から分かる
* (§3-3 の1つ目の条件。「読み込みに失敗しました」だけの全体エラーでは分からない)。
* サーバが文言を返していればそれを出す(★t() に通さない★ — 生成済みの文をキーにしない)。
*/
const blockError = (view: ReportBlockView) =>
view.kind === "error" ? (
<div className={styles.blockError}>
{view.message ?? t("データの取得に失敗しました")}
</div>
) : null;
return (
<div className={styles.page}>
<div className={styles.header}>
<h1 className={styles.title}>{t("レポート")}</h1>
{/* ★CSVは「施設別パフォーマンス」の表と同じ確定済みの条件に一致する全施設を出す★
(表示中の上位10件だけではない。D-163)。href は `exportQuery`(表と同じ条件・limit なし)。
★0件でも押せる★(ヘッダ行だけの CSV になる)。
ファイルダウンロード用の API なので、Next のルーター(router.push)ではなく
通常の <a download> でブラウザのネイティブなダウンロード遷移に任せる
(router.push だと RSC のプリフェッチ/プローブが走り、1回のクリックで2回出力される。
清掃詳細ログの CSV と同じ理由)。 */}
<a
className={styles.exportBtn}
href={`/api/reports/facility-performance/export?${exportQuery}`}
download
>
{t("CSVエクスポート")}
</a>
</div>
{/* 日付範囲の input 2つ(モック:664/:666)は作らない。
週次/月次/年次トグルと二重の期間指定になるためAPI契約側に寄せた。 */}
<form className={styles.toolbar} onSubmit={handleSearch}>
{/* ★分類の絞り込み(モック:668-673。指示書AK 段階7 で D-053 を解除)★
・選択肢は `/groups` と `/customers` から取る(★固定の選択肢を書かない★)
・★取得に失敗した/屋号が0件のときは出さない★(`available` が false)—
★空の `<select>` は「押しても何も起きないUI」への逆戻り★(落とし穴11)
・★option のラベルを `t()` に通さないこと★ — 「全ホールディングス」等は
フック側で `t()` 済みで、屋号名まで通すと辞書の対訳に誤って当たる */}
{classification.available && (
<>
{classification.hasGroups && (
<select
/* ★幅を固定する★(選んだ名前の長さで幅が変わらないように)。★切れた名前はホバーで読めるよう、
選択中の表示名を title に出す★(ユーザー指示 2026-10-06。D-162) */
className={`${styles.select} ${styles.masterSelect}`}
aria-label={t("ホールディングス")}
title={
classification.groupOptions.find(
(o) =>
o.value ===
String(classification.draft.groupId ?? ALL_OPTION_VALUE),
)?.label
}
value={
classification.draft.groupId === null
? ALL_OPTION_VALUE
: String(classification.draft.groupId)
}
onChange={(e) => classification.selectGroup(e.target.value)}
>
{classification.groupOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
)}
<select
ref={customerSelectRef}
/* ★KPIが422のあいだはセレクトを強調する★(§3-4「屋号セレクトを強調する」)—
★「顧客を選んでください」と書いてある場所と、実際に選ぶ場所を結びつける★ */
className={`${styles.select} ${styles.masterSelect} ${
blocks?.kpi.kind === "select-customer"
? styles.selectHighlight
: ""
}`}
aria-label={t("顧客")}
/* 幅の固定と title は上のホールディングスと同じ(D-162) */
title={
classification.customerOptions.find(
(o) =>
o.value ===
String(classification.draft.customerId ?? ALL_OPTION_VALUE),
)?.label
}
value={
classification.draft.customerId === null
? ALL_OPTION_VALUE
: String(classification.draft.customerId)
}
onChange={(e) => classification.selectCustomer(e.target.value)}
>
{classification.customerOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</>
)}
<input
className={styles.input}
aria-label={t("施設名")}
placeholder={t("施設名")}
value={draftFacilityName}
onChange={(e) => setDraftFacilityName(e.target.value)}
/>
<input
className={styles.input}
aria-label={t("シリアルNo.")}
placeholder={t("シリアルNo.")}
value={draftSerial}
onChange={(e) => setDraftSerial(e.target.value)}
/>
<select
className={styles.select}
aria-label={t("ステータス")}
value={draftStatus}
onChange={(e) => setDraftStatus(e.target.value as StatusFilter)}
>
<option value="all">{t("全ステータス")}</option>
<option value="bad">{t("要改善")}</option>
<option value="warning">{t("注意")}</option>
<option value="good">{t("良好")}</option>
{/* no_data(判定なし)はモックに無い4値目。決定1(2026-09-11 ユーザー確定) */}
<option value="no_data">{t("判定なし")}</option>
</select>
<button type="submit" className={styles.searchBtn}>
{t("検索")}
</button>
</form>
<div className={styles.periodRow}>
<div className={styles.periodNav}>
<button
type="button"
className={styles.navBtn}
onClick={() => navigatePeriod(-1)}
aria-label={t("前の期間")}
>
‹
</button>
{/* periodLabel は日本語固定(`lib/format/period.ts`)。t() を通さない */}
<span className={styles.periodLabel}>
{periodLabel(period, year, month, week)}
</span>
<button
type="button"
className={styles.navBtn}
onClick={() => navigatePeriod(1)}
aria-label={t("次の期間")}
>
›
</button>
</div>
<div className={styles.periodToggle}>
<button
type="button"
className={`${styles.toggleBtn} ${period === "weekly" ? styles.toggleActive : ""}`}
onClick={() => changePeriod("weekly")}
>
{t("週次")}
</button>
<button
type="button"
className={`${styles.toggleBtn} ${period === "monthly" ? styles.toggleActive : ""}`}
onClick={() => changePeriod("monthly")}
>
{t("月次")}
</button>
<button
type="button"
className={`${styles.toggleBtn} ${period === "yearly" ? styles.toggleActive : ""}`}
onClick={() => changePeriod("yearly")}
>
{t("年次")}
</button>
</div>
</div>
{loading && <div className={styles.loading}>{t("読み込み中…")}</div>}
{/* ★4本とも失敗したときだけ画面全体を1行にする★(§3-3 の2つ目の条件)—
★空の枠が4つ並ぶより、1行のほうが分かる★ */}
{!loading && blocks?.allFailed && (
<div className={styles.error}>
{blocks.message ?? t("データの取得に失敗しました")}
</div>
)}
{!loading && blocks && !blocks.allFailed && (
<>
{/* ---- KPI ブロック ---- */}
{blocks.kpi.kind !== "ready" && (
<div className={`${styles.card} ${styles.kpiNoticeCard}`}>
<div className={styles.cardHeader}>
{/* ★どのブロックが欠けたのか分かるように見出しを出す★ */}
<div className={styles.cardTitle}>{t("KPI")}</div>
<div className={styles.cardSub}>
{t("稼働率・出発成功率・清掃完了率・エラー発生率")}
</div>
</div>
{blockError(blocks.kpi)}
{/* ★422(対象顧客が1社に定まらない)はエラーではなく「指定が足りない」★
(`app/api/reports/kpi/route.ts` §2-1)。★候補の件数を出す★ので、
★「選択肢が複数ある」ことが文言を読まなくても分かる★(§3-4) */}
{blocks.kpi.kind === "select-customer" && (
<div className={styles.kpiNotice}>
<div className={styles.kpiNoticeTitle}>
{t("顧客を選んでください")}
</div>
{/* サーバが返した理由(合算できない旨)はそのまま出す */}
{blocks.kpi.message && (
<div className={styles.kpiNoticeText}>
{blocks.kpi.message}
</div>
)}
{blocks.kpi.candidateCustomerIds.length > 0 ? (
<div className={styles.kpiNoticeActions}>
<span className={styles.kpiNoticeCount}>
{t("選べる顧客が{n}件あります").replace(
"{n}",
String(blocks.kpi.candidateCustomerIds.length)
)}
</span>
{/* ★セレクトが出ていないときはボタンも出さない★
(マスタ取得に失敗しているときに、押しても何も起きないボタンを置かない) */}
{classification.available && (
<button
type="button"
className={styles.linkBtn}
onClick={focusCustomerSelect}
>
{t("顧客を選ぶ")} →
</button>
)}
</div>
) : (
// 候補0社=ホールディングス指定で傘下に屋号が無い
// (`lib/reports/target-customer.ts` の docstring)
<div className={styles.kpiNoticeText}>
{t("この条件では選べる顧客がありません")}
</div>
)}
</div>
)}
</div>
)}
{/* KPI 4枚。tone と betterWhenHigher の割り当てはモック:688 のとおり。
★エラー発生率だけ betterWhenHigher=false(下がったら良い)★ */}
{blocks.kpi.kind === "ready" && kpi && (
<div className={styles.kpiGrid}>
<KpiCard
label={t("稼働率")}
value={kpi.operation_rate.value}
diff={kpi.operation_rate.diff}
prevLabel={prevLabel}
prevValue={kpi.operation_rate.prev}
tone="ok"
betterWhenHigher
/>
<KpiCard
label={t("出発成功率")}
value={kpi.launch_rate.value}
diff={kpi.launch_rate.diff}
prevLabel={prevLabel}
prevValue={kpi.launch_rate.prev}
tone="pr"
betterWhenHigher
/>
<KpiCard
label={t("清掃完了率")}
value={kpi.completion_rate.value}
diff={kpi.completion_rate.diff}
prevLabel={prevLabel}
prevValue={kpi.completion_rate.prev}
tone="ac"
betterWhenHigher
/>
<KpiCard
label={t("エラー発生率")}
value={kpi.error_rate.value}
diff={kpi.error_rate.diff}
prevLabel={prevLabel}
prevValue={kpi.error_rate.prev}
tone="ng"
betterWhenHigher={false}
/>
</div>
)}
<div className={styles.twoCol}>
{/* #4 連続問題施設(モック:709-742)。★全件表示の取り直し中は薄く残す★(指示書BC 段階1) */}
<div
className={contRefreshing ? `${styles.card} ${styles.cardRefreshing}` : styles.card}
aria-busy={contRefreshing}
>
<div className={styles.cardHeader}>
<div className={styles.cardTitle}>{t("連続問題施設")}</div>
<div className={styles.cardSub}>
{/* ★日数は lib/config の L2 の閾値から作る★(API の絞り込みと同じ値。ハードコード禁止 CLAUDE.md §7) */}
{t("{n}日以上連続で△以下または出発失敗の施設").replace(
"{n}",
String(ESCALATION_THRESHOLDS.level2Days)
)}
</div>
</div>
{/* ★このブロックだけが失敗しても、他は出したまま★(§3-3) */}
{blocks.continuousProblems.kind !== "ready" ? (
blockError(blocks.continuousProblems)
) : continuousProblems.length === 0 ? (
<div className={styles.cardEmpty}>{t("該当施設なし")}</div>
) : (
<div>
{continuousProblems.map((item) => {
// L3 は ng、L2 は wn(モック:723-724)。絞り込みはAPI側で2日以上に
// 限っているので L1 は原則来ないが、来ても wn で表示が壊れない
const levelColor =
item.escalation_level === "L3" ? c.ng : c.wn;
return (
<div key={item.facility_id} className={styles.contRow}>
<span
className={styles.levelBadge}
style={{
background: `${levelColor}18`,
color: levelColor,
}}
>
Lv{item.escalation_level.replace("L", "")} /{" "}
{/* ★表示上限で頭打ちのときは「{n}日以上」(指示書AF 段階5-5)★
`日` を t() に通していなかったので EN でも「5日」と出ていた。
バッジに長い `"{n}日以上連続"`(システム管理画面が使うキー)は
使わない — 小さなバッジに収まらないため */}
{item.consecutive_problem_days_capped
? t("{n}日以上").replace(
"{n}",
String(item.consecutive_problem_days)
)
: t("{n}日").replace(
"{n}",
String(item.consecutive_problem_days)
)}
</span>
<span className={styles.contFacility}>
{item.facility_name}
</span>
{/* ★モックの「完了率 {n}%」ではなく summary を出す★
(指示書F 0-1 の判断が起点だが、内容は指示書Wで確定1「事実だけを
組み立てる」に変わった。D-026 の説明ではなく、出発失敗の有無を
事実として述べる文になっている) */}
{/* ★サーバはテンプレート文字列を返す(決定3。方式A)。数値はここで
差し込む★ — String.replace は文字列指定だと最初の1件だけ置換
するが、各テンプレートに {n} {m} は1回ずつしか出ないのでこれで足りる。
★launch_failed_days は任意項目(fixtures を凍結しているため)。
値が無いときは置換しない(0 を捏造しない)★ */}
<span className={styles.contSummary}>
{(() => {
const base = t(item.summary).replace(
"{n}",
String(item.consecutive_problem_days)
);
return item.launch_failed_days === undefined
? base
: base.replace("{m}", String(item.launch_failed_days));
})()}
</span>
</div>
);
})}
{!shownContShowAll && contRemaining > 0 && (
<div className={styles.cardFooter}>
<button
type="button"
className={styles.linkBtn}
onClick={() => setContShowAll(true)}
>
{t("他{n}件を表示 →").replace(
"{n}",
String(contRemaining)
)}
</button>
</div>
)}
{/* ★全件表示でも表示の上限(200)で切れたら注記を出す★(黙って切らない。指示書BC 2-5) */}
{shownContShowAll &&
continuousMeta &&
continuousMeta.total > continuousProblems.length && (
<div className={styles.cardFooter}>
<span className={styles.truncatedNote}>
{t("全{n}件のうち{m}件を表示しています(表示の上限)")
.replace("{n}", String(continuousMeta.total))
.replace("{m}", String(continuousProblems.length))}
</span>
</div>
)}
{/* ★「上位10件に戻す」は連続問題施設には置かない★
モック(:733-737)はこのカードに展開ボタンだけを持ち、折り畳みは
施設別パフォーマンス表(:821-825)にしかない。モックを正とする
(CLAUDE.md §9.6)。期間トグル・期間ナビの操作で展開状態は畳まれる */}
</div>
)}
</div>
{/* #5 エラー種別の内訳(モック:744-770) */}
<div className={styles.card}>
<div className={styles.cardHeader}>
<div className={styles.cardTitle}>{t("エラー種別の内訳")}</div>
{/* モック:748 は「今週」固定。期間を移動すると過去の期間になるので「この期間」と書く(期間は上の期間ナビに出ている)。
★periodLabel() は日本語固定なので文に混ぜない★(指示書BC 5-1) */}
<div className={styles.cardSub}>
{t("この期間のエラー発生件数(種別ごと)")}
</div>
</div>
{blocks.errorBreakdown.kind !== "ready" ? (
blockError(blocks.errorBreakdown)
) : errorBreakdown.length === 0 ? (
// ★空データ時の文言をモックが持っていない★
// 「施設」の一覧ではないので `該当施設なし` は使えない。
// 連続問題施設の文言に合わせて「該当なし」とした(指示書F 5-6 の推奨)。
<div className={styles.cardEmpty}>{t("該当なし")}</div>
) : (
<div>
{errorBreakdown.map((item, index) => (
<div
key={`${index}-${item.type}`}
className={styles.errorRow}
>
{/* 空値(null/undefined/空文字/空白のみ)は代替文言に差し替える(指示書O 段階3)。
値が入っている場合は formatRawLabel が加工しない(D-077)ので、
t() をかけても既知の対訳キーに無い生値はそのまま出る */}
<div className={styles.errorLabel}>
{t(formatRawLabel(item.label))}
</div>
<div className={styles.errorBar}>
<ProgressBar
percent={item.rate}
// ★未知の error_type は必ず来る(生値方針。D-077)。
// 既知の値だけ色を付け、それ以外は中立色 pr にフォールバックする。
// ok(緑)にしないのは未知のエラーを「正常」に見せないため。
tone={ERROR_TONE[item.type] ?? "pr"}
/>
</div>
<div className={styles.errorCount}>{item.count}</div>
{/* 表示は整数(D-020)。判定には使わない表示専用の丸め */}
<div className={styles.errorRate}>
{Math.round(item.rate)}%
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* #6 施設別パフォーマンス(モック:773-826)。
表本体とフッタ(全N施設を表示 / 上位10件に戻す)は
FacilityPerformanceTable が内包する。ここはカードヘッダだけを出す */}
<div
className={perfRefreshing ? `${styles.card} ${styles.cardRefreshing}` : styles.card}
aria-busy={perfRefreshing}
>
<div className={styles.tableCardHeader}>
<div className={styles.tableCardTitle}>
{t("施設別パフォーマンス(稼働率ワースト順)")}
</div>
{/* ★失敗しているときに「0施設」と出さない★ — 0件と読めてしまう */}
{blocks.facilityPerformance.kind === "ready" && (
<span className={styles.tableCardCount}>
{t("{n}施設").replace("{n}", String(facilityMeta?.total ?? 0))}
</span>
)}
</div>
{blocks.facilityPerformance.kind !== "ready" ? (
blockError(blocks.facilityPerformance)
) : (
<FacilityPerformanceTable
items={facilityPerformance}
prevLabel={prevLabel}
totalCount={facilityMeta ? facilityMeta.total : 0}
showAll={shownPerfShowAll}
onShowAll={() => setPerfShowAll(true)}
onCollapse={() => setPerfShowAll(false)}
/>
)}
</div>
</>
)}
</div>
);
}