Newer
Older
client_dwatch / components / features / facilities / FacilitiesScreen.tsx
"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import { Pagination } from "@/components/ui/Pagination";
import { Pill } from "@/components/ui/Pill";
import { useTheme } from "@/components/theme/ThemeProvider";
import { apiFetch, ApiFetchError } from "@/lib/api/client";
import { PAGINATION } from "@/lib/config";
import { isSupportedModel } from "@/lib/format/robot-model";
import { totalPageCount } from "@/lib/pagination/page-items";
import { THEME_TOKENS } from "@/lib/theme";
import type { Facility, ListResponse } from "@/lib/types";
import styles from "./FacilitiesScreen.module.css";

/**
 * F-08 施設ロボットマスタ(読み取り専用)
 *
 * ★モック右上にある登録・更新系の2ボタンは置かない★
 *   登録・更新は D-Security 側の画面で行い、D-Watch は GET のみ(docs/spec.md,
 *   `app/api/facilities/route.ts` の冒頭コメント)。モックにはボタンが描かれているが
 *   `onClick` が無い飾りで、実装すると「押せるのに何も起きないUI」になる(D-053)。
 *   グレーアウトして置くのも同じ理由で不可。
 *
 * ★任意項目(電話・責任者名・担当者名・メール)は null がありうる★(`Facility` の型)。
 *   空セルにすると列がずれて見えるため "-" を出す(`DsLogScreen` の備考列と同じ)。
 */

/** 任意項目の欠損表示。空文字だと行の高さが詰まって列がずれて見える */
function orDash(value: string | null): string {
  return value ?? "-";
}

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

  const [q, setQ] = useState("");
  const [debouncedQ, setDebouncedQ] = useState("");

  /**
   * 現在ページ(1始まり。指示書AA 段階5)
   *
   * ★検索語の debounce 反映と同時に 1 に戻すこと★ — 件数が減ったときに
   *   存在しないページへ取り残されると空表示になる。
   */
  const [page, setPage] = useState(1);

  const [items, setItems] = useState<Facility[]>([]);
  /** フッタの件数表示はフィルタ後の総件数(`meta.total`)。ページング後の items.length ではない */
  const [total, setTotal] = useState(0);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  // 1文字ごとに叩くと入力中ずっとローディング表示に入れ替わるので 300ms 待つ
  // (`DsLogScreen` / `DetailLogScreen` と同じ)
  // ★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("per_page", String(PAGINATION.defaultPerPage));
    params.set("page", String(page));
    return params.toString();
    // ★`page` を依存に入れること★ — 入れ忘れるとボタンを押しても取り直さない
  }, [debouncedQ, page]);

  const fetchFacilities = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const body = await apiFetch<ListResponse<Facility>>(`/api/facilities?${queryString}`);
      setItems(body.items);
      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);
    } catch (e) {
      setError(
        (e instanceof ApiFetchError ? e.serverMessage : null) ??
          "データの取得に失敗しました"
      );
      setItems([]);
      setTotal(0);
    } finally {
      setLoading(false);
    }
  }, [queryString]);

  useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect -- 検索語の変更に合わせた一覧の取り直し
    void fetchFacilities();
  }, [fetchFacilities]);

  return (
    <div className={styles.page}>
      <div className={styles.header}>
        <h1 className={styles.title}>{t("施設ロボットマスタ")}</h1>
        <input
          className={styles.searchInput}
          placeholder={t("施設名 / シリアル / 住所")}
          value={q}
          onChange={(e) => setQ(e.target.value)}
        />
      </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.card}>
          <div className={styles.tableWrap}>
            <table className={styles.table}>
              {/*
                ★列幅は言語によらず固定する(table-layout: fixed)★
                見出しの文字で列幅が決まる作りだと、JP/EN を切り替えるたびに列ごとに幅が変わって
                表が横に動く(2026-10-01 ユーザー指摘。最大29px)。幅の値と理由は CSS 側の .col* に書く。
                ★住所列にも幅を付け、表の余った幅は全列に幅の比で配る★(ユーザー指示 2026-10-06
                「3つとも同じように調整して」。D-167 追記。旧: 住所列だけ幅を指定せず余りを全部受け持っていた)。
                列を足す・並べ替えるときはここの <col> も同じ順に直すこと(ずれると別の列に幅が付く)。
              */}
              <colgroup>
                <col className={styles.colName} />
                <col className={styles.colPrefecture} />
                <col className={styles.colAddress} />
                <col className={styles.colPhone} />
                <col className={styles.colPerson} />
                <col className={styles.colPerson} />
                <col className={styles.colEmail} />
                <col className={styles.colMonitor} />
                <col className={styles.colDsec} />
                <col className={styles.colRobots} />
              </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.thCenter}>{t("監視")}</th>
                  <th className={styles.thCenter}>{t("D-Sec")}</th>
                  <th className={styles.th}>{t("ロボット(メーカー / 機種 / シリアル)")}</th>
                </tr>
              </thead>
              <tbody>
                {items.map((facility) => (
                  <tr key={facility.id} className={styles.row}>
                    <td className={styles.tdName}>{facility.facility_name}</td>
                    <td className={styles.tdMuted}>{facility.prefecture}</td>
                    <td className={styles.tdMuted}>{facility.address}</td>
                    <td className={styles.tdPhone}>{orDash(facility.phone)}</td>
                    <td className={styles.td}>{orDash(facility.manager_name)}</td>
                    <td className={styles.td}>{orDash(facility.charger_name)}</td>
                    <td className={styles.tdEmail}>{orDash(facility.email)}</td>
                    <td className={styles.tdMark}>
                      {facility.monitoring_enabled ? (
                        <span className={styles.markOn}>○</span>
                      ) : (
                        <span className={styles.markOff}>ー</span>
                      )}
                    </td>
                    <td className={styles.tdMark}>
                      {facility.d_security_enabled ? (
                        <span className={styles.markOnSec}>○</span>
                      ) : (
                        <span className={styles.markOff}>ー</span>
                      )}
                    </td>
                    <td className={styles.tdRobots}>
                      {facility.robots.map((robot) => (
                        <div key={robot.id} className={styles.robotRow}>
                          <Pill text={robot.maker} color={c.pr} />
                          <span className={styles.robotModel}>{robot.model}</span>
                          {/*
                            ★未登録機種を隠さず「未対応」バッジで出す(指示書N / D-087)★
                            レジストリに無い機種が実データから来ても画面から消さない —
                            隠すと例外もテスト失敗も出ないまま静かにロボットが消える。
                            色だけで区別せず必ず文字を出す(色覚特性・白黒印刷でも伝わるように)。
                            ★これはAPIレスポンスに基づく判定の再計算ではない★ —
                            CLAUDE.md §9.6 が禁じるのは「◎○△×・L1〜L3等、データに依存する
                            判定をフロントで再計算すること」。ここでの isSupportedModel() は
                            DFA側の静的な対応表を引くだけで、APIレスポンスに `supported` を
                            足すと d-watch-api.yaml の変更が必要になり範囲が広がるため、
                            設定値の参照としてフロント側に置いている。
                          */}
                          {!isSupportedModel(robot.maker, robot.model) && (
                            <Pill text={t("未対応")} color={c.wn} />
                          )}
                          <span className={styles.robotSerial}>{robot.serial_number}</span>
                        </div>
                      ))}
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
          {/* ★件数表示に `items.length` を使わないこと★。文言は部品側に閉じ込めてある */}
          <Pagination
            total={total}
            page={page}
            perPage={PAGINATION.defaultPerPage}
            onPageChange={setPage}
          />
        </div>
      )}
    </div>
  );
}