Newer
Older
client_dwatch / components / features / errors / ErrorsScreen.tsx
"use client";

import { Fragment, useEffect, useMemo, useState } from "react";
import { openDatePicker } from "@/components/ui/date-picker";
import { Pagination } from "@/components/ui/Pagination";
import { useTheme } from "@/components/theme/ThemeProvider";
import { apiFetch, ApiFetchError } from "@/lib/api/client";
import {
  appendDateRangeParams,
  dateRangeError,
} from "@/lib/clean-logs/detail-log-date-range";
import { PAGINATION, PRECHECK_NOTE_MAX_LENGTH } from "@/lib/config";
import { errorScheduleLabel } from "@/lib/error-reports/label";
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,
  type ResolutionDrafts,
} from "@/lib/prechecks/edit-drafts";
import type {
  ErrorReportItem,
  ListResponse,
  PrecheckStatus,
  PrecheckStatusUpdateRequest,
} from "@/lib/types";
import styles from "./ErrorsScreen.module.css";

/**
 * エラー一覧(`/errors`。D-160 / 指示書AW 段階5)。
 *
 * `robot_error_reports` の全行(事前チェック・本清掃・予定なし・`robot_id` が NULL を問わない)のうち、
 * 既定は未解消・対応中を出す。「解消済みも表示」で解消も出す。
 * ★`/alerts`(事前チェック+出発失敗だけ)とは別の画面★。見た目の作法は `AlertsScreen.tsx`(状態の色分け・備考)と
 * `DetailLogScreen.tsx` / `CleanLogTable`(ツールバー・固定幅の表)から借りる。★新しい共通部品は作らない★。
 *
 * ★`error_type` / `error_level` / `serial_number` / 施設名は `t()` に通さない★(生値のまま出す。D-077 / D-160 項3)—
 *   辞書に同じ綴りのキーがあると誤って訳される(`AlertsScreen.tsx` の「屋号名まで通すと辞書の対訳に誤って当たる」と同じ)。
 */

/**
 * ★期間の日数の上限は設けない★(`/prechecks` と同じ。指示書AW §11 Q13)。
 *   `dateRangeError()` は上限を引数で受けるので、無限大を渡して「`date_from > date_to` だけ」を検証する
 *   (文言はサーバ `parseErrorReportQuery()` と同じ「date_fromはdate_to以前の日付を指定してください」)。
 */
const NO_MAX_DATE_RANGE_DAYS = Number.POSITIVE_INFINITY;

/** 施設名・種類の文字入力を確定するまでの待ち(`DetailLogScreen.tsx` と同じ 300ms) */
const TEXT_DEBOUNCE_MS = 300;

/** 表の列数(保存に失敗した行の `colSpan`)。★`<colgroup>` / `<th>` / `<td>` と揃えること★ */
const COLUMN_COUNT = 8;

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()` に通す辞書のキー。
 * ★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;
}

/**
 * 1行を送る(`PATCH /errors/{error_id}/status`)。★失敗は例外のまま投げる★(`saveDrafts()` が行ごとに受ける)。
 * ★`error_id` は文字列のまま★(`Number()` を通さない。D-105 項2)。パスに入れるのでエンコードする
 */
function sendErrorUpdate(
  item: ErrorReportItem,
  request: PrecheckStatusUpdateRequest
): Promise<ErrorReportItem> {
  return apiFetch<ErrorReportItem>(`/api/errors/${encodeURIComponent(item.id)}/status`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(request),
  });
}

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

/**
 * 一覧の取得結果。★どのクエリの結果かを `query` で持つ★ —
 *   読み込み中は「手元の結果のクエリ ≠ 今のクエリ」で判定する(`loading` を effect の中で同期的に立てない)。
 */
interface FetchResult {
  query: string;
  items: ErrorReportItem[];
  total: number;
  error: string | null;
}

export function ErrorsScreen() {
  const { t } = useTheme();

  /**
   * 分類(ホールディングス → 顧客)の絞り込み。★この画面に「検索」ボタンは無い★(1操作=1確定)—
   *   `AlertsScreen.tsx` / `DetailLogScreen.tsx` と同じく、フックの `draft` をそのまま確定値として使う。
   */
  const classification = useClassificationFilter(t);

  /**
   * 期間(`YYYY-MM-DD`。空文字=指定なし)。★初期値は空(全期間)★。
   * ★空の側は URL に付けない★(`appendDateRangeParams()`。清掃詳細ログ(指示書AV 段階5b)と同じ)。
   */
  const [dateFrom, setDateFrom] = useState("");
  const [dateTo, setDateTo] = useState("");
  const [facilityName, setFacilityName] = useState("");
  const [errorType, setErrorType] = useState("");
  const [debouncedFacility, setDebouncedFacility] = useState("");
  const [debouncedErrorType, setDebouncedErrorType] = useState("");
  const [includeResolved, setIncludeResolved] = useState(false);

  /**
   * 現在ページ(1始まり)。★フィルタを変えたらその場で 1 に戻す★(`AlertsScreen.tsx` の注記と同じ)—
   *   `useEffect` で後追いすると古いページのままフェッチが1回余計に飛ぶ。debounce のものはタイマーの中で戻す。
   */
  const [page, setPage] = useState(1);

  const [result, setResult] = useState<FetchResult | null>(null);

  /**
   * ★対応状況・備考の編集モード(ユーザー指示 2026-10-06「対応状況と備考欄は編集モードを導入してそれでないと
   *   変更できないようにしてください」。D-165)★ — 画面全体で切り替え、保存ボタンでまとめて送る。
   * ・`drafts`: 変えた行の下書き(id → 変えた項目)。★行の値に戻したらエントリごと消える★(`setDraft*()`)
   * ・`rowErrors`: 保存に失敗した行(id → 文言)。★失敗した行の下書きは残す★(打ち直しにならないように)
   * ・`saving`: 保存中は「保存」「キャンセル」とすべての入力を無効にする(重ねて送らない)
   * ★D-161 の「プルダウンを選んだら即送信」・備考の「フォーカスが外れたら送信」は廃止★
   */
  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]);

  // ★debounce の反映と同時にページを1へ戻す★(React が1回の再描画にまとめる。`DetailLogScreen.tsx` と同じ)
  useEffect(() => {
    const id = window.setTimeout(() => {
      setDebouncedFacility(facilityName);
      setPage(1);
    }, TEXT_DEBOUNCE_MS);
    return () => window.clearTimeout(id);
  }, [facilityName]);
  useEffect(() => {
    const id = window.setTimeout(() => {
      setDebouncedErrorType(errorType);
      setPage(1);
    }, TEXT_DEBOUNCE_MS);
    return () => window.clearTimeout(id);
  }, [errorType]);

  const queryString = useMemo(() => {
    const params = new URLSearchParams();
    // ★空の側は付けない★(期間なし=全期間)
    appendDateRangeParams(params, dateFrom, dateTo);
    if (debouncedFacility) params.set("facility_name", debouncedFacility);
    if (debouncedErrorType) params.set("error_type", debouncedErrorType);
    // ★既定(false)は付けない★(API は「無い=false」)
    if (includeResolved) params.set("include_resolved", "true");
    // ★分類は手書きしない★(`group_id=0` を漏らさない・空のキーを並べない判断が入っている)
    appendClassificationParams(params, classification.draft);
    params.set("per_page", String(PAGINATION.defaultPerPage));
    params.set("page", String(page));
    return params.toString();
    // ★`page` を依存に入れること★ — 入れ忘れるとボタンを押しても取り直さない
  }, [
    dateFrom,
    dateTo,
    debouncedFacility,
    debouncedErrorType,
    includeResolved,
    classification.draft,
    page,
  ]);

  // ★期間の検証は両方入っているときだけ★。上限は無し(`NO_MAX_DATE_RANGE_DAYS`)。文言はサーバと同じ
  const rangeError = dateRangeError(dateFrom, dateTo, NO_MAX_DATE_RANGE_DAYS);
  const dateRangeUnset = !dateFrom && !dateTo;

  /**
   * 一覧の取得。★state は応答が届いてから(Promise の中で)だけ変える★ —
   *   読み込み中は `result.query !== queryString` で判定するので、effect の中で同期的に
   *   `setLoading(true)` しない(`react-hooks/set-state-in-effect` の抑止コメントを足さずに済ませるため)。
   * 取得中にフィルタを変えると古い応答が後から届いて新しい表示を上書きしうるので破棄する。
   */
  useEffect(() => {
    if (rangeError !== null) return;
    let cancelled = false;
    apiFetch<ListResponse<ErrorReportItem>>(`/api/errors?${queryString}`).then(
      (body) => {
        if (cancelled) return;
        setResult({
          query: queryString,
          items: body.items,
          total: body.meta.total,
          error: null,
        });
        // ★フィルタ外の要因(データ更新など)で総ページ数が減り、存在しないページに
        //   取り残された場合の保険。最終ページに寄せて取り直す★
        //   `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;
        setResult({
          query: queryString,
          items: [],
          total: 0,
          error:
            (e instanceof ApiFetchError ? e.serverMessage : null) ??
            "データの取得に失敗しました",
        });
      }
    );
    return () => {
      cancelled = true;
    };
  }, [queryString, rangeError]);

  const loading = rangeError === null && result?.query !== queryString;
  const error = rangeError ?? (loading ? null : (result?.error ?? null));
  const items = rangeError === null && !loading ? (result?.items ?? []) : [];
  /**
   * 総件数。★`meta.total`★(`items.length` を使わない — 読み込み中や失敗で items が空になり件数だけ0に見える)。
   * 読み込み中は直前の件数のまま(ページ送りが消えたり出たりしない)。
   */
  const total = rangeError !== null ? 0 : (result?.total ?? 0);

  /**
   * ★編集モードに入れるのは、表示中の一覧が今の絞り込みの結果と一致しているときだけ★ —
   *   読み込み中や、施設名・種類の入力が確定する前(debounce の待ち)に入ると、
   *   編集中に一覧が取り直されて下書きの行が消える。
   */
  const textPending = facilityName !== debouncedFacility || errorType !== debouncedErrorType;
  const canStartEdit = !loading && !textPending && 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()`。送る本文は `patchRequestFromDraft()` が作る)。
   * ・★成功した行はレスポンスの `ErrorReportItem` で差し替える★(一覧を取り直さない)。
   *   ★「解消済みも表示」がオフでも、解消にした行は次に取り直すまで消さない★(押し間違いをその場で戻せるように)
   * ・★失敗した行は下書きを残し、行の直後に失敗の文言を出す★(サーバーの文言があればそれ)
   * ・★全部成功なら通常モードに戻る。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, sendErrorUpdate);
      if (updated.size > 0) {
        setResult((prev) =>
          prev === null
            ? prev
            : { ...prev, items: prev.items.map((p) => updated.get(p.id) ?? p) }
        );
      }
      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: ErrorReportItem, 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: ErrorReportItem, 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>
          <div className={styles.subtitle}>
            {t("未対応のエラー(ロボット・制御盤・セキュリティ・清掃開始失敗)")}
          </div>
        </div>
        <div className={styles.headerRight}>
          {/* ★件数は `全{n}件`★(`{n}件` は EN が "{n} facilities" なので使わない。指示書AW 4-18) */}
          <div className={styles.count}>{t("全{n}件").replace("{n}", String(total))}</div>
          {/* ★編集モードの切り替え(D-165)★ — 件数の右に置く。★ボタンは固定幅★(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.card}>
        {/* ★編集中は絞り込みをまとめて無効にする★(`<fieldset disabled>` で中の入力・セレクトが全部無効になる。
            下書きが消えないように。D-165) */}
        <fieldset className={styles.toolbar} disabled={filtersDisabled}>
          <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>
          {/* ★分類の絞り込み(`AlertsScreen.tsx` と同じ)★
              ・★取得に失敗した/顧客が0件のときは出さない★(`available` が false。空の `<select>` を置かない)
              ・★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ページ目に戻す★
                    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>
            </>
          )}
          {/* ★入力欄は固定幅★(placeholder の JP/EN で幅が変わらないように。指示書AW 4-17) */}
          <input
            className={`${styles.searchInput} ${styles.searchFacility}`}
            aria-label={t("施設名")}
            placeholder={t("施設名")}
            value={facilityName}
            onChange={(e) => setFacilityName(e.target.value)}
          />
          <input
            className={`${styles.searchInput} ${styles.searchType}`}
            aria-label={t("種類")}
            placeholder={t("種類")}
            value={errorType}
            onChange={(e) => setErrorType(e.target.value)}
          />
          <label className={styles.checkLabel}>
            <input
              type="checkbox"
              checked={includeResolved}
              onChange={(e) => {
                setIncludeResolved(e.target.checked);
                setPage(1);
              }}
            />
            {t("解消済みも表示")}
          </label>
        </fieldset>

        {/* 編集モードの案内と、保存に失敗した件数(D-165) */}
        {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.tableWrap}>
            <table className={styles.table}>
              {/* ★列幅は <col> で固定する(`table-layout: fixed`)★ — JP/EN の切替で列が動かないように
                  (`CleanLogTable.tsx` と同じ作法)。★施設名の列だけ幅を指定しない★(残りの幅を受け持つ)。
                  ★<col> の並びは下の <th> / <td> と1対1★ — 列を足し引きするときは3箇所と `COLUMN_COUNT` を揃えること */}
              <colgroup>
                <col className={styles.colDetected} />
                <col />
                <col className={styles.colSerial} />
                <col />
                <col className={styles.colLevel} />
                <col className={styles.colSchedule} />
                <col className={styles.colStatus} />
                <col className={styles.colNote} />
              </colgroup>
              <thead>
                <tr>
                  <th className={styles.th}>{t("検知時刻")}</th>
                  <th className={styles.th}>{t("施設名")}</th>
                  <th className={styles.th}>{t("シリアル")}</th>
                  <th className={styles.th}>{t("種類")}</th>
                  <th className={styles.th}>{t("レベル")}</th>
                  <th className={styles.th}>{t("紐づく予定")}</th>
                  <th className={styles.th}>{t("対応状況")}</th>
                  <th className={styles.th}>{t("備考")}</th>
                </tr>
              </thead>
              <tbody>
                {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 項2)
                    <Fragment key={item.id}>
                      <tr className={changed ? `${styles.row} ${styles.rowChanged}` : styles.row}>
                        <td className={`${styles.td} ${styles.tdNowrap}`}>
                          {formatDateTime(item.detected_at)}
                        </td>
                        {/* ★施設名・シリアル・種類・レベルは生値。`t()` に通さない★(D-077) */}
                        <td className={styles.td}>{item.facility_name}</td>
                        <td className={`${styles.td} ${styles.tdMuted}`}>{item.serial_number}</td>
                        <td className={styles.td}>{item.error_type}</td>
                        <td className={`${styles.td} ${styles.tdMuted}`}>{item.error_level}</td>
                        <td className={styles.td}>{errorScheduleLabel(t, item)}</td>
                        <td className={styles.td}>
                          {editing ? (
                            /* ★編集モードはプルダウン(D-161 の見た目)。値は下書き優先★(`draftStatus()`)。
                               ★選んでもすぐには送らない★(保存ボタンでまとめて送る。D-165) */
                            <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>
                          ) : (
                            /* ★通常モードは表示だけ★(色付きのバッジ。押しても何も起きない。D-165) */
                            <span
                              className={`${styles.statusBadge} ${statusBadgeClass(item.resolution_status)}`}
                            >
                              {t(statusLabel(item.resolution_status))}
                            </span>
                          )}
                          {/* 解消のときだけ解消日時を下に出す。★保存済みの値(`item`)で判定する★ —
                              下書きで「解消」を選んだだけではまだ解消日時が無い */}
                          {item.resolution_status === "resolved" && (
                            <div className={styles.statusTime}>
                              {item.resolved_at ? formatDateTime(item.resolved_at) : "-"}
                            </div>
                          )}
                        </td>
                        <td className={styles.td}>
                          {editing ? (
                            /* ★入力欄は固定の大きさ★(2行ぶん・`resize: none`・幅は列いっぱい。D-165)。
                               上限超過(422)を画面側で先に防ぐ。上限値の正は lib/config(YAML の maxLength と同じ) */
                            <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)}
                            />
                          ) : (
                            /* ★通常モードは文字だけ★(1行・はみ出しは「…」・全文は `title`。枠は列幅いっぱいで固定。
                               D-162 / D-165)。★空のときは何も出さない★(枠だけ残して行の高さを揃える) */
                            <div className={styles.noteText} title={item.note ?? undefined}>
                              {item.note ?? ""}
                            </div>
                          )}
                        </td>
                      </tr>
                      {/* ★保存に失敗した行は直後に文言を出す★(下書きは残っている)。
                          サーバーの文言は辞書に無ければ原文のまま(`AlertsScreen.tsx` と同じ) */}
                      {rowError !== undefined && (
                        <tr>
                          <td colSpan={COLUMN_COUNT} className={styles.rowError}>
                            {t(rowError)}
                          </td>
                        </tr>
                      )}
                    </Fragment>
                  );
                })}
              </tbody>
            </table>
          </div>
        )}

        {/* ★件数に `items.length` を使わないこと★(`meta.total`)。0件のときは部品側が null を返す。
            ★編集中はページ送りを無効にする★(部品に disabled が無いので `<fieldset disabled>` で包む。D-165) */}
        <fieldset className={styles.pagerFieldset} disabled={filtersDisabled}>
          <Pagination
            total={total}
            page={page}
            perPage={PAGINATION.defaultPerPage}
            onPageChange={setPage}
          />
        </fieldset>
      </div>
    </div>
  );
}