"use client";
import { useCallback, useEffect, useMemo, 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 { PAGINATION } from "@/lib/config";
import { formatHm, formatMd } from "@/lib/format/date";
import type { DsAlertSummary } from "@/lib/ds-alerts/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 {
DsAlert,
DsAlertStatusFilter,
DsAlertType,
DsAlertTypeFilter,
SummaryListResponse,
} from "@/lib/types";
import styles from "./DsAlertsScreen.module.css";
type ThemeTokens = (typeof THEME_TOKENS)[ThemeName];
const TYPE_LABEL: Record<DsAlertType, string> = {
security_off: "セキュリティ無効",
disconnected: "通信断",
};
function typeColor(c: ThemeTokens, type: DsAlertType): string {
return type === "disconnected" ? c.wn : c.ng;
}
function formatDateTime(iso: string): string {
return `${formatMd(iso)} ${formatHm(iso)}`;
}
const EMPTY_SUMMARY: DsAlertSummary = { pending: 0, in_progress: 0, done: 0 };
export function DsAlertsScreen() {
const { t, theme } = useTheme();
const c = THEME_TOKENS[theme];
/**
* 分類(ホールディングス → 屋号)の絞り込み(指示書AK 段階7。D-053 を解除)。
*
* ★この画面に「検索」ボタンは無い★ — モック:916-936 のツールバーは
* 日付2つ・セレクト3つ・ステータスボタン3つだけで、★モック全体で「検索」ボタンが
* あるのはレポート画面(:679)1箇所だけ★(実測)。
* 隣の種別セレクト(:928)が既に「1操作=1確定」なので、分類だけ検索ボタン待ちに
* すると同じ並びのセレクトで挙動が食い違う。★フックの `draft` をそのまま
* 確定値として使う★(`DetailLogScreen.tsx` と同じ判断)。
*/
const classification = useClassificationFilter(t);
const [typeFilter, setTypeFilter] = useState<DsAlertTypeFilter>("all");
const [statusFilter, setStatusFilter] = useState<DsAlertStatusFilter>("open");
/**
* 現在ページ(1始まり。指示書AA 段階4)
*
* ★種別・対応状況を変えたら必ず 1 に戻すこと★ — 件数が減ったときに
* 存在しないページへ取り残されると、空表示になる。
*/
const [page, setPage] = useState(1);
const [items, setItems] = useState<DsAlert[]>([]);
const [summary, setSummary] = useState<DsAlertSummary>(EMPTY_SUMMARY);
/**
* 一覧(種別・対応状況フィルタ後)の総件数。★`meta.total`★ を入れる。
*
* ★`summary` を流用しないこと★(D-054 / 指示書AA 禁止事項4)—
* `summary`(未対応/対応中/対応済み)はフィルタの**前**に計算されており、
* 一覧の件数とは別物。総ページ数がずれて存在しないページのボタンが出る。
*/
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const queryString = useMemo(() => {
const params = new URLSearchParams();
params.set("type", typeFilter);
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` を依存に入れること★ — 入れ忘れるとボタンを押しても取り直さない
}, [typeFilter, statusFilter, classification.draft, page]);
const fetchAlerts = useCallback(async () => {
setLoading(true);
setError(null);
try {
const body = await apiFetch<SummaryListResponse<DsAlert, DsAlertSummary>>(
`/api/ds-alerts?${queryString}`
);
setItems(body.items);
setSummary(body.summary);
// ★総ページ数は `meta.total` から出す(`summary` ではない)★
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);
} catch (e) {
setError(
(e instanceof ApiFetchError ? e.serverMessage : null) ??
"データの取得に失敗しました"
);
setItems([]);
setSummary(EMPTY_SUMMARY);
setTotal(0);
} finally {
setLoading(false);
}
}, [queryString]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- フィルタ変更に合わせた一覧の取り直し
void fetchAlerts();
}, [fetchAlerts]);
return (
<div className={styles.page}>
<div className={styles.header}>
<h1 className={styles.title}>{t("D-Security アラート")}</h1>
<div className={styles.subtitle}>
{t("セキュリティ無効・通信断のアラート管理")}
</div>
</div>
<div className={styles.statGrid}>
<StatCard value={String(summary.pending)} label={t("未対応")} sub={t("即時対応")} tone="ng" />
<StatCard value={String(summary.in_progress)} label={t("対応中")} sub={t("進行中")} tone="wn" />
<StatCard value={String(summary.done)} label={t("対応済み")} sub={t("完了")} tone="ok" />
</div>
<div className={styles.toolbar}>
{/* ★分類の絞り込み(モック:922-927。指示書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>
</>
)}
<select
className={styles.select}
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value as DsAlertTypeFilter);
setPage(1);
}}
>
<option value="all">{t("全種別")}</option>
<option value="security_off">{t("セキュリティ無効")}</option>
<option value="disconnected">{t("通信断")}</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 === "done"}
color={c.ok}
onClick={() => {
setStatusFilter("done");
setPage(1);
}}
>
{t("対応済み")}
</FilterButton>
</div>
</div>
{loading && <div className={styles.loading}>読み込み中…</div>}
{!loading && error && <div className={styles.error}>{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) => (
<div
key={item.id}
className={styles.itemCard}
style={{ borderLeftColor: typeColor(c, item.alert_type) }}
>
<div className={styles.itemRow}>
<div className={styles.itemMain}>
<div className={styles.itemBadges}>
<Pill text={t(TYPE_LABEL[item.alert_type])} color={typeColor(c, item.alert_type)} />
<span className={styles.serial}>{item.device_serial}</span>
</div>
<div className={styles.facility}>
{item.facility_name}
<span className={styles.section}>({item.section_name})</span>
</div>
<div className={styles.detectedLine}>
{t("検知")}: {formatDateTime(item.detected_at)}
</div>
{item.memo && <div className={styles.memoLine}>{item.memo}</div>}
</div>
<div>
{item.resolution_status === "done" && (
<span className={`${styles.statusBadge} ${styles.statusDone}`}>
{t("対応済み")}
</span>
)}
{item.resolution_status === "in_progress" && (
<span className={`${styles.statusBadge} ${styles.statusInProgress}`}>
{t("対応中")}
</span>
)}
{item.resolution_status === "pending" && (
<span className={`${styles.statusBadge} ${styles.statusPending}`}>
{t("未対応")}
</span>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* ★件数表示に `items.length` を使わないこと★。0件なら部品側が null を返すので
「該当するアラートはありません」と二重にならない */}
<Pagination
total={total}
page={page}
perPage={PAGINATION.defaultPerPage}
onPageChange={setPage}
/>
</div>
);
}