Newer
Older
client_dwatch / components / features / ds-alerts / DsAlertsScreen.tsx
"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>
  );
}