Newer
Older
client_dwatch / components / features / alerts / AlertsScreen.tsx
"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import { openDatePicker } from "@/components/ui/date-picker";
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 { 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 { precheckSeverityLabel } from "@/lib/prechecks/label";
import { applyResolutionChange, type PrecheckSummary } from "@/lib/prechecks/query";
import { THEME_TOKENS, type ThemeName } from "@/lib/theme";
import { statusColor } from "@/lib/theme-status";
import type {
  PrecheckItem,
  PrecheckSeverity,
  PrecheckStatus,
  PrecheckStatusUpdateRequest,
  SummaryListResponse,
} from "@/lib/types";
import styles from "./AlertsScreen.module.css";

type ThemeTokens = (typeof THEME_TOKENS)[ThemeName];
type StatusButtonFilter = "open" | "all" | "resolved";

/**
 * ★`launch_failed` だけ赤(`c.ng`)、事前チェック異常は警告色(`c.wn`)★
 *   `severity` が2値化されても分岐は変わらない(D-105 で確認のみ)。
 */
function severityColor(c: ThemeTokens, severity: PrecheckSeverity): string {
  return severity === "launch_failed" ? c.ng : c.wn;
}

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()` に通す辞書キー。
 * ★`value` は `PrecheckStatus` そのもの★ — onChange で受け取った文字列をこの表で引き直して型を戻す
 */
const RESOLUTION_STATUS_OPTIONS: readonly { value: PrecheckStatus; label: string }[] = [
  { value: "unresolved", label: "未解消" },
  { value: "in_progress", label: "対応中" },
  { value: "resolved", label: "解消" },
];

/** 対応状況バッジの文言(解消は「解消 (日時)」)。読み取り専用のバッジ(出発失敗の行)で使う */
function StatusBadgeText({ item }: { item: PrecheckItem }) {
  const { t } = useTheme();
  switch (item.resolution_status) {
    case "resolved":
      return (
        <>
          {t("解消")} ({item.resolved_at ? formatDateTime(item.resolved_at) : "-"})
        </>
      );
    case "in_progress":
      return <>{t("対応中")}</>;
    case "unresolved":
      return <>{t("未解消")}</>;
  }
}

const EMPTY_SUMMARY: PrecheckSummary = { unresolved: 0, resolved: 0, total: 0 };

/** 対応状況のバッジ・プルダウンの文言(辞書キー)。表は `RESOLUTION_STATUS_OPTIONS` が正 */
function resolutionStatusLabel(status: PrecheckStatus): string {
  return RESOLUTION_STATUS_OPTIONS.find((o) => o.value === status)?.label ?? status;
}

/**
 * カードの class(★変えた行の目印★ と ★本清掃が成功した行を控えめにする★ を重ねる)。
 *
 * ★本清掃が成功した事前チェック異常(`main_run_succeeded`)は控えめに出す★(ユーザー指示 2026-10-07。D-171 の案2)—
 *   ★対応状況(未解消など)・件数は変えない★。見た目だけ。★編集モードでも同じ見た目★で、
 *   プルダウン・備考は今までどおり使える(不透明度は操作部品にかけない。`.itemCardSubdued` の CSS)
 */
function itemCardClass(item: PrecheckItem, changed: boolean): string {
  const classes = [styles.itemCard];
  if (item.main_run_succeeded) classes.push(styles.itemCardSubdued);
  if (changed) classes.push(styles.itemCardChanged);
  return classes.join(" ");
}

/** ★編集できるのは事前チェックの行だけ★(出発失敗には記録先が無い。D-104 / D-105 / 指示書AT §3-3) */
function isEditable(item: PrecheckItem): boolean {
  return item.severity === "precheck";
}

/**
 * 1行ぶんの PATCH(`PATCH /prechecks/{id}/status`。指示書AT §3-8)。
 * ★失敗は例外のまま投げる★(`saveDrafts()` が行ごとに受け止めて下書きを残す。D-165)
 */
function sendPrecheckPatch(
  item: PrecheckItem,
  request: PrecheckStatusUpdateRequest
): Promise<PrecheckItem> {
  return apiFetch<PrecheckItem>(`/api/prechecks/${encodeURIComponent(item.id)}/status`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(request),
  });
}

/** 行の下に出す保存失敗の文言。★サーバーの文言を優先★(無ければ汎用の文言) */
function saveErrorMessage(e: unknown): string {
  return (e instanceof ApiFetchError ? e.serverMessage : null) ?? "保存に失敗しました";
}

export function AlertsScreen() {
  const { t, theme } = useTheme();
  const c = THEME_TOKENS[theme];

  /**
   * 分類(ホールディングス → 屋号)の絞り込み(指示書AK 段階7。D-053 を解除)。
   *
   * ★この画面に「検索」ボタンは無い★ — モック:602-620 のツールバーは
   *   日付2つ・セレクト2つ・ステータスボタン3つだけで、★モック全体で「検索」ボタンが
   *   あるのはレポート画面(:679)1箇所だけ★(実測)。
   *   セレクトの変更は日付入力やステータスボタンと同じ「1操作=1確定」なので、
   *   ★フックの `draft` をそのまま確定値として使う★(`DetailLogScreen.tsx` と同じ判断。
   *   `ReportsScreen.tsx` のような draft/確定の二重持ちはしない)。
   */
  const classification = useClassificationFilter(t);

  const [dateFrom, setDateFrom] = useState("");
  const [dateTo, setDateTo] = useState("");
  const [statusFilter, setStatusFilter] = useState<StatusButtonFilter>("open");

  /**
   * 現在ページ(1始まり。指示書AA 段階4)
   *
   * ★フィルタを変えたら必ず 1 に戻すこと★ — 件数が減ったときにページ3へ
   *   取り残されると、ユーザーが名指しで禁じた「押したら有りません」の状態になる。
   * ★`useEffect` で後追いしないこと★ — 古いページのままフェッチが1回余計に飛ぶ。
   *   state を変えるその場で一緒に戻す。
   */
  const [page, setPage] = useState(1);

  const [items, setItems] = useState<PrecheckItem[]>([]);
  const [summary, setSummary] = useState<PrecheckSummary>(EMPTY_SUMMARY);
  /**
   * 一覧(対応状況フィルタ後)の総件数。★`meta.total`★ を入れる。
   *
   * ★`summary.total` を流用しないこと★(D-054 / 指示書AA 禁止事項4)—
   *   `summary` は対応状況フィルタの**前**に計算されているので、`status=open` で絞ると
   *   `summary.total > meta.total` になり、**存在しないページのボタンが出る**。
   */
  const [total, setTotal] = useState(0);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  /**
   * ★対応状況・備考の編集モード(ユーザー指示 2026-10-06。D-165)★ — 画面全体で切り替える。
   * ・通常モードでは表示だけ(押しても何も起きない)
   * ・編集モードで変えた内容は ★`drafts`(下書き)に貯めるだけで送らない★。「保存」でまとめて送る
   * ・★編集モード中は絞り込みとページ送りを無効にする★(取り直すと下書きの行が一覧から消えるため)
   * ・旧(D-161 / 指示書AT §3-8)の「プルダウンを選んだら即送信」「備考はフォーカスが外れたら送信」は廃止
   */
  const [editing, setEditing] = useState(false);
  const [drafts, setDrafts] = useState<ResolutionDrafts>(() => new Map());
  const [saving, setSaving] = useState(false);
  /** 行ごとの保存失敗(id → 文言)。★失敗した行は下書きを残し、行の下に出す★ */
  const [rowErrors, setRowErrors] = useState<ReadonlyMap<string, string>>(() => new Map());
  /** 直前の保存で失敗した件数(一覧の上に「{n}件を保存できませんでした」)。失敗が無ければ 0 */
  const [failedCount, setFailedCount] = useState(0);

  const queryString = useMemo(() => {
    const params = new URLSearchParams();
    if (dateFrom) params.set("date_from", dateFrom);
    if (dateTo) params.set("date_to", dateTo);
    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` を依存に入れること★ — 入れ忘れると `queryString` が変わらず、
    //   `fetchAlerts` も変わらないので、ボタンを押しても取り直さない
  }, [dateFrom, dateTo, statusFilter, classification.draft, page]);

  const fetchAlerts = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const body = await apiFetch<SummaryListResponse<PrecheckItem, PrecheckSummary>>(
        `/api/prechecks?${queryString}`
      );
      setItems(body.items);
      setSummary(body.summary);
      // ★総ページ数は `meta.total` から出す(`summary.total` ではない)★
      setTotal(body.meta.total);
      // ★フィルタ外の要因(データ更新など)で総ページ数が減り、存在しないページに
      //   取り残された場合の保険。最終ページに寄せて取り直す★
      //   `pages > 0` の判定を落とさないこと(0件のとき setPage(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]);

  /**
   * ★下書きがあるあいだはページを離れる前に確認する★(D-165)。
   * 下書きが無くなったら(保存・キャンセル)外す。
   */
  const hasDrafts = drafts.size > 0;
  useEffect(() => {
    if (!hasDrafts) return;
    const onBeforeUnload = (e: BeforeUnloadEvent) => {
      e.preventDefault();
    };
    window.addEventListener("beforeunload", onBeforeUnload);
    return () => window.removeEventListener("beforeunload", onBeforeUnload);
  }, [hasDrafts]);

  /** 下書き・失敗表示を捨てて通常モードに戻る */
  const exitEditMode = useCallback(() => {
    setEditing(false);
    setDrafts(new Map());
    setRowErrors(new Map());
    setFailedCount(0);
  }, []);

  const startEditMode = useCallback(() => {
    setRowErrors(new Map());
    setFailedCount(0);
    setEditing(true);
  }, []);

  /** ★下書きがあれば確認してから捨てる★(無ければ確認なしで戻る) */
  const cancelEditMode = useCallback(() => {
    if (drafts.size > 0 && !window.confirm(t("変更を破棄しますか?"))) return;
    exitEditMode();
  }, [drafts, exitEditMode, t]);

  /**
   * 下書きをまとめて保存する(D-165)。★変えた行だけを1行ずつ順に★ PATCH する(`saveDrafts()`)。
   *
   * ・★成功した行はレスポンスの `PrecheckItem` で差し替える★。サマリは `applyResolutionChange()` で差分だけ反映する
   * ・★`fetchAlerts()` を呼ばないこと★ — 取り直すとローディング表示に入れ替わって位置を見失う
   *   (`DsLogScreen.tsx` の `commitNote()` と同じ理由)。
   * ・★絞り込み(未解消のみ)に合わなくなっても、次に取り直すまで行を消さない★(押し間違いをその場で戻せるように)
   * ・★全部成功 → 通常モード★。★1件でも失敗 → 編集モードのまま★、失敗した行の下書きと文言を残す
   * ・変更が0件なら何も送らずに通常モードに戻る(「保存」が「編集を終了」の意味になる)
   */
  const saveEdits = useCallback(async () => {
    if (drafts.size === 0) {
      exitEditMode();
      return;
    }
    setSaving(true);
    setRowErrors(new Map());
    setFailedCount(0);
    try {
      const rows = items.filter(isEditable);
      const result = await saveDrafts(rows, drafts, sendPrecheckPatch);
      if (result.updated.size > 0) {
        setItems((prev) => prev.map((p) => result.updated.get(p.id) ?? p));
        setSummary((prev) =>
          rows.reduce((acc, row) => {
            const updated = result.updated.get(row.id);
            return updated
              ? applyResolutionChange(acc, row.resolution_status, updated.resolution_status)
              : acc;
          }, prev)
        );
      }
      setDrafts(result.remaining);
      if (result.failed.size === 0) {
        setEditing(false);
      } else {
        setRowErrors(
          new Map([...result.failed].map(([id, e]) => [id, saveErrorMessage(e)] as const))
        );
        setFailedCount(result.failed.size);
      }
    } finally {
      setSaving(false);
    }
  }, [drafts, exitEditMode, items]);

  const changeDraftStatus = useCallback((item: PrecheckItem, value: string) => {
    const next = RESOLUTION_STATUS_OPTIONS.find((o) => o.value === value)?.value;
    // 表に無い値なら何もしない(`value` を `PrecheckStatus` に戻すため表で引き直す)
    if (next === undefined) return;
    setDrafts((prev) => setDraftStatus(prev, item, next));
  }, []);

  const changeDraftNote = useCallback((item: PrecheckItem, note: string) => {
    setDrafts((prev) => setDraftNote(prev, item, note));
  }, []);

  const editableCount = items.filter(isEditable).length;

  // 分類のセレクトの値(`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}>
        <h1 className={styles.title}>{t("アラート(事前チェック)")}</h1>
        <div className={styles.subtitle}>{t("スケジュール開始前のエラー検知と出発失敗")}</div>
      </div>

      <div className={styles.statGrid}>
        <StatCard value={String(summary.unresolved)} label={t("未解消")} sub={t("要対応")} tone="ng" />
        <StatCard value={String(summary.resolved)} label={t("解消済み")} sub={t("対応完了")} tone="ok" />
        <StatCard value={String(summary.total)} label={t("合計")} sub={t("全アラート")} tone="ac" />
      </div>

      <div className={styles.toolbar}>
        {/* ★編集モード中は絞り込みを無効にする(ユーザー指示 2026-10-06。D-165)★ —
            取り直すと下書きの行が一覧から消えるため。`<fieldset disabled>` で中の入力・ボタンをまとめて無効にする
            (`FilterButton` に disabled を足さずに済む) */}
        <fieldset className={styles.filterSet} disabled={editing}>
          <div className={styles.dateGroup}>
            <input
              type="date"
              className={styles.dateInput}
              /* 枠のどこを押してもカレンダーを開く(ユーザー指示 2026-10-06。D-162) */
              onClick={openDatePicker}
              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}
              value={dateTo}
              onChange={(e) => {
                setDateTo(e.target.value);
                setPage(1);
              }}
            />
          </div>
          {/* ★分類の絞り込み(モック:609-614。指示書AK 段階7 で D-053 を解除)★
              ・選択肢は `/groups` と `/customers` から取る(★固定の選択肢を書かない★。
                モックの "PPIH" / "ドン・キホーテ" を写し取ったのが D-053 で消されたUI)
              ・★取得に失敗した/屋号が0件のときは出さない★(`available` が false)—
                ★空の `<select>` は「押しても何も起きないUI」への逆戻り★(落とし穴11)
              ・★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ページ目に戻す★
                    //   (件数が減ったときにページ3へ取り残されないため)
                    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>
            </>
          )}
          <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>
        {/* ★編集 / 保存({n}件)/ キャンセル(D-165)★ — 絞り込みの行の右端。
            ★幅は固定(`.editActions` / `.editButton`)★(JP/EN や件数で行の折り返しが変わらないように) */}
        <div className={styles.editActions}>
          {editing ? (
            <>
              <button
                type="button"
                className={`${styles.editButton} ${styles.editButtonPrimary}`}
                disabled={saving}
                onClick={() => void saveEdits()}
              >
                {saving
                  ? t("保存中…")
                  : drafts.size > 0
                    ? t("保存({n}件)").replace("{n}", String(drafts.size))
                    : t("保存")}
              </button>
              <button
                type="button"
                className={styles.editButton}
                disabled={saving}
                onClick={cancelEditMode}
              >
                {t("キャンセル")}
              </button>
            </>
          ) : (
            <button
              type="button"
              className={`${styles.editButton} ${styles.editButtonPrimary}`}
              /* 編集できる行(事前チェック)が無いときは押せない(押しても何も変えられない) */
              disabled={loading || editableCount === 0}
              onClick={startEditMode}
            >
              {t("編集")}
            </button>
          )}
        </div>
      </div>

      {editing && (
        <div className={styles.editNotice}>
          <div>{t("編集中は絞り込みとページ送りを使えません")}</div>
          {failedCount > 0 && (
            <div className={styles.editFailed}>
              {t("{n}件を保存できませんでした").replace("{n}", String(failedCount))}
            </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) => {
            const editable = isEditable(item);
            const draft = editable ? drafts.get(item.id) : undefined;
            const status = draftStatus(item, draft);
            const note = draftNote(item, draft);
            const rowError = rowErrors.get(item.id);
            return (
              <div
                key={item.id}
                /* ★変えた行の目印は背景の色(`.itemCardChanged`)★ — 左の帯は分類の色なので変えない(D-165)。
                   ★本清掃が成功した行は控えめ(`.itemCardSubdued`。D-171)★ — 左の帯も色はそのまま薄くする */
                className={itemCardClass(item, draft !== undefined)}
                style={{
                  borderLeftColor: item.main_run_succeeded
                    ? `color-mix(in srgb, ${severityColor(c, item.severity)} 60%, transparent)`
                    : severityColor(c, item.severity),
                }}
              >
                <div className={styles.itemRow}>
                  <div className={styles.itemMain}>
                    <div className={styles.itemBadges}>
                      <Pill
                        text={precheckSeverityLabel(t, item)}
                        color={severityColor(c, item.severity)}
                      />
                      <Pill text={item.robot_status} color={statusColor(theme, item.robot_status)} />
                      {/* ★本清掃が成功した事前チェック異常の目印(D-171)★ — 対応状況のバッジは変えない */}
                      {item.main_run_succeeded && <Pill text={t("本清掃は成功")} color={c.ok} />}
                      <span className={styles.serial}>{item.serial_number}</span>
                    </div>
                    <div className={styles.facility}>{item.facility_name}</div>
                    {/* ★備考を編集できるのは事前チェックの行だけ★(出発失敗には記録先が無い。D-104 / 指示書AT §3-3) */}
                    {editable && editing ? (
                      /* ★編集モード(D-165)★ — 入力欄は「予定: HH:MM」の下。2行ぶんの固定の高さ・幅は列いっぱい */
                      <>
                        <div className={styles.scheduleLine}>
                          {t("予定")}: {formatHm(item.scheduled_at)}
                        </div>
                        <textarea
                          className={styles.noteInput}
                          aria-label={t("備考")}
                          value={note}
                          rows={2}
                          /* 上限超過(422)を画面側で先に防ぐ。上限値の正は lib/config(YAML の maxLength と同じ) */
                          maxLength={PRECHECK_NOTE_MAX_LENGTH}
                          placeholder={t("備考を入力")}
                          disabled={saving}
                          onChange={(e) => changeDraftNote(item, e.target.value)}
                        />
                      </>
                    ) : editable && note ? (
                      /* ★通常モードは表示だけ(D-165)★。★1行に収める(D-162)★ — `.scheduleLineNote` で横並びにし、
                         備考の枠(`.noteText`)が残りの幅を全部取って、はみ出す分は「…」。全文は `title`。
                         ★空のときは何も出さない★(「クリックして備考を編集」は出さない) */
                      <div className={`${styles.scheduleLine} ${styles.scheduleLineNote}`}>
                        <span className={styles.schedulePrefix}>
                          {t("予定")}: {formatHm(item.scheduled_at)} —
                        </span>
                        <span className={styles.noteText} title={note}>
                          {note}
                        </span>
                      </div>
                    ) : (
                      <div className={styles.scheduleLine}>
                        {t("予定")}: {formatHm(item.scheduled_at)}
                        {item.note ? ` — ${item.note}` : ""}
                      </div>
                    )}
                    {item.battery_level !== null && (
                      <div className={styles.batteryLine}>
                        {t("残量")} {item.battery_level}%
                      </div>
                    )}
                    <div className={styles.detectedLine}>
                      {t("検知")}: {formatDateTime(item.detected_at)}
                    </div>
                  </div>
                  <div className={styles.statusColumn}>
                    {/* ★事前チェックの行だけ、編集モードでプルダウンにする★(未解消 / 対応中 / 解消 から選ぶ。D-161 の見た目)。
                        通常モードでは色付きのバッジ(表示だけ。D-165)。
                        ★出発失敗の行はどちらのモードでもバッジのまま★(書き込み先が無い。D-105) */}
                    {editable ? (
                      <>
                        {editing ? (
                          <select
                            className={`${styles.statusSelect} ${statusBadgeClass(status)}`}
                            aria-label={t("対応状況")}
                            value={status}
                            disabled={saving}
                            onChange={(e) => changeDraftStatus(item, e.target.value)}
                          >
                            {RESOLUTION_STATUS_OPTIONS.map((o) => (
                              <option key={o.value} value={o.value}>
                                {t(o.label)}
                              </option>
                            ))}
                          </select>
                        ) : (
                          <span
                            className={`${styles.statusBadge} ${styles.statusBadgeFixed} ${statusBadgeClass(status)}`}
                          >
                            {t(resolutionStatusLabel(status))}
                          </span>
                        )}
                        {/* 解消日時は解消のときだけ下に出す(未解消・対応中では何も出さない)。
                            ★下書きで解消にしただけの行には出さない★(日時は保存したときにサーバーが決める) */}
                        {status === "resolved" && item.resolution_status === "resolved" && (
                          <div className={styles.statusTime}>
                            {item.resolved_at ? formatDateTime(item.resolved_at) : "-"}
                          </div>
                        )}
                      </>
                    ) : (
                      <span className={`${styles.statusBadge} ${statusBadgeClass(item.resolution_status)}`}>
                        <StatusBadgeText item={item} />
                      </span>
                    )}
                  </div>
                </div>
                {/* 保存に失敗した行の文言。サーバーの文言は辞書に無ければ原文のまま(`DsLogScreen.tsx` と同じ) */}
                {rowError !== undefined && <div className={styles.rowError}>{t(rowError)}</div>}
              </div>
            );
          })}
        </div>
      )}

      {/* ★件数表示に `items.length` を使わないこと★ —
          ローディング中や取得失敗で items が空になり、件数だけ0に見える。
          0件のときは部品側が null を返すので、空表示と二重にならない */}
      {/* ★編集モード中はページ送りも無効(D-165)★ — `<fieldset disabled>` で中のボタンをまとめて無効にする */}
      <fieldset className={styles.pagerSet} disabled={editing}>
        <Pagination
          total={total}
          page={page}
          perPage={PAGINATION.defaultPerPage}
          onPageChange={setPage}
        />
      </fieldset>
    </div>
  );
}