"use client";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { FilterButton } from "@/components/ui/FilterButton";
import { Pagination } from "@/components/ui/Pagination";
import { Pill } from "@/components/ui/Pill";
import { StatCard } from "@/components/ui/StatCard";
import { useTheme } from "@/components/theme/ThemeProvider";
import { apiFetch, ApiFetchError } from "@/lib/api/client";
import { DEVICE_NOTE_MAX_LENGTH, PAGINATION } from "@/lib/config";
import { formatHm, formatMd } from "@/lib/format/date";
import {
noteDraftValue,
saveNoteDrafts,
setNoteDraft,
type NoteDrafts,
} from "@/lib/ds-devices/note-drafts";
import type { DsDeviceSummary } from "@/lib/ds-devices/query";
import { useClassificationFilter } from "@/lib/hooks/use-classification-filter";
import {
ALL_OPTION_VALUE,
appendClassificationParams,
} from "@/lib/masters/options";
import { totalPageCount } from "@/lib/pagination/page-items";
import { THEME_TOKENS, type ThemeName } from "@/lib/theme";
import type {
DsConnectionStatus,
DsDevice,
DsDeviceStatusFilter,
SummaryListResponse,
} from "@/lib/types";
import { DeviceLogsModal } from "./DeviceLogsModal";
import styles from "./DsLogScreen.module.css";
type ThemeTokens = (typeof THEME_TOKENS)[ThemeName];
/** 通信状態は正常/異常の2状態だけ(D-065。通信断は「異常」そのもの) */
function connectionStatusLabel(status: DsConnectionStatus): string {
if (status === "ok") return "正常";
return "異常";
}
function connectionStatusColor(c: ThemeTokens, status: DsConnectionStatus): string {
if (status === "ok") return c.ok;
return c.ng;
}
function formatDateTime(iso: string): string {
return `${formatMd(iso)} ${formatHm(iso)}`;
}
const EMPTY_SUMMARY: DsDeviceSummary = { total: 0, ok: 0, error: 0 };
/**
* PATCH /ds-devices/{serial_number}/note のレスポンス。
*
* `d-watch-api.yaml` ではこのスキーマに名前が付いていない(インライン定義)ため、
* `lib/types` には置かずここで持つ。名前付きスキーマになった時点で移す。
* `note` が null になり得るのは、空文字を送ると備考が削除されるため。
*/
interface DsDeviceNoteResponse {
serial_number: string;
note: string | null;
}
/** 表の列数(保存に失敗した行の colSpan)。★<colgroup> / <th> / <td> と揃えること★ */
const COLUMN_COUNT = 6;
/** 1行を送る(PATCH /ds-devices/{serial_number}/note)。★返ってきた備考(トリム済み・空なら null)を返す★。失敗は例外のまま */
async function sendDeviceNote(device: DsDevice, note: string): Promise<string | null> {
const saved = await apiFetch<DsDeviceNoteResponse>(
`/api/ds-devices/${encodeURIComponent(device.serial_number)}/note`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
}
);
return saved.note;
}
/** 行の失敗の文言。★サーバーの文言があればそれ★、無ければ既定(表示時に t() を通す) */
function rowErrorMessage(e: unknown): string {
return (e instanceof ApiFetchError ? e.serverMessage : null) ?? "保存に失敗しました";
}
export function DsLogScreen() {
const { t, theme } = useTheme();
const c = THEME_TOKENS[theme];
/**
* 分類(ホールディングス → 屋号)の絞り込み(指示書AK 段階7。D-053 を解除)。
*
* ★この画面に「検索」ボタンは無い★ — モック:834-847 のツールバーは
* セレクト2つ・検索入力・フィルタボタン3つだけで、★モック全体で「検索」ボタンが
* あるのはレポート画面(:679)1箇所だけ★(実測)。
* セレクトの変更はフィルタボタンと同じ「1操作=1確定」なので、
* ★フックの `draft` をそのまま確定値として使う★(`DetailLogScreen.tsx` と同じ判断)。
* ★debounce が要るのは文字入力(`q`)だけ★で、セレクトは1操作で1回しか変わらない。
*
* ★★分類は summary(数値カード3枚)にも効く★★ — サーバ側が
* `q` / `status` より前に分類で絞っている(`app/api/ds-devices/route.ts`)ので、
* 屋号を選ぶと「合計 / 正常 / 異常」もその屋号の値になる。
*/
const classification = useClassificationFilter(t);
const [q, setQ] = useState("");
const [debouncedQ, setDebouncedQ] = useState("");
const [statusFilter, setStatusFilter] = useState<DsDeviceStatusFilter>("all");
/**
* 現在ページ(1始まり。指示書AA 段階4)
*
* ★フィルタ・検索を変えたら必ず 1 に戻すこと★(debounce の反映時も)。
* ★備考の保存では触らないこと★(保存の後に一覧を取り直さない)— 保存のたびに1ページ目へ飛ばされると使い物にならない。
*/
const [page, setPage] = useState(1);
const [items, setItems] = useState<DsDevice[]>([]);
const [summary, setSummary] = useState<DsDeviceSummary>(EMPTY_SUMMARY);
/**
* 一覧(ステータスフィルタ後)の総件数。★`meta.total`★ を入れる。
*
* ★`summary.total` を流用しないこと★(D-054 / 指示書AA 禁止事項4)—
* `summary` はステータスフィルタの**前**に計算されているので、`status=error` で絞ると
* `summary.total > meta.total` になり、**存在しないページのボタンが出る**。
* `StatCard` に出している `summary.total` とは別物。
*/
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
/**
* ★備考の編集モード(ユーザー決定 2026-10-19 U3。D-183)★ — エラー一覧・アラート・D-Sec アラートと同じ操作。
* 変えた内容は下書きに貯め、「保存」で変えた行だけを1行ずつ送る。
* ★旧: セルを押して入力・フォーカスが外れたら1件ずつ保存(D-171 まで)は廃止★
* ・`drafts`: 変えた行の下書き(serial_number → 備考)。★行の値に戻したらエントリごと消える★(`setNoteDraft()`)
* ・`rowErrors`: 保存に失敗した行(serial_number → 文言)。★失敗した行の下書きは残す★(打ち直しにならないように)
* ・`saving`: 保存中は「保存」「キャンセル」とすべての入力を無効にする(重ねて送らない)
*/
const [editing, setEditing] = useState(false);
const [drafts, setDrafts] = useState<NoteDrafts>(() => new Map());
const [rowErrors, setRowErrors] = useState<ReadonlyMap<string, string>>(() => new Map());
const [saving, setSaving] = useState(false);
const draftCount = drafts.size;
/**
* 「ログ」モーダル(指示書AY 段階9-1)。
* 閉じたら押したボタンへフォーカスを戻すため、開いたときのボタンを保持する。
*/
const [logsTarget, setLogsTarget] = useState<DsDevice | null>(null);
const logsButtonRef = useRef<HTMLButtonElement | null>(null);
// ★下書きがあるあいだはページを離れる前に確認する★(D-165)
useEffect(() => {
if (draftCount === 0) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [draftCount]);
// ★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("status", statusFilter);
// ★分類は手書きしない★(`group_id=0` を漏らさない・空のキーを並べないための
// 判断が `appendClassificationParams()` に入っている。指示書AK 落とし穴3)
appendClassificationParams(params, classification.draft);
params.set("per_page", String(PAGINATION.defaultPerPage));
params.set("page", String(page));
return params.toString();
// ★`page` を依存に入れること★ — 入れ忘れるとボタンを押しても取り直さない
}, [debouncedQ, statusFilter, classification.draft, 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<SummaryListResponse<DsDevice, DsDeviceSummary>>(`/api/ds-devices?${queryString}`)
.then(
(body) => {
if (cancelled) return;
setItems(body.items);
setSummary(body.summary);
// ★総ページ数は `meta.total` から出す(`summary.total` ではない)★
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([]);
setSummary(EMPTY_SUMMARY);
setTotal(0);
}
)
.finally(() => {
// ★古い取得の finally が新しい取得の読み込み中を消さないように★
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [queryString]);
/** ★編集モードに入れるのは、表示中の一覧が今の絞り込みの結果と一致しているときだけ★(エラー一覧と同じ)—
* 読み込み中や検索語の確定前(debounce の待ち)に入ると、編集中に一覧が取り直されて下書きの行が消える */
const canStartEdit = !loading && error === null && items.length > 0 && q === debouncedQ;
/** ★編集中・保存中は絞り込みとページ送りを無効にする★(下書きが消えないように) */
const filtersDisabled = editing;
const startEdit = () => {
setRowErrors(new Map());
setEditing(true);
};
/** キャンセル。★下書きがあるときだけ確認する★(OK なら下書きを捨てて通常モード) */
const cancelEdit = () => {
if (draftCount > 0 && !window.confirm(t("変更を破棄しますか?"))) return;
setDrafts(new Map());
setRowErrors(new Map());
setEditing(false);
};
/**
* 保存。★変えた行だけを1行ずつ順に送る★(`saveNoteDrafts()`)。
* ・★成功した行はサーバーが返した備考で差し替える★(トリム・「空 → 削除」をサーバーが行うため。一覧は取り直さない)
* ・★失敗した行は下書きを残し、行の直後に文言を出す★。★全部成功なら通常モードに戻る★
* ・変更が0件なら何も送らずに通常モードに戻る(「編集を終了」の意味)
*/
const saveEdit = async () => {
if (saving) return;
if (draftCount === 0) {
setRowErrors(new Map());
setEditing(false);
return;
}
setSaving(true);
setRowErrors(new Map());
try {
const { updated, failed, remaining } = await saveNoteDrafts(items, drafts, sendDeviceNote);
if (updated.size > 0) {
setItems((prev) =>
prev.map((d) =>
updated.has(d.serial_number) ? { ...d, note: updated.get(d.serial_number) ?? null } : d
)
);
}
setDrafts(remaining);
setRowErrors(
new Map(Array.from(failed, ([serial, e]): [string, string] => [serial, rowErrorMessage(e)]))
);
if (failed.size === 0) setEditing(false);
} finally {
setSaving(false);
}
};
return (
<div className={styles.page}>
<div className={styles.header}>
<div>
<h1 className={styles.title}>{t("D-Secログ")}</h1>
<div className={styles.subtitle}>{t("D-S制御盤の稼働状況")}</div>
</div>
<div className={styles.headerRight}>
{/* ★編集モードの切り替え(D-165 / D-183)★ — ★ボタンは固定幅★(JP/EN で位置が動かないように)。
★件数は出さない★(この画面には無い。ページ送りが件数を出す) */}
{editing ? (
<>
<button
type="button"
className={`${styles.editButton} ${styles.saveButton}`}
disabled={saving}
onClick={() => void saveEdit()}
>
{saving
? t("保存中…")
: draftCount > 0
? t("保存({n}件)").replace("{n}", String(draftCount))
: t("保存")}
</button>
<button
type="button"
className={`${styles.editButton} ${styles.cancelButton}`}
disabled={saving}
onClick={cancelEdit}
>
{t("キャンセル")}
</button>
</>
) : (
<button
type="button"
className={`${styles.editButton} ${styles.startButton}`}
disabled={!canStartEdit}
onClick={startEdit}
>
{t("編集")}
</button>
)}
</div>
</div>
<div className={styles.statGrid}>
<StatCard value={String(summary.total)} label={t("合計")} tone="ac" />
<StatCard value={String(summary.ok)} label={t("正常")} tone="ok" />
<StatCard value={String(summary.error)} label={t("異常")} sub={t("要対応")} tone="ng" />
</div>
{/* ★編集中は絞り込みをまとめて無効にする★(<fieldset disabled> で中のセレクト・検索・ボタンが全部無効になる。下書きが消えないように。D-165 / D-183) */}
<fieldset className={styles.toolbar} disabled={filtersDisabled}>
{/* ★分類の絞り込み(モック:835-840。指示書AK 段階7 で D-053 を解除)★
・選択肢は `/groups` と `/customers` から取る(★固定の選択肢を書かない★。
モックの "PPIH" / "ドン・キホーテ" を写し取ったのが D-053 で消されたUI)
・★取得に失敗した/屋号が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);
// ★絞り込みを変えたらその場で1ページ目に戻す★
// (件数が減ったときにページ3へ取り残されないため)
setPage(1);
}}
>
{classification.groupOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
)}
<select
/* 幅の固定と title は上のホールディングスと同じ(D-162) */
className={`${styles.select} ${styles.masterSelect}`}
aria-label={t("顧客")}
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);
setPage(1);
}}
>
{classification.customerOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</>
)}
<input
className={styles.searchInput}
aria-label={t("施設名・シリアルで検索")}
placeholder={t("施設名・シリアルで検索")}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className={styles.statusButtons}>
<FilterButton
active={statusFilter === "all"}
color={c.txD}
onClick={() => {
setStatusFilter("all");
setPage(1);
}}
>
{t("全て")}
</FilterButton>
<FilterButton
active={statusFilter === "error"}
color={c.ng}
onClick={() => {
setStatusFilter("error");
setPage(1);
}}
>
{t("異常")}
</FilterButton>
<FilterButton
active={statusFilter === "ok"}
color={c.ok}
onClick={() => {
setStatusFilter("ok");
setPage(1);
}}
>
{t("正常")}
</FilterButton>
</div>
</fieldset>
{/* 編集モードの案内と、保存に失敗した件数(D-165 / D-183) */}
{editing && (
<div className={styles.editNotice}>
<span>{t("編集中は絞り込みとページ送りを使えません")}</span>
{rowErrors.size > 0 && (
<span className={styles.editNoticeError}>
{t("{n}件を保存できませんでした").replace("{n}", String(rowErrors.size))}
</span>
)}
</div>
)}
{loading && <div className={styles.loading}>{t("読み込み中…")}</div>}
{/* サーバーの文言は辞書に無ければ原文のまま */}
{!loading && error && <div className={styles.error}>{t(error)}</div>}
{!loading && !error && (
<div className={styles.card}>
<div className={styles.tableWrap}>
<table className={styles.table}>
{/*
★列幅は言語によらず固定する(table-layout: fixed)★
見出しの文字で列幅が決まる作りだと、JP/EN を切り替えるたびに列ごとに幅が変わって
表が横に動く(2026-10-01 ユーザー指摘)。通常モード ↔ 編集モードでも幅が動かない。
幅の値と理由は CSS 側の .col* に書く。
★施設名列にも幅を付け、表の余った幅は全列に幅の比で配る★(ユーザー指示 2026-10-06
「3つとも同じように調整して」。D-167 追記。旧: 施設名列だけ幅を指定せず余りを全部受け持っていた)。
列を足す・並べ替えるときはここの <col> も同じ順に直すこと(ずれると別の列に幅が付く)。
*/}
<colgroup>
<col className={styles.colSerial} />
<col className={styles.colFacility} />
<col className={styles.colStatus} />
<col className={styles.colLastReported} />
<col className={styles.colNote} />
<col className={styles.colLogs} />
</colgroup>
<thead>
<tr>
<th className={styles.th}>{t("シリアル")}</th>
<th className={styles.th}>{t("施設名")}</th>
<th className={styles.thCenter}>{t("接続状態")}</th>
<th className={styles.th}>{t("最終通信")}</th>
<th className={styles.th}>{t("備考")}</th>
<th className={styles.thCenter}>{t("ログ")}</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
// ★変えた行の目印★(下書きがある行。保存に失敗した行も下書きが残るので目印が付いたまま。エラー一覧と同じ)
const changed = drafts.has(item.serial_number);
const rowError = rowErrors.get(item.serial_number);
return (
<Fragment key={item.serial_number}>
<tr className={changed ? `${styles.row} ${styles.rowChanged}` : styles.row}>
<td className={styles.tdSerial}>{item.serial_number}</td>
<td className={styles.td}>{item.facility_name}</td>
<td className={styles.tdCenter}>
<Pill
text={t(connectionStatusLabel(item.connection_status))}
color={connectionStatusColor(c, item.connection_status)}
/>
</td>
{/* ★心拍がまだ無い制御盤は null → 「-」★(指示書BB A1) */}
<td className={styles.tdDate}>{item.last_reported_at ? formatDateTime(item.last_reported_at) : "-"}</td>
<td className={styles.tdNote}>
{editing ? (
/* ★入力欄は固定の大きさ★(2行ぶん・resize: none・幅は列いっぱい。エラー一覧と同じ)。
上限超過(422)を画面側で先に防ぐ。上限値の正は lib/config(YAML と同じ) */
<textarea
className={styles.noteInput}
aria-label={t("備考")}
value={noteDraftValue(item, drafts)}
rows={2}
maxLength={DEVICE_NOTE_MAX_LENGTH}
placeholder={t("備考を入力")}
disabled={saving}
onChange={(e) => setDrafts((prev) => setNoteDraft(prev, item, e.target.value))}
/>
) : (
/* ★通常モードは文字だけ★(1行・はみ出しは「…」・全文は title。押しても何も起きない。
★空のときは何も出さない★ — エラー一覧・アラートと同じ。旧: 「-」のボタン) */
<div className={styles.noteText} title={item.note ?? undefined}>
{item.note ?? ""}
</div>
)}
</td>
<td className={styles.tdLogs}>
{/* ★編集中は押せない(U6)★。表は fieldset の外なので disabled を直接付ける。
★幅は固定★(JP/EN で位置が動かない。M1) */}
<button
type="button"
className={styles.logsButton}
disabled={filtersDisabled}
onClick={(e) => {
logsButtonRef.current = e.currentTarget;
setLogsTarget(item);
}}
>
{t("ログ")}
</button>
</td>
</tr>
{/* ★保存に失敗した行は直後に文言を出す★(下書きは残っている)。サーバーの文言は辞書に無ければ原文のまま */}
{rowError !== undefined && (
<tr>
<td colSpan={COLUMN_COUNT} className={styles.rowError}>
{t(rowError)}
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
{items.length === 0 && (
<div className={styles.empty}>{t("該当するデバイスはありません")}</div>
)}
{/* ★件数表示に `items.length` を使わないこと★。0件なら部品側が null を返す。
★編集中はページ送りを無効にする★(部品に disabled が無いので `<fieldset disabled>` で包む。D-165 / D-183) */}
<fieldset className={styles.pagerFieldset} disabled={filtersDisabled}>
<Pagination
total={total}
page={page}
perPage={PAGINATION.defaultPerPage}
onPageChange={setPage}
/>
</fieldset>
</div>
)}
{logsTarget && (
<DeviceLogsModal
serialNumber={logsTarget.serial_number}
facilityName={logsTarget.facility_name}
restoreFocusRef={logsButtonRef}
onClose={() => setLogsTarget(null)}
/>
)}
</div>
);
}