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

import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { openDatePicker } from "@/components/ui/date-picker";
import { Pagination } from "@/components/ui/Pagination";
import { ScheduleFilterSelect } from "@/components/ui/ScheduleFilterSelect";
import { SortableTh } from "@/components/ui/SortableTh";
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 {
  requiresNote,
  saveErrorDraftsWithCheck,
  setErrorDraftErrorListId,
  setErrorDraftNote,
  setErrorDraftStatus,
  type ErrorResolutionDrafts,
} from "@/lib/errors/edit-drafts";
import { errorContentView, type ErrorContentView } from "@/lib/errors/error-content-label";
import { renderSaveErrorMessage, saveErrorMessage } from "@/lib/errors/save-error-message";
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, showsResolvedTime } from "@/lib/prechecks/edit-drafts";
import { displayOrder, nextSortState, type SortState } from "@/lib/table-sort";
import type {
  ErrorListItem,
  ErrorListsResponse,
  ErrorReportItem,
  ErrorReportSortKey,
  ScheduleFilter,
  ErrorStatusUpdateRequest,
  ListResponse,
  PrecheckStatus,
} from "@/lib/types";
import { ErrorContentPicker } from "./ErrorContentPicker";
import styles from "./ErrorsScreen.module.css";

/**
 * エラー一覧(`/errors`。D-160 / 指示書AW 段階5)。
 *
 * `robot_error_reports` の全行(事前チェック・本清掃・予定なし・`robot_id` が NULL を問わない)のうち、
 * 既定は未解消と対応中を出す。「解消済みも表示」で解消も出す。
 * ★`/alerts`(事前チェック+出発失敗だけ)とは別の画面★。見た目の作法は `AlertsScreen.tsx`(状態の色分け・備考)と
 * `DetailLogScreen.tsx` / `CleanLogTable`(ツールバー・固定幅の表)から借りる。
 * ★エラー内容を選ぶ窓(`ErrorContentPicker`)はこの画面専用★(共通部品にはしない。指示書BE。D-185)。
 *
 * ★`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>` と揃えること★。
 * ★9列★(2026-10-27 ユーザー指示で「詳細」(エラー内容)を備考の左の列にした。指示書BE §12)
 */
const COLUMN_COUNT = 9;

/** 「紐づく予定」の絞り込み(空=すべて) */
/**
 * 「種類」(`error_type` の生値)を、★「_」の後ろでだけ折り返せる★ようにする(`<wbr>` を挟むだけ。文字は変えない。D-077)。
 * 列が狭いと「D-」「S_CONTROL_FAILU」「RE」のように単語の途中やハイフンの後ろで割れていたため
 * (ユーザー指摘 2026-10-27)。「D-S_CONTROL_」「FAILURE」の2行になる。
 */
function withUnderscoreBreaks(value: string) {
  const parts = value.split("_");
  return parts.map((part, i) => (
    <Fragment key={i}>
      {part}
      {i < parts.length - 1 && (
        <>
          _<wbr />
        </>
      )}
    </Fragment>
  ));
}

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`)。★失敗は例外のまま投げる★(`saveErrorDraftsWithCheck()` が行ごとに受ける)。
 * ★`error_list_id` を載せられるのはこの経路だけ★(アラートと D-Sec は共通の `saveDrafts()` のまま。指示書BE §5)。
 * ★`error_id` は文字列のまま★(`Number()` を通さない。D-105 項2)。パスに入れるのでエンコードする
 */
function sendErrorUpdate(
  item: ErrorReportItem,
  request: ErrorStatusUpdateRequest
): Promise<ErrorReportItem> {
  return apiFetch<ErrorReportItem>(`/api/errors/${encodeURIComponent(item.id)}/status`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(request),
  });
}

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

/** 選択肢の取得。★編集に入ったときのクリックでだけ取りに行く★(effect の中で同期的に loading を立てない) */
type ErrorListsLoad =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "ready"; items: ErrorListItem[] }
  | { status: "error" };

/** 編集モードの「詳細」のボタンの文字。未選択は「選択」(通常モードの「未選択」とは分ける。指示書BE §4) */
function errorContentButtonText(
  t: (text: string) => string,
  view: ErrorContentView
): string {
  if (view.kind === "unselected") return t("選択");
  if (view.kind === "unknown") return t("不明({id})").replace("{id}", String(view.id));
  if (view.kind === "dash") return "-";
  return view.text;
}

export function ErrorsScreen() {
  const { t, lang } = 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);
  const [sort, setSort] = useState<SortState<ErrorReportSortKey>>(null);
  const [scheduleFilter, setScheduleFilter] = useState<ScheduleFilter | null>(null);

  /**
   * 現在ページ(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 → 変えた項目。エラー内容の id も含む)。★行の値に戻したらエントリごと消える★
   *   (`setErrorDraft*()`。共通の `setDraft*` は `error_list_id` を落とすので使わない。指示書BE §5)
   * ・`rowErrors`: 保存に失敗した行(id → 文言)。★失敗した行の下書きは残す★(打ち直しにならないように)
   * ・`saving`: 保存中は「保存」「キャンセル」とすべての入力を無効にする(重ねて送らない)
   * ★D-161 の「プルダウンを選んだら即送信」・備考の「フォーカスが外れたら送信」は廃止★
   */
  const [editing, setEditing] = useState(false);
  const [drafts, setDrafts] = useState<ErrorResolutionDrafts>(() => new Map());
  const [rowErrors, setRowErrors] = useState<ReadonlyMap<string, string>>(() => new Map());
  const [saving, setSaving] = useState(false);
  const draftCount = drafts.size;
  /**
   * エラー内容の選択肢。★編集に初めて入ったとき(失敗していたら入り直したとき)に1回取る★。
   * 世代番号で、遅れて届いた古い応答は捨てる(一覧の `cancelled` と同じ。D-183)。
   */
  const [listsLoad, setListsLoad] = useState<ErrorListsLoad>({ status: "idle" });
  const listsGeneration = useRef(0);
  /** 選択窓を開いた行。閉じたら null */
  const [pickerRowId, setPickerRowId] = useState<string | null>(null);
  const pickerOpenerRef = useRef<HTMLButtonElement | null>(null);

  // ★下書きがあるあいだはページを離れる前に確認する★(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 changeSort = (key: ErrorReportSortKey) => {
    setSort((current) =>
      nextSortState(current, key, { first: "asc", twoState: key === "detected_at" })
    );
    setPage(1);
  };

  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);
    if (sort) {
      params.set("sort", sort.key);
      params.set("order", sort.order);
    }
    if (scheduleFilter) params.set("schedule", scheduleFilter);
    params.set("per_page", String(PAGINATION.defaultPerPage));
    params.set("page", String(page));
    return params.toString();
    // ★`page` を依存に入れること★ — 入れ忘れるとボタンを押しても取り直さない
  }, [
    dateFrom,
    dateTo,
    debouncedFacility,
    debouncedErrorType,
    includeResolved,
    classification.draft,
    sort,
    scheduleFilter,
    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);
    if (listsLoad.status === "ready" || listsLoad.status === "loading") return;
    const generation = listsGeneration.current + 1;
    listsGeneration.current = generation;
    setListsLoad({ status: "loading" });
    apiFetch<ErrorListsResponse>("/api/error-lists").then(
      (body) => {
        if (listsGeneration.current !== generation) return;
        setListsLoad({ status: "ready", items: body.items });
      },
      () => {
        if (listsGeneration.current !== generation) return;
        setListsLoad({ status: "error" });
      }
    );
  };

  /** キャンセル。★下書きがあるときだけ確認する★(OK なら下書きを捨てて通常モード) */
  const cancelEdit = () => {
    if (draftCount > 0 && !window.confirm(t("変更を破棄しますか?"))) return;
    setDrafts(new Map());
    setRowErrors(new Map());
    setEditing(false);
  };

  /**
   * 保存。★変えた行だけを1行ずつ順に送る★(`saveErrorDraftsWithCheck()`。本文は `errorPatchRequestFromDraft()`)。
   * ・★その他で備考が空の行は送らない★(下書きを残し、行エラーに数える。指示書BE §6。振り分けは lib 側)
   * ・★成功した行はレスポンスの `ErrorReportItem` で差し替える★(一覧を取り直さない)。
   *   ★「解消済みも表示」がオフでも、解消にした行は次に取り直すまで消さない★(押し間違いをその場で戻せるように)
   * ・★失敗した行は下書きを残し、行の直後に失敗の文言を出す★(文言は失敗の種類から決める。`saveErrorMessage()`。指示書BF §1)
   * ・★送らなかった行も失敗した行も無ければ通常モードに戻る★
   * ・変更が0件なら何も送らずに通常モードに戻る(「編集を終了」の意味)
   */
  const saveEdit = async () => {
    if (saving) return;
    if (draftCount === 0) {
      setRowErrors(new Map());
      setEditing(false);
      return;
    }
    const catalog = listsLoad.status === "ready" ? listsLoad.items : [];
    setSaving(true);
    setRowErrors(new Map());
    try {
      const { updated, failed, blocked, remaining } = await saveErrorDraftsWithCheck(
        items,
        drafts,
        catalog,
        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, saveErrorMessage(e)]),
          ...blocked,
        ])
      );
      if (failed.size === 0 && blocked.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) => setErrorDraftStatus(prev, item, next));
  };

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

  const catalog = listsLoad.status === "ready" ? listsLoad.items : [];
  const listsUnavailable = listsLoad.status !== "ready";
  const pickerItem = pickerRowId === null ? undefined : items.find((item) => item.id === pickerRowId);

  // 分類のセレクトの値(`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)}
          />
          <ScheduleFilterSelect
            className={`${styles.select} ${styles.scheduleFilterSelect}`}
            value={scheduleFilter}
            onChange={(next) => {
              setScheduleFilter(next);
              setPage(1);
            }}
          />
          <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>
            {listsLoad.status === "error" && (
              <span className={styles.editNoticeError}>
                {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` と同じ作法)。★施設名・種類は表の幅の割合、詳細だけ幅を指定しない★(残りの幅を受け持つ。
                  幅の内訳は `ErrorsScreen.module.css` の `.table` のコメント)。
                  ★<col> の並びは下の <th> / <td> と1対1★ — 列を足し引きするときは3箇所と `COLUMN_COUNT` を揃えること */}
              <colgroup>
                <col className={styles.colDetected} />
                <col className={styles.colFacility} />
                <col className={styles.colSerial} />
                <col className={styles.colType} />
                <col className={styles.colLevel} />
                <col className={styles.colSchedule} />
                <col className={styles.colStatus} />
                <col />
                <col className={styles.colNote} />
              </colgroup>
              <thead>
                <tr>
                  <SortableTh
                    label={t("検知時刻")}
                    order={displayOrder(sort, "detected_at", "detected_at")}
                    onClick={() => changeSort("detected_at")}
                    disabled={editing}
                    ariaLabel={t("検知時刻で並べ替え")}
                    align="left"
                  />
                  <th className={styles.th}>{t("施設名")}</th>
                  <SortableTh
                    label={t("シリアル")}
                    order={displayOrder(sort, "serial_number")}
                    onClick={() => changeSort("serial_number")}
                    disabled={editing}
                    ariaLabel={t("シリアルで並べ替え")}
                    align="left"
                  />
                  <SortableTh
                    label={t("種類")}
                    order={displayOrder(sort, "error_type")}
                    onClick={() => changeSort("error_type")}
                    disabled={editing}
                    ariaLabel={t("種類で並べ替え")}
                    align="left"
                  />
                  <SortableTh
                    label={t("レベル")}
                    order={displayOrder(sort, "error_level")}
                    onClick={() => changeSort("error_level")}
                    disabled={editing}
                    ariaLabel={t("レベルで並べ替え")}
                    align="left"
                  />
                  <th className={styles.th}>{t("紐づく予定")}</th>
                  <SortableTh
                    label={t("対応状況")}
                    order={displayOrder(sort, "resolution_status")}
                    onClick={() => changeSort("resolution_status")}
                    disabled={editing}
                    ariaLabel={t("対応状況で並べ替え")}
                    align="left"
                  />
                  <th className={styles.th}>{t("詳細")}</th>
                  <SortableTh
                    label={t("備考")}
                    order={displayOrder(sort, "note")}
                    onClick={() => changeSort("note")}
                    disabled={editing}
                    ariaLabel={t("備考で並べ替え")}
                    align="left"
                  />
                </tr>
              </thead>
              <tbody>
                {items.map((item) => {
                  const draft = drafts.get(item.id);
                  // ★変えた行の目印★(下書きがある行。エラー内容だけの変更も含む。行エラーの行も下書きが残る)
                  const changed = draft !== undefined;
                  // ★保存前の検証と同じ関数★(選べない行は常に false。指示書BD §3-3 手順4)
                  const noteRequired = requiresNote(item, draft, catalog);
                  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}>{withUnderscoreBreaks(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>
                          )}
                          {/* 解消のときだけ解消日時を下に出す(`showsResolvedTime()`。3画面で同じ)。★下書きで「解消」にしただけの行と、
                              下書きで未解消や対応中に変えた行には出さない★(指示書BA 項1。D-181) */}
                          {showsResolvedTime(item, draft) && (
                            <div className={styles.statusTime}>
                              {item.resolved_at ? formatDateTime(item.resolved_at) : "-"}
                            </div>
                          )}
                        </td>
                        {/* ★詳細(エラー内容)は種類の2行目から独立した列にした★(2026-10-27 ユーザー指示。指示書BE §12)。
                            ★長い文字は折り返す(「…」で切らない)★。通常モードも編集モードのボタンも同じ */}
                        <td className={styles.td}>
                          <ErrorContentCell
                            item={item}
                            draftId={draft?.error_list_id}
                            catalog={catalog}
                            lang={lang}
                            t={t}
                            editing={editing}
                            disabled={saving || listsUnavailable}
                            onOpen={(button) => {
                              pickerOpenerRef.current = button;
                              setPickerRowId(item.id);
                            }}
                          />
                        </td>
                        <td className={styles.td}>
                          {editing ? (
                            /* ★入力欄は固定の大きさ★(2行ぶん・`resize: none`・幅は列いっぱい。D-165)。
                               上限超過(422)を画面側で先に防ぐ。上限値の正は lib/config(YAML の maxLength と同じ) */
                            <textarea
                              className={
                                noteRequired && draftNote(item, draft).trim() === ""
                                  ? `${styles.noteInput} ${styles.noteInputRequired}`
                                  : styles.noteInput
                              }
                              aria-label={t("備考")}
                              value={draftNote(item, draft)}
                              rows={2}
                              maxLength={PRECHECK_NOTE_MAX_LENGTH}
                              placeholder={
                                noteRequired ? t("備考を入力(必須)") : 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>
                      {/* ★保存に失敗した行は直後に文言を出す★(下書きは残っている)。
                          ★文言は失敗の種類から決める★(`saveErrorMessage()`。指示書BF §1)。
                          422 のその他で出すサーバーの文言は、辞書に無ければ原文のまま */}
                      {rowError !== undefined && (
                        <tr>
                          <td colSpan={COLUMN_COUNT} className={styles.rowError}>
                            {renderSaveErrorMessage(rowError, t)}
                          </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>
      {pickerItem && (
        <ErrorContentPicker
          items={catalog}
          lang={lang}
          currentId={drafts.get(pickerItem.id)?.error_list_id ?? pickerItem.error_list_id}
          restoreFocusRef={pickerOpenerRef}
          onPick={(id) => {
            setDrafts((prev) => setErrorDraftErrorListId(prev, pickerItem, id));
            setPickerRowId(null);
          }}
          onClose={() => setPickerRowId(null)}
        />
      )}
    </div>
  );
}

/**
 * 「詳細」のセルの中身(指示書BE §3 の表。§12 で種類の2行目から独立した列に移した)。
 * ★分類名・詳細の文字列は `t()` に通さない★(`errorContentView()` が `lang` で `_ja` / `_en` を出し分ける)
 */
function ErrorContentCell({
  item,
  draftId,
  catalog,
  lang,
  t,
  editing,
  disabled,
  onOpen,
}: {
  item: ErrorReportItem;
  draftId: number | undefined;
  catalog: readonly ErrorListItem[];
  lang: "ja" | "en";
  t: (text: string) => string;
  editing: boolean;
  disabled: boolean;
  onOpen: (button: HTMLButtonElement) => void;
}) {
  const view = errorContentView({
    lang,
    selectable: item.error_list_selectable,
    savedId: item.error_list_id,
    savedList: item.error_list,
    draftId,
    catalog,
  });
  if (editing && view.kind !== "dash") {
    const title = view.kind === "category" || view.kind === "detail" ? view.title : undefined;
    return (
      <button
        type="button"
        className={styles.errorContentButton}
        title={title}
        disabled={disabled}
        onClick={(e) => onOpen(e.currentTarget)}
      >
        {errorContentButtonText(t, view)}
      </button>
    );
  }
  if (view.kind === "dash") {
    return <div className={`${styles.errorContentText} ${styles.errorContentMuted}`}>-</div>;
  }
  if (view.kind === "unselected") {
    return (
      <div className={`${styles.errorContentText} ${styles.errorContentMuted}`}>{t("未選択")}</div>
    );
  }
  if (view.kind === "unknown") {
    return (
      <div className={`${styles.errorContentText} ${styles.errorContentMuted}`}>
        {t("不明({id})").replace("{id}", String(view.id))}
      </div>
    );
  }
  return (
    <div className={styles.errorContentText} title={view.title}>
      {view.text}
    </div>
  );
}