"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>
);
}