Newer
Older
client_dwatch / components / features / ds-alerts / DsAlertsScreen.tsx
"use client";

import { 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, PRECHECK_NOTE_MAX_LENGTH } from "@/lib/config";
import { adjustDsAlertSummary, dsAlertWithResolution } from "@/lib/ds-alerts/edit";
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 {
  draftNote,
  draftStatus,
  saveDrafts,
  setDraftNote,
  setDraftStatus,
  showsResolvedTime,
  type ResolutionDrafts,
} from "@/lib/prechecks/edit-drafts";
import { THEME_TOKENS, type ThemeName } from "@/lib/theme";
import type {
  DsAlert,
  DsAlertStatusFilter,
  DsAlertType,
  DsAlertTypeFilter,
  ErrorReportItem,
  PrecheckStatus,
  PrecheckStatusUpdateRequest,
  SummaryListResponse,
} from "@/lib/types";
import styles from "./DsAlertsScreen.module.css";

/**
 * D-Security アラート(`/ds-alerts`)。
 *
 * ★編集モードはエラー一覧(`ErrorsScreen.tsx`)と同じ操作★(指示書AX U4・段階5)。
 * ★書くのは同じ `robot_error_reports` の4カラムで、経路もエラー一覧の `PATCH /errors/{error_id}/status`★(D6)。
 *
 * ★シリアル・施設名・清掃エリア名・備考は `t()` に通さない★(生値のまま出す。D-077)—
 *   辞書に同じ綴りのキーがあると誤って訳される。
 */

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 = { unresolved: 0, in_progress: 0, resolved: 0 };

function statusBadgeClass(status: PrecheckStatus): string {
  switch (status) {
    case "resolved":
      return styles.statusResolved;
    case "in_progress":
      return styles.statusInProgress;
    case "unresolved":
      return styles.statusUnresolved;
  }
}

/**
 * 対応状況のプルダウンの選択肢(エラー一覧の `STATUS_OPTIONS` と同じ並び・文言。指示書AX 表4)。
 * ラベルは `t()` に通す辞書のキー。★onChange の文字列を `PrecheckStatus` に戻すときもこの表から引く★
 */
const STATUS_OPTIONS: readonly { value: PrecheckStatus; label: string }[] = [
  { value: "unresolved", label: "未解消" },
  { value: "in_progress", label: "対応中" },
  { value: "resolved", label: "解消" },
];

/** 対応状況の表示名(辞書のキー)。通常モードのバッジに使う */
function statusLabel(status: PrecheckStatus): string {
  return STATUS_OPTIONS.find((o) => o.value === status)?.label ?? status;
}

/** ★書き込みはエラー一覧と同じルート(指示書AX D6)★。応答は ErrorReportItem なので DsAlert に戻す */
async function sendDsAlertUpdate(row: DsAlert, request: PrecheckStatusUpdateRequest): Promise<DsAlert> {
  const updated = await apiFetch<ErrorReportItem>(`/api/errors/${encodeURIComponent(row.id)}/status`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(request),
  });
  return dsAlertWithResolution(row, updated);
}

/** 行の失敗の文言。★サーバーの文言があればそれ★、無ければ既定の文言(どちらも表示時に `t()` を通す) */
function rowErrorMessage(e: unknown): string {
  return (e instanceof ApiFetchError ? e.serverMessage : null) ?? "保存に失敗しました";
}

/**
 * 一覧の取得結果。★どのクエリの結果かを `query` で持つ★ —
 *   読み込み中は「手元の結果のクエリ ≠ 今のクエリ」で判定する(`loading` を effect の中で同期的に立てない)。
 */
interface FetchResult {
  query: string;
  items: DsAlert[];
  summary: DsAlertSummary;
  /**
   * 一覧(種別・対応状況フィルタ後)の総件数。★`meta.total`★ を入れる。
   *
   * ★`summary` を流用しないこと★(D-054 / 指示書AA 禁止事項4)—
   *   `summary`(未解消/対応中/解消)はフィルタの**前**に計算されており、
   *   一覧の件数とは別物。総ページ数がずれて存在しないページのボタンが出る。
   */
  total: number;
  error: string | null;
}

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 [result, setResult] = useState<FetchResult | null>(null);

  /**
   * ★対応状況・備考の編集モード(エラー一覧と同じ。D-165 / 指示書AX U4)★ — 画面全体で切り替え、保存ボタンでまとめて送る。
   * ・`drafts`: 変えた行の下書き(id → 変えた項目)。★行の値に戻したらエントリごと消える★(`setDraft*()`)
   * ・`rowErrors`: 保存に失敗した行(id → 文言)。★失敗した行の下書きは残す★(打ち直しにならないように)
   * ・`saving`: 保存中は「保存」「キャンセル」とすべての入力を無効にする(重ねて送らない)
   */
  const [editing, setEditing] = useState(false);
  const [drafts, setDrafts] = useState<ResolutionDrafts>(() => new Map());
  const [rowErrors, setRowErrors] = useState<ReadonlyMap<string, string>>(() => new Map());
  const [saving, setSaving] = useState(false);
  const draftCount = drafts.size;

  // ★下書きがあるあいだはページを離れる前に確認する★(D-165)
  useEffect(() => {
    if (draftCount === 0) return;
    const onBeforeUnload = (e: BeforeUnloadEvent) => {
      e.preventDefault();
    };
    window.addEventListener("beforeunload", onBeforeUnload);
    return () => window.removeEventListener("beforeunload", onBeforeUnload);
  }, [draftCount]);

  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]);

  /**
   * 一覧の取得。★state は応答が届いてから(Promise の中で)だけ変える★ —
   *   読み込み中は `result.query !== queryString` で判定するので、effect の中で同期的に
   *   `setLoading(true)` しない(`ErrorsScreen.tsx` と同じ。指示書AX 4-18)。
   * 取得中にフィルタを変えると古い応答が後から届いて新しい表示を上書きしうるので破棄する。
   */
  useEffect(() => {
    let cancelled = false;
    apiFetch<SummaryListResponse<DsAlert, DsAlertSummary>>(`/api/ds-alerts?${queryString}`).then(
      (body) => {
        if (cancelled) return;
        setResult({
          query: queryString,
          items: body.items,
          summary: body.summary,
          // ★総ページ数は `meta.total` から出す(`summary` ではない)★
          total: body.meta.total,
          error: null,
        });
        // ★存在しないページに取り残された場合の保険★(`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;
        setResult({
          query: queryString,
          items: [],
          summary: EMPTY_SUMMARY,
          total: 0,
          error:
            (e instanceof ApiFetchError ? e.serverMessage : null) ??
            "データの取得に失敗しました",
        });
      }
    );
    return () => {
      cancelled = true;
    };
  }, [queryString]);

  const loading = result?.query !== queryString;
  const error = loading ? null : (result?.error ?? null);
  const items = loading ? [] : (result?.items ?? []);
  /** 読み込み中は直前の値のまま(数値カード・ページ送りが消えたり出たりしない) */
  const summary = result?.summary ?? EMPTY_SUMMARY;
  const total = result?.total ?? 0;

  /**
   * ★編集モードに入れるのは、表示中の一覧が今の絞り込みの結果と一致しているときだけ★ —
   *   読み込み中に入ると、編集中に一覧が取り直されて下書きの行が消える(指示書AX 4-17)。
   */
  const canStartEdit = !loading && error === null && items.length > 0;

  /** ★編集中・保存中は絞り込みとページ送りを無効にする★(下書きが消えないように。D-165) */
  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行ずつ順に送る★(`saveDrafts()`)。
   * ・★成功した行は応答で差し替え、`summary` は手元で付け替える★(一覧を取り直さない。指示書AX D15・4-9)。
   *   ★`status=open` の表示でも、解消にした行は次に取り直すまで消さない★(押し間違いをその場で戻せるように)
   * ・★失敗した行は下書きを残し、カードの直下に失敗の文言を出す★(サーバーの文言があればそれ)
   * ・★全部成功なら通常モードに戻る。1件でも失敗したら編集モードのまま★
   * ・変更が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 saveDrafts(items, drafts, sendDsAlertUpdate);
      if (updated.size > 0) {
        setResult((prev) =>
          prev === null
            ? prev
            : {
                ...prev,
                items: prev.items.map((p) => updated.get(p.id) ?? p),
                summary: adjustDsAlertSummary(prev.summary, prev.items, updated),
              }
        );
      }
      setDrafts(remaining);
      setRowErrors(
        new Map(Array.from(failed, ([id, e]): [string, string] => [id, rowErrorMessage(e)]))
      );
      if (failed.size === 0) setEditing(false);
    } finally {
      setSaving(false);
    }
  };

  const changeDraftStatus = (item: DsAlert, value: string) => {
    const next = STATUS_OPTIONS.find((o) => o.value === value)?.value;
    if (next === undefined) return;
    setDrafts((prev) => setDraftStatus(prev, item, next));
  };

  const changeDraftNote = (item: DsAlert, note: string) => {
    setDrafts((prev) => setDraftNote(prev, item, note));
  };

  return (
    <div className={styles.page}>
      <div className={styles.header}>
        <div>
          <h1 className={styles.title}>{t("D-Security アラート")}</h1>
          {/* ★副題は変えない★(指示書AX U6) */}
          <div className={styles.subtitle}>
            {t("セキュリティ無効・通信断のアラート管理")}
          </div>
        </div>
        <div className={styles.headerRight}>
          {/* ★編集モードの切り替え(エラー一覧と同じ)★。★ボタンは固定幅★(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.unresolved)} label={t("未解消")} sub={t("即時対応")} tone="ng" />
        <StatCard value={String(summary.in_progress)} label={t("対応中")} sub={t("進行中")} tone="wn" />
        <StatCard value={String(summary.resolved)} label={t("解消済み")} sub={t("完了")} tone="ok" />
      </div>

      {/* ★編集中は絞り込みをまとめて無効にする★(`<fieldset disabled>` で中のセレクト・ボタンが全部無効になる。
          種別のセレクトも含む。下書きが消えないように。指示書AX 4-17) */}
      <fieldset className={styles.toolbar} disabled={filtersDisabled}>
        {/* ★分類の絞り込み(モック: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>
          </>
        )}
        {/* ★種別のセレクトは残す★(指示書AX U6。「セキュリティ無効」を選ぶと0件になるのは承知のうえ) */}
        <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>
        {/* ★状態ボタンの文言はアラート画面に揃える(未解消のみ / 全件 / 解消済み。ユーザー決定 2026-10-19。D-181)★ —
            ★「未解消のみ」は対応中も含む★(status=open。アラートも同じ。ユーザー了承)。バッジ・プルダウンは「未解消 / 対応中 / 解消」のまま */}
        <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>

      {/* 編集モードの案内と、保存に失敗した件数 */}
      {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 && 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 changed = draft !== undefined;
            const rowError = rowErrors.get(item.id);
            return (
              // ★key は `error_id` の文字列のまま★(`Number()` を通さない。D-105)
              <div
                key={item.id}
                className={changed ? `${styles.itemCard} ${styles.itemCardChanged}` : 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)} />
                      {/* ★シリアル・施設名・清掃エリア名・備考は生値。`t()` に通さない★(D-077) */}
                      <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>
                    {/* 備考。★編集モードの入力欄もここ(左の本文の、備考の行の位置)に出す★ — 対応状況の列には入れない
                        (アラート・エラー一覧と同じく、切り替えで右の列の幅が変わらないように。指示書BA 項4・D-181)。
                        通常モードは ★空なら出さない★。
                        ★入力欄は固定の大きさ★(2行ぶん・`resize: none`・幅は本文いっぱい)。
                        上限超過(422)を画面側で先に防ぐ。上限値の正は lib/config(YAML の maxLength と同じ) */}
                    {editing ? (
                      <textarea
                        className={styles.noteInput}
                        aria-label={t("備考")}
                        value={draftNote(item, draft)}
                        rows={2}
                        maxLength={PRECHECK_NOTE_MAX_LENGTH}
                        placeholder={t("備考を入力")}
                        disabled={saving}
                        onChange={(e) => changeDraftNote(item, e.target.value)}
                      />
                    ) : (
                      item.note && <div className={styles.noteLine}>{item.note}</div>
                    )}
                  </div>
                  <div className={styles.statusColumn}>
                    {editing ? (
                      /* ★編集モードはプルダウン。値は下書き優先★(`draftStatus()`)。
                         ★選んでもすぐには送らない★(保存ボタンでまとめて送る) */
                      <select
                        className={`${styles.statusSelect} ${statusBadgeClass(draftStatus(item, draft))}`}
                        aria-label={t("対応状況")}
                        value={draftStatus(item, draft)}
                        disabled={saving}
                        onChange={(e) => changeDraftStatus(item, e.target.value)}
                      >
                        {STATUS_OPTIONS.map((o) => (
                          <option key={o.value} value={o.value}>
                            {t(o.label)}
                          </option>
                        ))}
                      </select>
                    ) : (
                      /* ★通常モードは表示だけ★(色付きのバッジ。押しても何も起きない) */
                      <span
                        className={`${styles.statusBadge} ${statusBadgeClass(item.resolution_status)}`}
                      >
                        {t(statusLabel(item.resolution_status))}
                      </span>
                    )}
                    {/* 解消のときだけ解消日時を下に出す(`showsResolvedTime()`。3画面で同じ)。★下書きで「解消」にしただけの行と、
                        下書きで未解消や対応中に変えた行には出さない★(指示書BA 項1。D-181) */}
                    {showsResolvedTime(item, draft) && (
                      <div className={styles.statusTime}>
                        {item.resolved_at ? formatDateTime(item.resolved_at) : "-"}
                      </div>
                    )}
                  </div>
                </div>
                {/* ★保存に失敗したカードは直下に文言を出す★(下書きは残っている)。
                    サーバーの文言は辞書に無ければ原文のまま */}
                {rowError !== undefined && <div className={styles.rowError}>{t(rowError)}</div>}
              </div>
            );
          })}
        </div>
      )}

      {/* ★件数表示に `items.length` を使わないこと★。0件なら部品側が null を返すので
          「該当するアラートはありません」と二重にならない。
          ★編集中はページ送りを無効にする★(部品に disabled が無いので `<fieldset disabled>` で包む) */}
      <fieldset className={styles.pagerFieldset} disabled={filtersDisabled}>
        <Pagination
          total={total}
          page={page}
          perPage={PAGINATION.defaultPerPage}
          onPageChange={setPage}
        />
      </fieldset>
    </div>
  );
}