"use client";
import { useCallback, 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]);
const fetchSchedules = useCallback(async () => {
setLoading(true);
setError(null);
try {
const body = await apiFetch<ListResponse<Schedule>>(`/api/schedules?${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 fetchSchedules();
}, [fetchSchedules]);
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 ユーザー指摘)。幅の値と理由は 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}`
}
>
<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>
);
}