Newer
Older
client_dwatch / components / features / ds-logs / DsLogScreen.tsx
"use client";

import { Fragment, useEffect, useMemo, useRef, 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 { DEVICE_NOTE_MAX_LENGTH, PAGINATION } from "@/lib/config";
import { formatHm, formatMd } from "@/lib/format/date";
import {
  noteDraftValue,
  saveNoteDrafts,
  setNoteDraft,
  type NoteDrafts,
} from "@/lib/ds-devices/note-drafts";
import type { DsDeviceSummary } from "@/lib/ds-devices/query";
import { useClassificationFilter } from "@/lib/hooks/use-classification-filter";
import {
  ALL_OPTION_VALUE,
  appendClassificationParams,
} from "@/lib/masters/options";
import { totalPageCount } from "@/lib/pagination/page-items";
import { THEME_TOKENS, type ThemeName } from "@/lib/theme";
import type {
  DsConnectionStatus,
  DsDevice,
  DsDeviceStatusFilter,
  SummaryListResponse,
} from "@/lib/types";
import { DeviceLogsModal } from "./DeviceLogsModal";
import styles from "./DsLogScreen.module.css";

type ThemeTokens = (typeof THEME_TOKENS)[ThemeName];

/** 通信状態は正常/異常の2状態だけ(D-065。通信断は「異常」そのもの) */
function connectionStatusLabel(status: DsConnectionStatus): string {
  if (status === "ok") return "正常";
  return "異常";
}

function connectionStatusColor(c: ThemeTokens, status: DsConnectionStatus): string {
  if (status === "ok") return c.ok;
  return c.ng;
}

function formatDateTime(iso: string): string {
  return `${formatMd(iso)} ${formatHm(iso)}`;
}

const EMPTY_SUMMARY: DsDeviceSummary = { total: 0, ok: 0, error: 0 };

/**
 * PATCH /ds-devices/{serial_number}/note のレスポンス。
 *
 * `d-watch-api.yaml` ではこのスキーマに名前が付いていない(インライン定義)ため、
 * `lib/types` には置かずここで持つ。名前付きスキーマになった時点で移す。
 * `note` が null になり得るのは、空文字を送ると備考が削除されるため。
 */
interface DsDeviceNoteResponse {
  serial_number: string;
  note: string | null;
}

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

/** 1行を送る(PATCH /ds-devices/{serial_number}/note)。★返ってきた備考(トリム済み・空なら null)を返す★。失敗は例外のまま */
async function sendDeviceNote(device: DsDevice, note: string): Promise<string | null> {
  const saved = await apiFetch<DsDeviceNoteResponse>(
    `/api/ds-devices/${encodeURIComponent(device.serial_number)}/note`,
    {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ note }),
    }
  );
  return saved.note;
}

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

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

  /**
   * 分類(ホールディングス → 屋号)の絞り込み(指示書AK 段階7。D-053 を解除)。
   *
   * ★この画面に「検索」ボタンは無い★ — モック:834-847 のツールバーは
   *   セレクト2つ・検索入力・フィルタボタン3つだけで、★モック全体で「検索」ボタンが
   *   あるのはレポート画面(:679)1箇所だけ★(実測)。
   *   セレクトの変更はフィルタボタンと同じ「1操作=1確定」なので、
   *   ★フックの `draft` をそのまま確定値として使う★(`DetailLogScreen.tsx` と同じ判断)。
   *   ★debounce が要るのは文字入力(`q`)だけ★で、セレクトは1操作で1回しか変わらない。
   *
   * ★★分類は summary(数値カード3枚)にも効く★★ — サーバ側が
   *   `q` / `status` より前に分類で絞っている(`app/api/ds-devices/route.ts`)ので、
   *   屋号を選ぶと「合計 / 正常 / 異常」もその屋号の値になる。
   */
  const classification = useClassificationFilter(t);

  const [q, setQ] = useState("");
  const [debouncedQ, setDebouncedQ] = useState("");
  const [statusFilter, setStatusFilter] = useState<DsDeviceStatusFilter>("all");

  /**
   * 現在ページ(1始まり。指示書AA 段階4)
   *
   * ★フィルタ・検索を変えたら必ず 1 に戻すこと★(debounce の反映時も)。
   * ★備考の保存では触らないこと★(保存の後に一覧を取り直さない)— 保存のたびに1ページ目へ飛ばされると使い物にならない。
   */
  const [page, setPage] = useState(1);

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

  /**
   * ★備考の編集モード(ユーザー決定 2026-10-19 U3。D-183)★ — エラー一覧・アラート・D-Sec アラートと同じ操作。
   * 変えた内容は下書きに貯め、「保存」で変えた行だけを1行ずつ送る。
   * ★旧: セルを押して入力・フォーカスが外れたら1件ずつ保存(D-171 まで)は廃止★
   * ・`drafts`: 変えた行の下書き(serial_number → 備考)。★行の値に戻したらエントリごと消える★(`setNoteDraft()`)
   * ・`rowErrors`: 保存に失敗した行(serial_number → 文言)。★失敗した行の下書きは残す★(打ち直しにならないように)
   * ・`saving`: 保存中は「保存」「キャンセル」とすべての入力を無効にする(重ねて送らない)
   */
  const [editing, setEditing] = useState(false);
  const [drafts, setDrafts] = useState<NoteDrafts>(() => new Map());
  const [rowErrors, setRowErrors] = useState<ReadonlyMap<string, string>>(() => new Map());
  const [saving, setSaving] = useState(false);
  const draftCount = drafts.size;

  /**
   * 「ログ」モーダル(指示書AY 段階9-1)。
   * 閉じたら押したボタンへフォーカスを戻すため、開いたときのボタンを保持する。
   */
  const [logsTarget, setLogsTarget] = useState<DsDevice | null>(null);
  const logsButtonRef = 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回の再描画にまとめる)
  useEffect(() => {
    const id = window.setTimeout(() => {
      setDebouncedQ(q);
      setPage(1);
    }, 300);
    return () => window.clearTimeout(id);
  }, [q]);

  const queryString = useMemo(() => {
    const params = new URLSearchParams();
    if (debouncedQ) params.set("q", debouncedQ);
    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` を依存に入れること★ — 入れ忘れるとボタンを押しても取り直さない
  }, [debouncedQ, statusFilter, classification.draft, page]);

  // ★一覧の取得。取得中に絞り込みを変えたら、遅れて届いた古い応答は捨てる★(片付けで cancelled にする。
  //   捨てないと前の条件の結果が新しい条件の表示を上書きする。エラー一覧と同じ考え方。指示書BC 段階1・D-183)
  useEffect(() => {
    let cancelled = false;
    // eslint-disable-next-line react-hooks/set-state-in-effect -- フィルタ変更に合わせた一覧の取り直し
    setLoading(true);
    setError(null);
    apiFetch<SummaryListResponse<DsDevice, DsDeviceSummary>>(`/api/ds-devices?${queryString}`)
      .then(
        (body) => {
          if (cancelled) return;
          setItems(body.items);
          setSummary(body.summary);
          // ★総ページ数は `meta.total` から出す(`summary.total` ではない)★
          setTotal(body.meta.total);
          // ★存在しないページに取り残された場合の保険★(`pages > 0` を落とさないこと)
          const pages = totalPageCount(body.meta.total, body.meta.per_page);
          if (pages > 0 && body.meta.page > pages) setPage(pages);
        },
        (e: unknown) => {
          if (cancelled) return;
          setError(
            (e instanceof ApiFetchError ? e.serverMessage : null) ??
              "データの取得に失敗しました"
          );
          setItems([]);
          setSummary(EMPTY_SUMMARY);
          setTotal(0);
        }
      )
      .finally(() => {
        // ★古い取得の finally が新しい取得の読み込み中を消さないように★
        if (!cancelled) setLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [queryString]);

  /** ★編集モードに入れるのは、表示中の一覧が今の絞り込みの結果と一致しているときだけ★(エラー一覧と同じ)—
   *  読み込み中や検索語の確定前(debounce の待ち)に入ると、編集中に一覧が取り直されて下書きの行が消える */
  const canStartEdit = !loading && error === null && items.length > 0 && q === debouncedQ;
  /** ★編集中・保存中は絞り込みとページ送りを無効にする★(下書きが消えないように) */
  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行ずつ順に送る★(`saveNoteDrafts()`)。
   * ・★成功した行はサーバーが返した備考で差し替える★(トリム・「空 → 削除」をサーバーが行うため。一覧は取り直さない)
   * ・★失敗した行は下書きを残し、行の直後に文言を出す★。★全部成功なら通常モードに戻る★
   * ・変更が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 saveNoteDrafts(items, drafts, sendDeviceNote);
      if (updated.size > 0) {
        setItems((prev) =>
          prev.map((d) =>
            updated.has(d.serial_number) ? { ...d, note: updated.get(d.serial_number) ?? null } : d
          )
        );
      }
      setDrafts(remaining);
      setRowErrors(
        new Map(Array.from(failed, ([serial, e]): [string, string] => [serial, rowErrorMessage(e)]))
      );
      if (failed.size === 0) setEditing(false);
    } finally {
      setSaving(false);
    }
  };

  return (
    <div className={styles.page}>
      <div className={styles.header}>
        <div>
          <h1 className={styles.title}>{t("D-Secログ")}</h1>
          <div className={styles.subtitle}>{t("D-S制御盤の稼働状況")}</div>
        </div>
        <div className={styles.headerRight}>
          {/* ★編集モードの切り替え(D-165 / D-183)★ — ★ボタンは固定幅★(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.total)} label={t("合計")} tone="ac" />
        <StatCard value={String(summary.ok)} label={t("正常")} tone="ok" />
        <StatCard value={String(summary.error)} label={t("異常")} sub={t("要対応")} tone="ng" />
      </div>

      {/* ★編集中は絞り込みをまとめて無効にする★(<fieldset disabled> で中のセレクト・検索・ボタンが全部無効になる。下書きが消えないように。D-165 / D-183) */}
      <fieldset className={styles.toolbar} disabled={filtersDisabled}>
        {/* ★分類の絞り込み(モック:835-840。指示書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>
          </>
        )}
        <input
          className={styles.searchInput}
          aria-label={t("施設名・シリアルで検索")}
          placeholder={t("施設名・シリアルで検索")}
          value={q}
          onChange={(e) => setQ(e.target.value)}
        />
        <div className={styles.statusButtons}>
          <FilterButton
            active={statusFilter === "all"}
            color={c.txD}
            onClick={() => {
              setStatusFilter("all");
              setPage(1);
            }}
          >
            {t("全て")}
          </FilterButton>
          <FilterButton
            active={statusFilter === "error"}
            color={c.ng}
            onClick={() => {
              setStatusFilter("error");
              setPage(1);
            }}
          >
            {t("異常")}
          </FilterButton>
          <FilterButton
            active={statusFilter === "ok"}
            color={c.ok}
            onClick={() => {
              setStatusFilter("ok");
              setPage(1);
            }}
          >
            {t("正常")}
          </FilterButton>
        </div>
      </fieldset>

      {/* 編集モードの案内と、保存に失敗した件数(D-165 / D-183) */}
      {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 && (
        <div className={styles.card}>
          <div className={styles.tableWrap}>
            <table className={styles.table}>
              {/*
                ★列幅は言語によらず固定する(table-layout: fixed)★
                見出しの文字で列幅が決まる作りだと、JP/EN を切り替えるたびに列ごとに幅が変わって
                表が横に動く(2026-10-01 ユーザー指摘)。通常モード ↔ 編集モードでも幅が動かない。
                幅の値と理由は CSS 側の .col* に書く。
                ★施設名列にも幅を付け、表の余った幅は全列に幅の比で配る★(ユーザー指示 2026-10-06
                「3つとも同じように調整して」。D-167 追記。旧: 施設名列だけ幅を指定せず余りを全部受け持っていた)。
                列を足す・並べ替えるときはここの <col> も同じ順に直すこと(ずれると別の列に幅が付く)。
              */}
              <colgroup>
                <col className={styles.colSerial} />
                <col className={styles.colFacility} />
                <col className={styles.colStatus} />
                <col className={styles.colLastReported} />
                <col className={styles.colNote} />
                <col className={styles.colLogs} />
              </colgroup>
              <thead>
                <tr>
                  <th className={styles.th}>{t("シリアル")}</th>
                  <th className={styles.th}>{t("施設名")}</th>
                  <th className={styles.thCenter}>{t("接続状態")}</th>
                  <th className={styles.th}>{t("最終通信")}</th>
                  <th className={styles.th}>{t("備考")}</th>
                  <th className={styles.thCenter}>{t("ログ")}</th>
                </tr>
              </thead>
              <tbody>
                {items.map((item) => {
                  // ★変えた行の目印★(下書きがある行。保存に失敗した行も下書きが残るので目印が付いたまま。エラー一覧と同じ)
                  const changed = drafts.has(item.serial_number);
                  const rowError = rowErrors.get(item.serial_number);
                  return (
                    <Fragment key={item.serial_number}>
                      <tr className={changed ? `${styles.row} ${styles.rowChanged}` : styles.row}>
                        <td className={styles.tdSerial}>{item.serial_number}</td>
                        <td className={styles.td}>{item.facility_name}</td>
                        <td className={styles.tdCenter}>
                          <Pill
                            text={t(connectionStatusLabel(item.connection_status))}
                            color={connectionStatusColor(c, item.connection_status)}
                          />
                        </td>
                        {/* ★心拍がまだ無い制御盤は null → 「-」★(指示書BB A1) */}
                        <td className={styles.tdDate}>{item.last_reported_at ? formatDateTime(item.last_reported_at) : "-"}</td>
                        <td className={styles.tdNote}>
                          {editing ? (
                            /* ★入力欄は固定の大きさ★(2行ぶん・resize: none・幅は列いっぱい。エラー一覧と同じ)。
                               上限超過(422)を画面側で先に防ぐ。上限値の正は lib/config(YAML と同じ) */
                            <textarea
                              className={styles.noteInput}
                              aria-label={t("備考")}
                              value={noteDraftValue(item, drafts)}
                              rows={2}
                              maxLength={DEVICE_NOTE_MAX_LENGTH}
                              placeholder={t("備考を入力")}
                              disabled={saving}
                              onChange={(e) => setDrafts((prev) => setNoteDraft(prev, item, e.target.value))}
                            />
                          ) : (
                            /* ★通常モードは文字だけ★(1行・はみ出しは「…」・全文は title。押しても何も起きない。
                               ★空のときは何も出さない★ — エラー一覧・アラートと同じ。旧: 「-」のボタン) */
                            <div className={styles.noteText} title={item.note ?? undefined}>
                              {item.note ?? ""}
                            </div>
                          )}
                        </td>
                        <td className={styles.tdLogs}>
                          {/* ★編集中は押せない(U6)★。表は fieldset の外なので disabled を直接付ける。
                              ★幅は固定★(JP/EN で位置が動かない。M1) */}
                          <button
                            type="button"
                            className={styles.logsButton}
                            disabled={filtersDisabled}
                            onClick={(e) => {
                              logsButtonRef.current = e.currentTarget;
                              setLogsTarget(item);
                            }}
                          >
                            {t("ログ")}
                          </button>
                        </td>
                      </tr>
                      {/* ★保存に失敗した行は直後に文言を出す★(下書きは残っている)。サーバーの文言は辞書に無ければ原文のまま */}
                      {rowError !== undefined && (
                        <tr>
                          <td colSpan={COLUMN_COUNT} className={styles.rowError}>
                            {t(rowError)}
                          </td>
                        </tr>
                      )}
                    </Fragment>
                  );
                })}
              </tbody>
            </table>
          </div>
          {items.length === 0 && (
            <div className={styles.empty}>{t("該当するデバイスはありません")}</div>
          )}
          {/* ★件数表示に `items.length` を使わないこと★。0件なら部品側が null を返す。
              ★編集中はページ送りを無効にする★(部品に disabled が無いので `<fieldset disabled>` で包む。D-165 / D-183) */}
          <fieldset className={styles.pagerFieldset} disabled={filtersDisabled}>
            <Pagination
              total={total}
              page={page}
              perPage={PAGINATION.defaultPerPage}
              onPageChange={setPage}
            />
          </fieldset>
        </div>
      )}

      {logsTarget && (
        <DeviceLogsModal
          serialNumber={logsTarget.serial_number}
          facilityName={logsTarget.facility_name}
          restoreFocusRef={logsButtonRef}
          onClose={() => setLogsTarget(null)}
        />
      )}
    </div>
  );
}