"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openDatePicker } from "@/components/ui/date-picker";
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 { dateRangeError } from "@/lib/clean-logs/detail-log-date-range";
import { PAGINATION, PRECHECK_NOTE_MAX_LENGTH } from "@/lib/config";
import { formatHm, formatMd } from "@/lib/format/date";
import { useClassificationFilter } from "@/lib/hooks/use-classification-filter";
import {
ALL_OPTION_VALUE,
appendClassificationParams,
type MasterOption,
} from "@/lib/masters/options";
import { totalPageCount } from "@/lib/pagination/page-items";
import {
draftNote,
draftStatus,
saveDrafts,
setDraftNote,
setDraftStatus,
showsResolvedTime,
type ResolutionDrafts,
} from "@/lib/prechecks/edit-drafts";
import { precheckSeverityLabel } from "@/lib/prechecks/label";
import { applyResolutionChange, type PrecheckSummary } from "@/lib/prechecks/query";
import { THEME_TOKENS, type ThemeName } from "@/lib/theme";
import { statusColor } from "@/lib/theme-status";
import type {
PrecheckItem,
PrecheckStatus,
PrecheckStatusUpdateRequest,
SummaryListResponse,
} from "@/lib/types";
import styles from "./AlertsScreen.module.css";
type ThemeTokens = (typeof THEME_TOKENS)[ThemeName];
type StatusButtonFilter = "open" | "all" | "resolved";
/**
* 種別(左の帯・種別のバッジ)の色。★事前チェック異常は警告色(`c.wn`)★。
* ★アラートは事前チェック異常だけ★(ユーザー判断 2026-10-29)— 旧「出発失敗」の赤(`c.ng`)の分岐は消した。
*/
function severityColor(c: ThemeTokens): string {
return c.wn;
}
function formatDateTime(iso: string): string {
return `${formatMd(iso)} ${formatHm(iso)}`;
}
/**
* 選んでいる選択肢のラベル(分類のセレクトの `title` に使う)。
* ★セレクトは固定幅で長い名前は「…」で切れる★ので、hover で全文を読めるようにする(ユーザー指示 2026-10-06。D-162)
*/
function selectedOptionLabel(options: readonly MasterOption[], value: string): string | undefined {
return options.find((o) => o.value === value)?.label;
}
function statusBadgeClass(status: PrecheckStatus): string {
switch (status) {
case "resolved":
return styles.statusResolved;
case "in_progress":
return styles.statusInProgress;
case "unresolved":
return styles.statusUnresolved;
}
}
/**
* 対応状況のプルダウンの選択肢(この順で並べる)。ラベルは `t()` に通す辞書キー。
* ★`value` は `PrecheckStatus` そのもの★ — onChange で受け取った文字列をこの表で引き直して型を戻す
*/
const RESOLUTION_STATUS_OPTIONS: readonly { value: PrecheckStatus; label: string }[] = [
{ value: "unresolved", label: "未解消" },
{ value: "in_progress", label: "対応中" },
{ value: "resolved", label: "解消" },
];
const EMPTY_SUMMARY: PrecheckSummary = { unresolved: 0, resolved: 0, total: 0 };
/**
* ★期間の日数の上限は設けない★(`/prechecks` は上限なし。指示書AW §11 Q13)。
* `dateRangeError()` に無限大を渡して「開始 > 終了」だけを検証する(エラー一覧と同じ。指示書BC 2-3)
*/
const NO_MAX_DATE_RANGE_DAYS = Number.POSITIVE_INFINITY;
/** 対応状況のバッジ・プルダウンの文言(辞書キー)。表は `RESOLUTION_STATUS_OPTIONS` が正 */
function resolutionStatusLabel(status: PrecheckStatus): string {
return RESOLUTION_STATUS_OPTIONS.find((o) => o.value === status)?.label ?? status;
}
/**
* カードの class(★変えた行の目印★ と ★本清掃が成功した行を控えめにする★ を重ねる)。
*
* ★本清掃が成功した事前チェック異常(`main_run_succeeded`)は控えめに出す★(ユーザー指示 2026-10-07。D-171 の案2)—
* ★対応状況(未解消など)・件数は変えない★。見た目だけ。★編集モードでも同じ見た目★で、
* プルダウン・備考は今までどおり使える(不透明度は操作部品にかけない。`.itemCardSubdued` の CSS)
*/
function itemCardClass(item: PrecheckItem, changed: boolean): string {
const classes = [styles.itemCard];
if (item.main_run_succeeded) classes.push(styles.itemCardSubdued);
if (changed) classes.push(styles.itemCardChanged);
return classes.join(" ");
}
/**
* 1行ぶんの PATCH(`PATCH /prechecks/{id}/status`。指示書AT §3-8)。
* ★失敗は例外のまま投げる★(`saveDrafts()` が行ごとに受け止めて下書きを残す。D-165)
*/
function sendPrecheckPatch(
item: PrecheckItem,
request: PrecheckStatusUpdateRequest
): Promise<PrecheckItem> {
return apiFetch<PrecheckItem>(`/api/prechecks/${encodeURIComponent(item.id)}/status`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
}
/** 行の下に出す保存失敗の文言。★サーバーの文言を優先★(無ければ汎用の文言) */
function saveErrorMessage(e: unknown): string {
return (e instanceof ApiFetchError ? e.serverMessage : null) ?? "保存に失敗しました";
}
export function AlertsScreen() {
const { t, theme } = useTheme();
const c = THEME_TOKENS[theme];
/**
* 分類(ホールディングス → 屋号)の絞り込み(指示書AK 段階7。D-053 を解除)。
*
* ★この画面に「検索」ボタンは無い★ — モック:602-620 のツールバーは
* 日付2つ・セレクト2つ・ステータスボタン3つだけで、★モック全体で「検索」ボタンが
* あるのはレポート画面(:679)1箇所だけ★(実測)。
* セレクトの変更は日付入力やステータスボタンと同じ「1操作=1確定」なので、
* ★フックの `draft` をそのまま確定値として使う★(`DetailLogScreen.tsx` と同じ判断。
* `ReportsScreen.tsx` のような draft/確定の二重持ちはしない)。
*/
const classification = useClassificationFilter(t);
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusButtonFilter>("open");
/**
* 現在ページ(1始まり。指示書AA 段階4)
*
* ★フィルタを変えたら必ず 1 に戻すこと★ — 件数が減ったときにページ3へ
* 取り残されると、ユーザーが名指しで禁じた「押したら有りません」の状態になる。
* ★`useEffect` で後追いしないこと★ — 古いページのままフェッチが1回余計に飛ぶ。
* state を変えるその場で一緒に戻す。
*/
const [page, setPage] = useState(1);
const [items, setItems] = useState<PrecheckItem[]>([]);
const [summary, setSummary] = useState<PrecheckSummary>(EMPTY_SUMMARY);
/**
* 一覧(対応状況フィルタ後)の総件数。★`meta.total`★ を入れる。
*
* ★`summary.total` を流用しないこと★(D-054 / 指示書AA 禁止事項4)—
* `summary` は対応状況フィルタの**前**に計算されているので、`status=open` で絞ると
* `summary.total > meta.total` になり、**存在しないページのボタンが出る**。
*/
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
/**
* ★対応状況・備考の編集モード(ユーザー指示 2026-10-06。D-165)★ — 画面全体で切り替える。
* ・通常モードでは表示だけ(押しても何も起きない)
* ・編集モードで変えた内容は ★`drafts`(下書き)に貯めるだけで送らない★。「保存」でまとめて送る
* ・★編集モード中は絞り込みとページ送りを無効にする★(取り直すと下書きの行が一覧から消えるため)
* ・旧(D-161 / 指示書AT §3-8)の「プルダウンを選んだら即送信」「備考はフォーカスが外れたら送信」は廃止
*/
const [editing, setEditing] = useState(false);
const [drafts, setDrafts] = useState<ResolutionDrafts>(() => new Map());
const [saving, setSaving] = useState(false);
/** 行ごとの保存失敗(id → 文言)。★失敗した行は下書きを残し、行の下に出す★ */
const [rowErrors, setRowErrors] = useState<ReadonlyMap<string, string>>(() => new Map());
/** 直前の保存で失敗した件数(一覧の上に「{n}件を保存できませんでした」)。失敗が無ければ 0 */
const [failedCount, setFailedCount] = useState(0);
const queryString = useMemo(() => {
const params = new URLSearchParams();
if (dateFrom) params.set("date_from", dateFrom);
if (dateTo) params.set("date_to", dateTo);
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` を依存に入れること★ — 入れ忘れると `queryString` が変わらず、
// 取得の effect も走らないので、ボタンを押しても取り直さない
}, [dateFrom, dateTo, statusFilter, classification.draft, page]);
// ★期間の検証は両方入っているときだけ★。上限は無し(`NO_MAX_DATE_RANGE_DAYS`)。文言はサーバと同じ(指示書BC 2-3)
const rangeError = dateRangeError(dateFrom, dateTo, NO_MAX_DATE_RANGE_DAYS);
const dateRangeUnset = !dateFrom && !dateTo;
// ★一覧の取得。取得中に絞り込みを変えたら、遅れて届いた古い応答は捨てる★(片付けで cancelled にする。
// 捨てないと前の条件の結果が新しい条件の表示を上書きする。エラー一覧と同じ考え方。指示書BC 段階1・D-183)
useEffect(() => {
if (rangeError !== null) {
// ★期間の誤りは取得しない★(今の取得失敗と同じ空に戻す。前の取得は React が先に片付ける。指示書BC 2-3)
// eslint-disable-next-line react-hooks/set-state-in-effect -- フィルタ変更に合わせた一覧の取り直し
setError(rangeError);
setItems([]);
setSummary(EMPTY_SUMMARY);
setTotal(0);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
apiFetch<SummaryListResponse<PrecheckItem, PrecheckSummary>>(`/api/prechecks?${queryString}`)
.then(
(body) => {
if (cancelled) return;
setItems(body.items);
setSummary(body.summary);
// ★総ページ数は `meta.total` から出す(`summary.total` ではない)★
setTotal(body.meta.total);
// ★フィルタ外の要因(データ更新など)で総ページ数が減り、存在しないページに
// 取り残された場合の保険。最終ページに寄せて取り直す★
// `pages > 0` の判定を落とさないこと(0件のとき setPage(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, rangeError]);
/**
* ★下書きがあるあいだはページを離れる前に確認する★(D-165)。
* 下書きが無くなったら(保存・キャンセル)外す。
*/
const hasDrafts = drafts.size > 0;
useEffect(() => {
if (!hasDrafts) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [hasDrafts]);
/** 下書き・失敗表示を捨てて通常モードに戻る */
const exitEditMode = useCallback(() => {
setEditing(false);
setDrafts(new Map());
setRowErrors(new Map());
setFailedCount(0);
}, []);
const startEditMode = useCallback(() => {
setRowErrors(new Map());
setFailedCount(0);
setEditing(true);
}, []);
/** ★下書きがあれば確認してから捨てる★(無ければ確認なしで戻る) */
const cancelEditMode = useCallback(() => {
if (drafts.size > 0 && !window.confirm(t("変更を破棄しますか?"))) return;
exitEditMode();
}, [drafts, exitEditMode, t]);
/**
* 下書きをまとめて保存する(D-165)。★変えた行だけを1行ずつ順に★ PATCH する(`saveDrafts()`)。
*
* ・★成功した行はレスポンスの `PrecheckItem` で差し替える★。サマリは `applyResolutionChange()` で差分だけ反映する
* ・★保存の後に一覧を取り直さないこと★ — 取り直すとローディング表示に入れ替わって位置を見失う
* (エラー一覧の `saveEdit()`・D-Sec ログの `saveEdit()` と同じ理由)。
* ・★絞り込み(未解消のみ)に合わなくなっても、次に取り直すまで行を消さない★(押し間違いをその場で戻せるように)
* ・★全部成功 → 通常モード★。★1件でも失敗 → 編集モードのまま★、失敗した行の下書きと文言を残す
* ・変更が0件なら何も送らずに通常モードに戻る(「保存」が「編集を終了」の意味になる)
*/
const saveEdits = useCallback(async () => {
if (drafts.size === 0) {
exitEditMode();
return;
}
setSaving(true);
setRowErrors(new Map());
setFailedCount(0);
try {
// ★全行が事前チェック異常なので全行を編集できる★(旧: 出発失敗の行を外していた。2026-10-29)
const rows = items;
const result = await saveDrafts(rows, drafts, sendPrecheckPatch);
if (result.updated.size > 0) {
setItems((prev) => prev.map((p) => result.updated.get(p.id) ?? p));
setSummary((prev) =>
rows.reduce((acc, row) => {
const updated = result.updated.get(row.id);
return updated
? applyResolutionChange(acc, row.resolution_status, updated.resolution_status)
: acc;
}, prev)
);
}
setDrafts(result.remaining);
if (result.failed.size === 0) {
setEditing(false);
} else {
setRowErrors(
new Map([...result.failed].map(([id, e]) => [id, saveErrorMessage(e)] as const))
);
setFailedCount(result.failed.size);
}
} finally {
setSaving(false);
}
}, [drafts, exitEditMode, items]);
const changeDraftStatus = useCallback((item: PrecheckItem, value: string) => {
const next = RESOLUTION_STATUS_OPTIONS.find((o) => o.value === value)?.value;
// 表に無い値なら何もしない(`value` を `PrecheckStatus` に戻すため表で引き直す)
if (next === undefined) return;
setDrafts((prev) => setDraftStatus(prev, item, next));
}, []);
const changeDraftNote = useCallback((item: PrecheckItem, note: string) => {
setDrafts((prev) => setDraftNote(prev, item, note));
}, []);
// 分類のセレクトの値(`value` と `title` の両方で使う)
const groupValue =
classification.draft.groupId === null
? ALL_OPTION_VALUE
: String(classification.draft.groupId);
const customerValue =
classification.draft.customerId === null
? ALL_OPTION_VALUE
: String(classification.draft.customerId);
return (
<div className={styles.page}>
<div className={styles.header}>
<div>
<h1 className={styles.title}>{t("アラート(事前チェック)")}</h1>
{/* ★出発失敗は出さない(ユーザー判断 2026-10-29)★ — モック(:584)の「…と出発失敗」から文言を変えた。出発失敗は清掃詳細ログで見る */}
<div className={styles.subtitle}>{t("スケジュール開始前のエラー検知")}</div>
</div>
<div className={styles.headerRight}>
{/* ★編集 / 保存({n}件)/ キャンセル(D-165)★ — 見出しの右(エラー一覧・D-Sec と同じ位置・同じ見た目。D-181)。
★ボタンは固定幅★(JP/EN や件数で位置が動かないように) */}
{editing ? (
<>
<button
type="button"
className={`${styles.editButton} ${styles.saveButton}`}
disabled={saving}
onClick={() => void saveEdits()}
>
{saving
? t("保存中…")
: drafts.size > 0
? t("保存({n}件)").replace("{n}", String(drafts.size))
: t("保存")}
</button>
<button
type="button"
className={`${styles.editButton} ${styles.cancelButton}`}
disabled={saving}
onClick={cancelEditMode}
>
{t("キャンセル")}
</button>
</>
) : (
<button
type="button"
className={`${styles.editButton} ${styles.startButton}`}
/* 行が無いときは押せない(押しても何も変えられない) */
disabled={loading || items.length === 0}
onClick={startEditMode}
>
{t("編集")}
</button>
)}
</div>
</div>
<div className={styles.statGrid}>
<StatCard value={String(summary.unresolved)} label={t("未解消")} sub={t("要対応")} tone="ng" />
<StatCard value={String(summary.resolved)} label={t("解消済み")} sub={t("対応完了")} tone="ok" />
<StatCard value={String(summary.total)} label={t("合計")} sub={t("全アラート")} tone="ac" />
</div>
<div className={styles.toolbar}>
{/* ★編集モード中は絞り込みを無効にする(ユーザー指示 2026-10-06。D-165)★ —
取り直すと下書きの行が一覧から消えるため。`<fieldset disabled>` で中の入力・ボタンをまとめて無効にする
(`FilterButton` に disabled を足さずに済む) */}
<fieldset className={styles.filterSet} disabled={editing}>
<div className={styles.dateGroup}>
{/* ★期間が空のときは「全期間」と分かるようにする★(清掃詳細ログ(指示書AV 段階5b-5)と同じ)—
`<input type="date">` の placeholder は多くのブラウザで表示されないので、両方空のあいだだけ文言を出す */}
{dateRangeUnset && <span className={styles.dateSep}>{t("指定なし(全期間)")}</span>}
<input
type="date"
className={styles.dateInput}
/* 枠のどこを押してもカレンダーを開く(ユーザー指示 2026-10-06。D-162) */
onClick={openDatePicker}
aria-label={t("開始日")}
placeholder={t("指定なし(全期間)")}
value={dateFrom}
onChange={(e) => {
setDateFrom(e.target.value);
setPage(1);
}}
/>
<span className={styles.dateSep}>〜</span>
<input
type="date"
className={styles.dateInput}
/* 枠のどこを押してもカレンダーを開く(ユーザー指示 2026-10-06。D-162) */
onClick={openDatePicker}
aria-label={t("終了日")}
placeholder={t("指定なし(全期間)")}
value={dateTo}
onChange={(e) => {
setDateTo(e.target.value);
setPage(1);
}}
/>
</div>
{/* ★分類の絞り込み(モック:609-614。指示書AK 段階7 で D-053 を解除)★
・選択肢は `/groups` と `/customers` から取る(★固定の選択肢を書かない★。
モックの "PPIH" / "ドン・キホーテ" を写し取ったのが D-053 で消されたUI)
・★取得に失敗した/屋号が0件のときは出さない★(`available` が false)—
★空の `<select>` は「押しても何も起きないUI」への逆戻り★(落とし穴11)
・★option のラベルを `t()` に通さないこと★ — 「全ホールディングス」等は
フック側で `t()` 済みで、屋号名まで通すと辞書の対訳に誤って当たる
・★幅は固定(`.masterSelect`)★ — 選んだ名前の長さで幅が変わらないように。切れた名前は `title` で読む
(ユーザー指示 2026-10-06。D-162) */}
{classification.available && (
<>
{classification.hasGroups && (
<select
className={`${styles.select} ${styles.masterSelect}`}
aria-label={t("ホールディングス")}
value={groupValue}
title={selectedOptionLabel(classification.groupOptions, groupValue)}
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
className={`${styles.select} ${styles.masterSelect}`}
aria-label={t("顧客")}
value={customerValue}
title={selectedOptionLabel(classification.customerOptions, customerValue)}
onChange={(e) => {
classification.selectCustomer(e.target.value);
setPage(1);
}}
>
{classification.customerOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</>
)}
<div className={styles.statusButtons}>
<FilterButton
active={statusFilter === "open"}
color={c.ng}
onClick={() => {
setStatusFilter("open");
setPage(1);
}}
>
{t("未解消のみ")}
</FilterButton>
<FilterButton
active={statusFilter === "all"}
color={c.txD}
onClick={() => {
setStatusFilter("all");
setPage(1);
}}
>
{t("全件")}
</FilterButton>
<FilterButton
active={statusFilter === "resolved"}
color={c.ok}
onClick={() => {
setStatusFilter("resolved");
setPage(1);
}}
>
{t("解消済み")}
</FilterButton>
</div>
</fieldset>
</div>
{/* 編集モードの案内と、保存に失敗した件数(D-165) */}
{editing && (
<div className={styles.editNotice}>
<span>{t("編集中は絞り込みとページ送りを使えません")}</span>
{failedCount > 0 && (
<span className={styles.editNoticeError}>
{t("{n}件を保存できませんでした").replace("{n}", String(failedCount))}
</span>
)}
</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.list}>
{items.map((item) => {
const draft = drafts.get(item.id);
const status = draftStatus(item, draft);
const note = draftNote(item, draft);
const rowError = rowErrors.get(item.id);
return (
<div
key={item.id}
/* ★変えた行の目印は背景と上・右・下の枠(`.itemCardChanged`。D-Sec と同じ。D-181)★ — 左の帯は分類の色なので変えない(D-165)。
★本清掃が成功した行は控えめ(`.itemCardSubdued`。D-171)★ — 左の帯も色はそのまま薄くする */
className={itemCardClass(item, draft !== undefined)}
style={{
borderLeftColor: item.main_run_succeeded
? `color-mix(in srgb, ${severityColor(c)} 60%, transparent)`
: severityColor(c),
}}
>
<div className={styles.itemRow}>
<div className={styles.itemMain}>
<div className={styles.itemBadges}>
<Pill
text={precheckSeverityLabel(t, item)}
color={severityColor(c)}
/>
<Pill text={item.robot_status} color={statusColor(theme, item.robot_status)} />
{/* ★本清掃が成功した事前チェック異常の目印(D-171)★ — 対応状況のバッジは変えない */}
{item.main_run_succeeded && <Pill text={t("本清掃は成功")} color={c.ok} />}
{/* `robot_error_reports.serial_number` の生値(null にならない。t() に通さない) */}
<span className={styles.serial}>{item.serial_number}</span>
</div>
<div className={styles.facility}>{item.facility_name}</div>
{editing ? (
/* ★編集モード(D-165)★ — 入力欄は「予定: HH:MM」の下。2行ぶんの固定の高さ・幅は列いっぱい */
<>
<div className={styles.scheduleLine}>
{t("予定")}: {formatHm(item.scheduled_at)}
</div>
<textarea
className={styles.noteInput}
aria-label={t("備考")}
value={note}
rows={2}
/* 上限超過(422)を画面側で先に防ぐ。上限値の正は lib/config(YAML の maxLength と同じ) */
maxLength={PRECHECK_NOTE_MAX_LENGTH}
placeholder={t("備考を入力")}
disabled={saving}
onChange={(e) => changeDraftNote(item, e.target.value)}
/>
</>
) : note ? (
/* ★通常モードは表示だけ(D-165)★。★1行に収める(D-162)★ — `.scheduleLineNote` で横並びにし、
備考の枠(`.noteText`)が残りの幅を全部取って、はみ出す分は「…」。全文は `title`。
★空のときは何も出さない★ */
<div className={`${styles.scheduleLine} ${styles.scheduleLineNote}`}>
<span className={styles.schedulePrefix}>
{t("予定")}: {formatHm(item.scheduled_at)} —
</span>
<span className={styles.noteText} title={note}>
{note}
</span>
</div>
) : (
<div className={styles.scheduleLine}>
{t("予定")}: {formatHm(item.scheduled_at)}
{item.note ? ` — ${item.note}` : ""}
</div>
)}
{item.battery_level !== null && (
<div className={styles.batteryLine}>
{t("残量")} {item.battery_level}%
</div>
)}
<div className={styles.detectedLine}>
{t("検知")}: {formatDateTime(item.detected_at)}
</div>
</div>
<div className={styles.statusColumn}>
{/* ★編集モードはプルダウン★(未解消 / 対応中 / 解消 から選ぶ。D-161 の見た目)。
通常モードは色付きのバッジ(表示だけ。D-165)。
★バッジもプルダウンも 120px 固定・解消日時は下★(エラー一覧・D-Sec と同じ。D-181) */}
{editing ? (
<select
className={`${styles.statusSelect} ${statusBadgeClass(status)}`}
aria-label={t("対応状況")}
value={status}
disabled={saving}
onChange={(e) => changeDraftStatus(item, e.target.value)}
>
{RESOLUTION_STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{t(o.label)}
</option>
))}
</select>
) : (
<span
className={`${styles.statusBadge} ${styles.statusBadgeFixed} ${statusBadgeClass(status)}`}
>
{t(resolutionStatusLabel(status))}
</span>
)}
{/* 解消日時は解消のときだけ下に出す(`showsResolvedTime()`。3画面で同じ。D-181)。
★下書きで解消にしただけの行・下書きで未解消/対応中に変えた行には出さない★ */}
{showsResolvedTime(item, draft) && (
<div className={styles.statusTime}>
{item.resolved_at ? formatDateTime(item.resolved_at) : "-"}
</div>
)}
</div>
</div>
{/* 保存に失敗した行の文言。サーバーの文言は辞書に無ければ原文のまま(`DsLogScreen.tsx` と同じ) */}
{rowError !== undefined && <div className={styles.rowError}>{t(rowError)}</div>}
</div>
);
})}
</div>
)}
{/* ★件数表示に `items.length` を使わないこと★ —
ローディング中や取得失敗で items が空になり、件数だけ0に見える。
0件のときは部品側が null を返すので、空表示と二重にならない */}
{/* ★編集モード中はページ送りも無効(D-165)★ — `<fieldset disabled>` で中のボタンをまとめて無効にする */}
<fieldset className={styles.pagerSet} disabled={editing}>
<Pagination
total={total}
page={page}
perPage={PAGINATION.defaultPerPage}
onPageChange={setPage}
/>
</fieldset>
</div>
);
}