"use client";
import { useEffect, useMemo, useState } from "react";
import { Pagination } from "@/components/ui/Pagination";
import { Pill } from "@/components/ui/Pill";
import { useTheme } from "@/components/theme/ThemeProvider";
import { apiFetch, ApiFetchError } from "@/lib/api/client";
import { PAGINATION } from "@/lib/config";
import { isSupportedModel } from "@/lib/format/robot-model";
import { totalPageCount } from "@/lib/pagination/page-items";
import { THEME_TOKENS } from "@/lib/theme";
import type { Facility, ListResponse } from "@/lib/types";
import styles from "./FacilitiesScreen.module.css";
/**
* F-08 施設ロボットマスタ(読み取り専用)
*
* ★モック右上にある登録・更新系の2ボタンは置かない★
* 登録・更新は D-Security 側の画面で行い、D-Watch は GET のみ(docs/spec.md,
* `app/api/facilities/route.ts` の冒頭コメント)。モックにはボタンが描かれているが
* `onClick` が無い飾りで、実装すると「押せるのに何も起きないUI」になる(D-053)。
* グレーアウトして置くのも同じ理由で不可。
*
* ★任意項目(電話・責任者名・担当者名・メール)は null がありうる★(`Facility` の型)。
* 空セルにすると列がずれて見えるため "-" を出す(`DsLogScreen` の備考列と同じ)。
*/
/** 任意項目の欠損表示。空文字だと行の高さが詰まって列がずれて見える */
function orDash(value: string | null): string {
return value ?? "-";
}
export function FacilitiesScreen() {
const { t, theme } = useTheme();
const c = THEME_TOKENS[theme];
const [q, setQ] = useState("");
const [debouncedQ, setDebouncedQ] = useState("");
/**
* 現在ページ(1始まり。指示書AA 段階5)
*
* ★検索語の debounce 反映と同時に 1 に戻すこと★ — 件数が減ったときに
* 存在しないページへ取り残されると空表示になる。
*/
const [page, setPage] = useState(1);
const [items, setItems] = useState<Facility[]>([]);
/** フッタの件数表示はフィルタ後の総件数(`meta.total`)。ページング後の items.length ではない */
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 1文字ごとに叩くと入力中ずっとローディング表示に入れ替わるので 300ms 待つ
// (`DsLogScreen` / `DetailLogScreen` と同じ)
// ★debounce の反映と同時にページを1へ戻す★(React が1回の再描画にまとめる)
useEffect(() => {
const id = window.setTimeout(() => {
setDebouncedQ(q);
setPage(1);
}, 300);
return () => window.clearTimeout(id);
}, [q]);
const queryString = useMemo(() => {
const params = new URLSearchParams();
if (debouncedQ) params.set("q", debouncedQ);
params.set("per_page", String(PAGINATION.defaultPerPage));
params.set("page", String(page));
return params.toString();
// ★`page` を依存に入れること★ — 入れ忘れるとボタンを押しても取り直さない
}, [debouncedQ, page]);
// ★一覧の取得。取得中に検索語を変えたら、遅れて届いた古い応答は捨てる★(片付けで cancelled にする。
// 捨てないと前の条件の結果が新しい条件の表示を上書きする。エラー一覧と同じ考え方。指示書BC 段階1・D-183)
useEffect(() => {
let cancelled = false;
// eslint-disable-next-line react-hooks/set-state-in-effect -- 検索語の変更に合わせた一覧の取り直し
setLoading(true);
setError(null);
apiFetch<ListResponse<Facility>>(`/api/facilities?${queryString}`)
.then(
(body) => {
if (cancelled) return;
setItems(body.items);
setTotal(body.meta.total);
// ★存在しないページに取り残された場合の保険★(`pages > 0` を落とさないこと)
const pages = totalPageCount(body.meta.total, body.meta.per_page);
if (pages > 0 && body.meta.page > pages) setPage(pages);
},
(e: unknown) => {
if (cancelled) return;
setError(
(e instanceof ApiFetchError ? e.serverMessage : null) ??
"データの取得に失敗しました"
);
setItems([]);
setTotal(0);
}
)
.finally(() => {
// ★古い取得の finally が新しい取得の読み込み中を消さないように★
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [queryString]);
return (
<div className={styles.page}>
<div className={styles.header}>
<h1 className={styles.title}>{t("施設ロボットマスタ")}</h1>
<input
className={styles.searchInput}
aria-label={t("施設名 / シリアル / 住所")}
placeholder={t("施設名 / シリアル / 住所")}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
{loading && <div className={styles.loading}>{t("読み込み中…")}</div>}
{/* サーバーの文言は辞書に無ければ原文のまま */}
{!loading && error && <div className={styles.error}>{t(error)}</div>}
{!loading && !error && items.length === 0 && (
<div className={styles.empty}>{t("該当する施設はありません")}</div>
)}
{!loading && !error && items.length > 0 && (
<div className={styles.card}>
<div className={styles.tableWrap}>
<table className={styles.table}>
{/*
★列幅は言語によらず固定する(table-layout: fixed)★
見出しの文字で列幅が決まる作りだと、JP/EN を切り替えるたびに列ごとに幅が変わって
表が横に動く(2026-10-01 ユーザー指摘。最大29px)。幅の値と理由は CSS 側の .col* に書く。
★住所列にも幅を付け、表の余った幅は全列に幅の比で配る★(ユーザー指示 2026-10-06
「3つとも同じように調整して」。D-167 追記。旧: 住所列だけ幅を指定せず余りを全部受け持っていた)。
列を足す・並べ替えるときはここの <col> も同じ順に直すこと(ずれると別の列に幅が付く)。
*/}
<colgroup>
<col className={styles.colName} />
<col className={styles.colPrefecture} />
<col className={styles.colAddress} />
<col className={styles.colPhone} />
<col className={styles.colPerson} />
<col className={styles.colPerson} />
<col className={styles.colEmail} />
<col className={styles.colMonitor} />
<col className={styles.colDsec} />
<col className={styles.colRobots} />
</colgroup>
<thead>
<tr>
<th className={styles.th}>{t("施設名")}</th>
<th className={styles.th}>{t("都道府県")}</th>
<th className={styles.th}>{t("住所")}</th>
<th className={styles.th}>{t("電話番号")}</th>
<th className={styles.th}>{t("責任者名")}</th>
<th className={styles.th}>{t("担当者名")}</th>
<th className={styles.th}>{t("メール")}</th>
<th className={styles.thCenter}>{t("監視")}</th>
<th className={styles.thCenter}>{t("D-Sec")}</th>
<th className={styles.th}>{t("ロボット(メーカー / 機種 / シリアル)")}</th>
</tr>
</thead>
<tbody>
{items.map((facility) => (
<tr key={facility.id} className={styles.row}>
<td className={styles.tdName}>{facility.facility_name}</td>
<td className={styles.tdMuted}>{facility.prefecture}</td>
<td className={styles.tdMuted}>{facility.address}</td>
<td className={styles.tdPhone}>{orDash(facility.phone)}</td>
<td className={styles.td}>{orDash(facility.manager_name)}</td>
<td className={styles.td}>{orDash(facility.charger_name)}</td>
<td className={styles.tdEmail}>{orDash(facility.email)}</td>
<td className={styles.tdMark}>
{facility.monitoring_enabled ? (
<span className={styles.markOn}>○</span>
) : (
<span className={styles.markOff}>ー</span>
)}
</td>
<td className={styles.tdMark}>
{facility.d_security_enabled ? (
<span className={styles.markOnSec}>○</span>
) : (
<span className={styles.markOff}>ー</span>
)}
</td>
<td className={styles.tdRobots}>
{/* ★ロボットが0台なら「-」★(ほかの列の空欄と同じ。`orDash()` と同じ記号。指示書BC 2-6) */}
{facility.robots.length === 0 ? "-" : facility.robots.map((robot) => (
<div key={robot.id} className={styles.robotRow}>
<Pill text={robot.maker} color={c.pr} />
<span className={styles.robotModel}>{robot.model}</span>
{/*
★未登録機種を隠さず「未対応」バッジで出す(指示書N / D-087)★
レジストリに無い機種が実データから来ても画面から消さない —
隠すと例外もテスト失敗も出ないまま静かにロボットが消える。
色だけで区別せず必ず文字を出す(色覚特性・白黒印刷でも伝わるように)。
★これはAPIレスポンスに基づく判定の再計算ではない★ —
CLAUDE.md §9.6 が禁じるのは「◎○△×・L1〜L3等、データに依存する
判定をフロントで再計算すること」。ここでの isSupportedModel() は
DFA側の静的な対応表を引くだけで、APIレスポンスに `supported` を
足すと d-watch-api.yaml の変更が必要になり範囲が広がるため、
設定値の参照としてフロント側に置いている。
*/}
{!isSupportedModel(robot.maker, robot.model) && (
<Pill text={t("未対応")} color={c.wn} />
)}
<span className={styles.robotSerial}>{robot.serial_number}</span>
</div>
))}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* ★件数表示に `items.length` を使わないこと★。文言は部品側に閉じ込めてある */}
<Pagination
total={total}
page={page}
perPage={PAGINATION.defaultPerPage}
onPageChange={setPage}
/>
</div>
)}
</div>
);
}