Newer
Older
client_dwatch / components / features / detail-log / DetailLogScreen.tsx
"use client";

import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { CleanLogTable } from "@/components/features/detail-log/CleanLogTable";
import { AreaModal } from "@/components/features/detail-log/AreaModal";
import { FilterButton } from "@/components/ui/FilterButton";
import { ScheduleFilterSelect } from "@/components/ui/ScheduleFilterSelect";
import { Pagination } from "@/components/ui/Pagination";
import { useTheme } from "@/components/theme/ThemeProvider";
import { apiFetch, ApiFetchError } from "@/lib/api/client";
import { cleanLogAdviceUrl } from "@/lib/clean-logs/advice-url";
import type { AreaFilter } from "@/lib/clean-logs/areas";
import {
  appendDateRangeParams,
  dateRangeError,
} from "@/lib/clean-logs/detail-log-date-range";
import { MAX_CSV_ROWS, MAX_DATE_RANGE_DAYS, PAGINATION } from "@/lib/config";
import { formatMd } from "@/lib/format/date";
import { openDatePicker } from "@/components/ui/date-picker";
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 } from "@/lib/theme";
import { nextSortState, type SortState } from "@/lib/table-sort";
import type {
  AreaCompletionsResponse,
  CleanLogAdviceResponse,
  CleanLogItem,
  CleanLogSortKey,
  ListResponse,
  ScheduleFilter,
} from "@/lib/types";
import styles from "./DetailLogScreen.module.css";

type GradeFilter = "problem" | "all" | "critical";

/**
 * エリアモーダルを開いている対象。
 *
 * ★`logId` は「エリアを特定するキー」ではなく、施設と日付を特定するための入口★
 * (指示書Z 決定1)。施設名・日付はヘッダに出すためだけに持つ。
 */
interface AreaTarget {
  logId: number;
  facilityName: string;
  dateLabel: string;
}

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

  /**
   * 分類(ホールディングス → 屋号)の絞り込み(指示書AK 段階7。D-053 を解除)。
   *
   * ★この画面に「検索」ボタンは無い★(モック:553-570 は CSV ボタンだけ)。
   *   セレクトの変更は △×のみ / ×のみ / 全件 や日付入力と同じ「1操作=1確定」なので、
   *   ★フックの `draft` をそのまま確定値として使う★(レポート画面のような
   *   draft/確定の二重持ちはしない)。
   *   ★1文字ごとに叩かないための debounce が要るのは文字入力だけ★で、
   *   セレクトは1回の操作で1回しか変わらない。
   */
  const classification = useClassificationFilter(t);

  /**
   * 期間(`YYYY-MM-DD`。空文字=指定なし)。
   *
   * ★既定は「期間を指定しない(全期間)」★(ユーザー指示 2026-10-06「C」。D-159 追記 / 指示書AV 段階5b)—
   *   ダッシュボードは「各清掃エリアの直近の清掃(7日以内)」なので、既定を当日にすると
   *   前日 19:00 の出発失敗がダッシュボードには出るのに、カードや「全項目 →」で移った先に出ない。
   *   旧: 開いたときは `date_from` = `date_to` = 当日。
   * ★空の側は URL に付けない★(`appendDateRangeParams()`)。片方だけならその側だけの範囲になる(5b 以前と同じ)。
   * ★性能の注意(本書では対策しない)★: Prisma 版は期間なしだと保持期間(2年)の予定を全部読んでから
   *   ページ分けする(`lib/db/index.ts` の `cleanLogs()`)。リリース前に見直す(指示書AV 段階5b-7)
   */
  const [dateFrom, setDateFrom] = useState("");
  const [dateTo, setDateTo] = useState("");
  const [gradeFilter, setGradeFilter] = useState<GradeFilter>("problem");
  const [scheduleFilter, setScheduleFilter] = useState<ScheduleFilter | null>(null);
  const [facilityName, setFacilityName] = useState("");
  const [model, setModel] = useState("");
  const [serial, setSerial] = useState("");
  const [debouncedFacility, setDebouncedFacility] = useState("");
  const [debouncedModel, setDebouncedModel] = useState("");
  const [debouncedSerial, setDebouncedSerial] = useState("");
  /**
   * 現在ページ(1始まり。指示書AA 段階3)
   *
   * ★フィルタを変えたら必ず 1 に戻すこと★ — 件数が減ったときにページ3へ
   *   取り残されると、ユーザーが名指しで禁じた「押したら有りません」の状態になる。
   * ★`useEffect` で後追いしないこと★ — 古いページのままフェッチが1回余計に飛ぶ。
   *   state を変えるその場で一緒に戻す(debounce のものはタイマーの中で戻す)。
   */
  const [page, setPage] = useState(1);
  /** 並べ替え(null は既定順)。★変えたらページを1へ戻す★(`changeSort()`) */
  const [sort, setSort] = useState<SortState<CleanLogSortKey>>(null);

  const [items, setItems] = useState<CleanLogItem[]>([]);
  const [total, setTotal] = useState(0);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  // ★開いている行・アドバイスは `row_key` で持つ★(指示書BG §2-1。予定外タスクの行は `id` が null で重なるため)
  const [expandedKey, setExpandedKey] = useState<string | null>(null);
  const [adviceByKey, setAdviceByKey] = useState<
    Record<string, CleanLogAdviceResponse | undefined>
  >({});
  const [adviceLoadingKey, setAdviceLoadingKey] = useState<string | null>(null);
  /** アドバイスの取得に失敗した行(★`adviceByKey` には入れない★ — 入れると次に開いたとき取り直さない) */
  const [adviceFailedKeys, setAdviceFailedKeys] = useState<ReadonlySet<string>>(() => new Set());
  const [showGradeInfo, setShowGradeInfo] = useState(false);
  const [areaTarget, setAreaTarget] = useState<AreaTarget | null>(null);
  // ★既定は "problem"(YAML の default と同じ)★
  const [areaFilter, setAreaFilter] = useState<AreaFilter>("problem");
  const [areaData, setAreaData] = useState<AreaCompletionsResponse | null>(null);
  const [areaLoading, setAreaLoading] = useState(false);
  const [areaError, setAreaError] = useState<string | null>(null);

  // ★debounce の反映と同時にページを1へ戻す★(React が1回の再描画にまとめる)
  useEffect(() => {
    const id = window.setTimeout(() => {
      setDebouncedFacility(facilityName);
      setPage(1);
    }, 300);
    return () => window.clearTimeout(id);
  }, [facilityName]);
  useEffect(() => {
    const id = window.setTimeout(() => {
      setDebouncedModel(model);
      setPage(1);
    }, 300);
    return () => window.clearTimeout(id);
  }, [model]);
  useEffect(() => {
    const id = window.setTimeout(() => {
      setDebouncedSerial(serial);
      setPage(1);
    }, 300);
    return () => window.clearTimeout(id);
  }, [serial]);

  const changeSort = (key: CleanLogSortKey) => {
    setSort((current) =>
      nextSortState(current, key, {
        first: "asc",
        twoState: key === "scheduled_at",
      })
    );
    setPage(1);
  };

  const queryString = useMemo(() => {
    const params = new URLSearchParams();
    // ★空の側は付けない★(期間なし=全期間。API の既存の挙動。CSV も同じクエリ)
    appendDateRangeParams(params, dateFrom, dateTo);
    params.set("result", gradeFilter);
    if (scheduleFilter) params.set("schedule", scheduleFilter);
    if (debouncedFacility) params.set("facility_name", debouncedFacility);
    if (debouncedModel) params.set("model", debouncedModel);
    if (debouncedSerial) params.set("serial", debouncedSerial);
    // ★分類は手書きしない★(`group_id=0` を漏らさない・空のキーを並べないための
    //   判断が `appendClassificationParams()` に入っている。指示書AK 落とし穴3)
    appendClassificationParams(params, classification.draft);
    if (sort) {
      params.set("sort", sort.key);
      params.set("order", sort.order);
    }
    params.set("per_page", String(PAGINATION.defaultPerPage));
    params.set("page", String(page));
    return params.toString();
    // ★`page` を依存に入れること★ — 入れ忘れると `queryString` が変わらず、
    //   取得の effect も走らないので、ボタンを押しても取り直さない
  }, [
    dateFrom,
    dateTo,
    gradeFilter,
    scheduleFilter,
    debouncedFacility,
    debouncedModel,
    debouncedSerial,
    classification.draft,
    sort,
    page,
  ]);

  // ★期間の検証は両方入っているときだけ★(指示書AV 段階5b-2)。文言・境界はサーバと同じ
  const rangeError = dateRangeError(dateFrom, dateTo, MAX_DATE_RANGE_DAYS);
  const dateRangeUnset = !dateFrom && !dateTo;

  /**
   * ★CSVを出せない絞り込みか★(ユーザー指示 2026-09-15 / 指示書AI 段階8)
   *
   * ★★一覧を取ったときの `meta.total` をそのまま使う★★ —
   *   ★CSVのために問い合わせを増やさないこと★(件数は既に手元にある)。
   *
   * ★上限は「含む」★ — ちょうど `MAX_CSV_ROWS` 件は出せる(`>` であって `>=` ではない)。
   *   サーバ側(`app/api/clean-logs/export/route.ts`)と★同じ向き★にすること。
   *   ★片方だけ `>=` にすると、押せるのに422が返る(またはその逆)★。
   *
   * ★★サーバ側の422を消さないこと★★ — APIを直接叩く経路が残る。
   *   ★画面は「親切」、サーバは「歯止め」で役割が違う★。
   *
   * ★読み込み中は false★ — `total` は前の条件の件数なので、そのまま使うと「上限超過」の文言が
   *   古いまま残る。ボタンは読み込み中も `csvUnavailable` で押せない。
   */
  const csvBlocked = !loading && total > MAX_CSV_ROWS;
  /**
   * ★今は CSV を出せない状態か★(期間の誤り・読み込み中。指示書BC 2-2)—
   *   期間が誤っていると API は 422 を返し、読み込み中は件数(`total`)が前の条件のもの。
   *   ★理由の文言は足さない★(期間の誤りは表の場所に出ている。読み込みはすぐ終わる)
   */
  const csvUnavailable = rangeError !== null || loading;

  // ★一覧の取得。取得中に絞り込みを変えたら、遅れて届いた古い応答は捨てる★(片付けで cancelled にする。
  //   捨てないと前の条件の結果が新しい条件の表示を上書きする。エラー一覧と同じ考え方。指示書BC 段階1・D-183)
  useEffect(() => {
    if (rangeError !== null) {
      // ★期間の誤りは取得しない★(前の取得は React が先に片付けるので、遅れて届いても表を上書きしない)
      // eslint-disable-next-line react-hooks/set-state-in-effect -- fetch 起点の一覧更新
      setError(rangeError);
      setItems([]);
      setTotal(0);
      setLoading(false);
      return;
    }

    let cancelled = false;
    setLoading(true);
    setError(null);
    apiFetch<ListResponse<CleanLogItem>>(`/api/clean-logs?${queryString}`)
      .then(
        (body) => {
          if (cancelled) return;
          setItems(body.items);
          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);
        },
        (e: unknown) => {
          if (cancelled) return;
          // サーバーが ApiError を返した場合はその文言、それ以外は画面側の既定文言
          setError(
            (e instanceof ApiFetchError ? e.serverMessage : null) ??
              "データの取得に失敗しました"
          );
          setItems([]);
          setTotal(0);
        }
      )
      .finally(() => {
        // ★古い取得の finally が新しい取得の読み込み中を消さないように★
        if (!cancelled) setLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [queryString, rangeError]);

  async function toggleRow(row: CleanLogItem) {
    // ★アドバイス URL が作れない行(id/report_id が無い。API の不変条件違反)は開かない★。
    //   ◎○・結果待ちの行は表が ▼ を出さず呼ばない(view.expandable。指示書BI)
    const url = cleanLogAdviceUrl(row);
    if (url === null) return;
    const key = row.row_key;
    if (expandedKey === key) {
      setExpandedKey(null);
      return;
    }
    setExpandedKey(key);
    if (adviceByKey[key]) return;
    // ★前に失敗した行は取り直す★(失敗の表示を消してから)
    setAdviceFailedKeys((prev) => {
      if (!prev.has(key)) return prev;
      const next = new Set(prev);
      next.delete(key);
      return next;
    });
    setAdviceLoadingKey(key);
    try {
      const body = await apiFetch<CleanLogAdviceResponse>(url);
      setAdviceByKey((prev) => ({ ...prev, [key]: body }));
    } catch {
      // ★失敗は「該当なし」と区別して出す★(行は開いたまま。予定のある行ならエリア別詳細のボタンは出る。指示書BC 2-1 / BI)
      setAdviceFailedKeys((prev) => new Set(prev).add(key));
    } finally {
      // ★自分の行の読み込み中だけを消す★ — 先に開いた行の取得が後から終わっても、
      //   後から開いた行の「読み込み中…」を消さない(指示書BC 2-1)
      setAdviceLoadingKey((prev) => (prev === key ? null : prev));
    }
  }

  /**
   * エリアモーダルを開く。
   *
   * ★ここでは取得しない★ — 取得は下の `useEffect` に寄せてある。
   *   ★`filter` を切り替えたら**取り直す**必要がある★(絞り込みはサーバ側。指示書Z 決定8)ので、
   *   「開いたとき」と「切り替えたとき」で取得経路が2本に分かれないようにした。
   * ★開くたびに `filter` を既定(問題エリアのみ)に戻す★ — 前に見た行の選択が残ると、
   *   別の行を開いたときに何を見ているのか分からなくなる。
   */
  function openAreas(row: CleanLogItem) {
    // ★予定外タスクの行(`id: null`)からは開かない★(`/areas` は予定の id が入口。指示書BG §2-1)
    if (row.id === null) return;
    setAreaFilter("problem");
    setAreaTarget({
      logId: row.id,
      facilityName: row.facility_name,
      dateLabel: formatMd(row.scheduled_at),
    });
  }

  const areaLogId = areaTarget?.logId ?? null;
  useEffect(() => {
    if (areaLogId === null) return;
    // 取得中に別の行を開く / filter を切り替えると、古い応答が後から届いて
    // 新しい表示を上書きしうるので破棄する
    let cancelled = false;
    // eslint-disable-next-line react-hooks/set-state-in-effect -- fetch 起点のモーダル更新
    setAreaLoading(true);
    setAreaError(null);
    setAreaData(null);
    void (async () => {
      try {
        const body = await apiFetch<AreaCompletionsResponse>(
          `/api/clean-logs/${areaLogId}/areas?filter=${areaFilter}`
        );
        if (!cancelled) setAreaData(body);
      } catch (e) {
        // ★取得できなくてもモーダルは開いたままにする★ —
        //   ボタンを押して何も起きないのがいちばん困るため、失敗はモーダルの中に出す
        if (!cancelled) {
          setAreaError(
            (e instanceof ApiFetchError ? e.serverMessage : null) ??
              "データの取得に失敗しました"
          );
        }
      } finally {
        if (!cancelled) setAreaLoading(false);
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [areaLogId, areaFilter]);

  return (
    <div className={styles.page}>
      <div className={styles.header}>
        <div>
          <h1 className={styles.title}>{t("清掃詳細ログ")}</h1>
          <div className={styles.subtitle}>{t("1行=1ロボット×1スケジュール実行")}</div>
        </div>
        <div className={styles.headerRight}>
          {/* ★期間が空のときは「全期間」と分かるようにする★(指示書AV 段階5b-5)—
              ★`<input type="date">` の placeholder は多くのブラウザで表示されない★ので、
              両方空のあいだだけ同じ文言を入力欄の前にも出す(placeholder にも同じ文言) */}
          {dateRangeUnset && (
            <span className={styles.dateSep}>{t("指定なし(全期間)")}</span>
          )}
          <input
            type="date"
            /* ★枠のどこを押してもカレンダーを開く★(ユーザー指示 2026-10-06。D-162) */
            onClick={openDatePicker}
            className={styles.dateInput}
            aria-label={t("開始日")}
            placeholder={t("指定なし(全期間)")}
            value={dateFrom}
            onChange={(e) => {
              setDateFrom(e.target.value);
              setPage(1);
            }}
          />
          <span className={styles.dateSep}>~</span>
          <input
            type="date"
            onClick={openDatePicker}
            className={styles.dateInput}
            aria-label={t("終了日")}
            placeholder={t("指定なし(全期間)")}
            value={dateTo}
            onChange={(e) => {
              setDateTo(e.target.value);
              setPage(1);
            }}
          />
          <Link href="/dashboard" className={styles.backBtn}>
            {t("← 戻る")}
          </Link>
        </div>
      </div>

      <div className={styles.card}>
        <div className={styles.toolbar}>
          <div className={styles.toolbarLeft}>
            {/* ★分類の絞り込み(モック:555-560。指示書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>
              </>
            )}
            <FilterButton
              active={gradeFilter === "problem"}
              color={c.wn}
              onClick={() => {
                setGradeFilter("problem");
                setPage(1);
              }}
            >
              {t("△×のみ")}
            </FilterButton>
            <FilterButton
              active={gradeFilter === "critical"}
              color={c.ng}
              onClick={() => {
                setGradeFilter("critical");
                setPage(1);
              }}
            >
              {t("×のみ")}
            </FilterButton>
            <FilterButton
              active={gradeFilter === "all"}
              color={c.txD}
              onClick={() => {
                setGradeFilter("all");
                setPage(1);
              }}
            >
              {t("全件")}
            </FilterButton>
            <ScheduleFilterSelect
              className={`${styles.select} ${styles.scheduleFilterSelect}`}
              value={scheduleFilter}
              onChange={(next) => {
                setScheduleFilter(next);
                setPage(1);
              }}
            />
          </div>
          <div className={styles.toolbarRight}>
            <input
              className={styles.searchInput}
              style={{ width: 130 }}
              value={facilityName}
              onChange={(e) => setFacilityName(e.target.value)}
              aria-label={t("施設名")}
              placeholder={t("施設名")}
            />
            <input
              className={styles.searchInput}
              style={{ width: 80 }}
              value={model}
              onChange={(e) => setModel(e.target.value)}
              aria-label={t("機種")}
              placeholder={t("機種")}
            />
            <input
              className={styles.searchInput}
              style={{ width: 100 }}
              value={serial}
              onChange={(e) => setSerial(e.target.value)}
              aria-label={t("シリアル")}
              placeholder={t("シリアル")}
            />
            {/* /api/clean-logs/export はページではなくファイルダウンロード用のAPIなので、
                Next のルーター(router.push)ではなく通常の <a download> でブラウザの
                ネイティブなダウンロード遷移に任せる。router.push を使うと RSC の
                プリフェッチ/プローブが追加で走り、クリック1回でエクスポートが2回実行されてしまう。 */}
            {/* ★上限超過のときは <a> ではなく <button disabled> に差し替える★(指示書AI 段階8)—
                ★`<a>` に `disabled` 属性は無く、付けても押せてしまう★。
                `pointer-events: none` で隠すのも不可(キーボード操作では辿れてしまう)。
                ★理由は下の `.csvNote` に必ず出すこと★ — 押せないボタンだけを置くと
                不具合と区別がつかない(D-053 がUIを削除した理由と同じ)。
                ★期間の誤り・読み込み中も同じ <button disabled>(`csvUnavailable`)★ */}
            {csvBlocked || csvUnavailable ? (
              <button
                type="button"
                className={styles.csvBtnDisabled}
                disabled
                aria-describedby="csv-note"
              >
                {t("CSV")}
              </button>
            ) : (
              <a
                className={styles.csvBtn}
                href={`/api/clean-logs/export?${queryString}`}
                download
              >
                {t("CSV")}
              </a>
            )}
          </div>
        </div>
        {/* ★CSVは表示中のページではなく全件★ —
            app/api/clean-logs/export/route.ts は page / per_page を無視する契約なので、
            href に `page` が混ざっても動作は変わらない(リンクを分岐させないこと)。
            ★上限を超えているときは、同じ場所を「出せない理由」に差し替える★
            (注記と理由を上下に並べると、どちらが今の状態なのか分からなくなる) */}
        <div
          id="csv-note"
          className={csvBlocked ? styles.csvNoteBlocked : styles.csvNote}
        >
          {csvBlocked
            ? t(
                "この期間だとCSV出力できません({total}件/上限{max}件)。期間を短くするか、店舗で絞り込んでください。"
              )
                .replace("{total}", String(total))
                .replace("{max}", String(MAX_CSV_ROWS))
            : t(
                "CSVは絞り込み条件に一致する全件を出力します(表示中のページだけではありません)"
              )}
        </div>

        <div className={styles.tableWrap}>
          {loading && <div className={styles.loading}>{t("読み込み中…")}</div>}
          {/* サーバーの文言は辞書に無ければ原文のまま */}
          {!loading && error && <div className={styles.error}>{t(error)}</div>}
          {!loading && !error && (
            <CleanLogTable
              rows={items}
              expandedKey={expandedKey}
              adviceByKey={adviceByKey}
              adviceLoadingKey={adviceLoadingKey}
              adviceFailedKeys={adviceFailedKeys}
              showGradeInfo={showGradeInfo}
              onToggleGradeInfo={() => setShowGradeInfo((v) => !v)}
              onToggleRow={toggleRow}
              onOpenAreas={openAreas}
              sort={sort}
              onSort={changeSort}
            />
          )}
        </div>

        {/* ★件数表示に `items.length` を使わないこと★ —
            ローディング中や取得失敗で items が空になり、件数だけ0に見える */}
        <Pagination
          total={total}
          page={page}
          perPage={PAGINATION.defaultPerPage}
          onPageChange={setPage}
        />
      </div>

      {areaTarget && (
        <AreaModal
          facilityName={areaTarget.facilityName}
          dateLabel={areaTarget.dateLabel}
          filter={areaFilter}
          onChangeFilter={setAreaFilter}
          data={areaData}
          loading={areaLoading}
          error={areaError}
          onClose={() => setAreaTarget(null)}
        />
      )}
    </div>
  );
}