Newer
Older
client_dwatch / components / features / schedules / SchedulesScreen.tsx
"use client";

import { useEffect, useMemo, useState } from "react";
import { Pagination } from "@/components/ui/Pagination";
import { useTheme } from "@/components/theme/ThemeProvider";
import { apiFetch, ApiFetchError } from "@/lib/api/client";
import { PAGINATION } from "@/lib/config";
import { totalPageCount } from "@/lib/pagination/page-items";
import type { ListResponse, Schedule } from "@/lib/types";
import styles from "./SchedulesScreen.module.css";

/**
 * スケジュール管理(F-09)。**完全な参照専用**の画面。
 *
 * ★行クリック・編集・削除を足さないこと★
 *   登録・更新は D-Security 側の画面で行うため、D-Watch は GET のみ
 *   (`app/api/schedules/route.ts` の JSDoc / `d-watch-api.yaml` の /schedules)。
 *   モック(d-watch-mock.jsx:1037-1069)にもボタン・遷移は無い。
 *
 * ★フッタのページ送りだけはモックに無いUI★(指示書AA 段階5)
 *   `CLAUDE.md` §5(モックが画面の正)の例外として、ユーザーの明示の承認
 *   (2026-09-11「ページ送りは作成してください」)に基づき入れている。
 *   201件目以降が画面から見えない問題を直すためで、参照専用であることは変えていない。
 *
 * ★h1 は「スケジュール管理」、ナビのラベルは「📅 スケジュール」で一致しない★
 *   モックがそうなっているので揃えない(components/layout/AppShell.tsx)。
 *
 * ★`weekdays`(7桁)を画面で解釈しないこと★
 *   曜日列にはサーバーが生成した `weekdays_label`(「毎日」「月水金」等)をそのまま出す。
 *   7桁の解釈が画面とサーバーの2箇所にあると、曜日の起点がズレたときに
 *   片方だけ直る事故が起きる(CLAUDE.md §9.6「判定結果はAPIから受け取る」と同じ趣旨)。
 *
 * ★`end_time` をパースしないこと★
 *   DB の end_time は「時」が 24〜29 を取りうる4桁文字列で、API は D-060 に従い
 *   「翌04:30」のような**整形済み文字列**で返す(`lib/schedules/query.ts` の注意書き)。
 *   画面は `{start_time} - {end_time}` と並べるだけ。時刻の計算をここに書かない。
 */
export function SchedulesScreen() {
  const { t } = useTheme();

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

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

  const [items, setItems] = useState<Schedule[]>([]);
  /** 一覧(検索後)の総件数。`meta.total` を入れる。items.length ではない */
  const [total, setTotal] = useState(0);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  // ★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]);

  // ★一覧の取得。取得中に検索語を変えたら、遅れて届いた古い応答は捨てる★(片付けで cancelled にする。
  //   捨てないと前の条件の結果が新しい条件の表示を上書きする。エラー一覧と同じ考え方。指示書BC 段階1・D-183)
  useEffect(() => {
    let cancelled = false;
    // eslint-disable-next-line react-hooks/set-state-in-effect -- 検索語の変更に合わせた一覧の取り直し
    setLoading(true);
    setError(null);
    apiFetch<ListResponse<Schedule>>(`/api/schedules?${queryString}`)
      .then(
        (body) => {
          if (cancelled) return;
          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);
        },
        (e: unknown) => {
          if (cancelled) return;
          setError(
            (e instanceof ApiFetchError ? e.serverMessage : null) ??
              "データの取得に失敗しました"
          );
          setItems([]);
          setTotal(0);
        }
      )
      .finally(() => {
        // ★古い取得の finally が新しい取得の読み込み中を消さないように★
        if (!cancelled) setLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [queryString]);

  return (
    <div className={styles.page}>
      <div className={styles.header}>
        <h1 className={styles.title}>{t("スケジュール管理")}</h1>
        <input
          className={styles.searchInput}
          aria-label={t("施設名 / シリアル")}
          placeholder={t("施設名 / シリアル")}
          value={q}
          onChange={(e) => setQ(e.target.value)}
        />
      </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.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.colDays} />
                <col className={styles.colTime} />
                <col className={styles.colActive} />
              </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.thCenter}>{t("有効")}</th>
                </tr>
              </thead>
              <tbody>
                {items.map((item) => (
                  <tr
                    key={item.id}
                    className={
                      item.active ? styles.row : `${styles.row} ${styles.rowInactive}`
                    }
                  >
                    {/* ★有効なロボットがいない清掃エリアは null → 「-」★(指示書BB U2・D6。t() に通さない) */}
                    <td className={styles.tdSerial}>{item.serial_number ?? "-"}</td>
                    <td className={styles.td}>{item.facility_name}</td>
                    {/* サーバー生成のラベルをそのまま出す。7桁の weekdays は見ない */}
                    <td className={styles.td}>{item.weekdays_label}</td>
                    {/* 整形済みの文字列を並べるだけ(日跨ぎは「23:30 - 00:30」等) */}
                    <td className={styles.td}>
                      {item.start_time} - {item.end_time}
                    </td>
                    <td
                      className={`${styles.tdActive} ${
                        item.active ? styles.activeOn : styles.activeOff
                      }`}
                    >
                      {item.active ? "●" : "○"}
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
          {/* ★件数表示に `items.length` を使わないこと★。0件なら部品側が null を返す */}
          <Pagination
            total={total}
            page={page}
            perPage={PAGINATION.defaultPerPage}
            onPageChange={setPage}
          />
        </div>
      )}
    </div>
  );
}