"use client";
/**
* 分類(ホールディングス → 屋号)のカスケード絞り込みを5画面で共有するフック。
* (指示書AK 段階7 の下準備。★この時点では画面に組み込まない★ — 組み込みは後続担当)
*
* 絞り込みの規則そのものは `lib/masters/options.ts`(純粋関数)にある。
* ここが持つのは ★マスタの取得タイミング★ と ★入力中(draft)の選択★ だけ。
*
* ------------------------------------------------------------------------
* ★★何をフックが持ち、何を画面が持つか★★
* ------------------------------------------------------------------------
* | 持ち物 | どこが持つ | なぜ |
* |---|---|---|
* | `/groups` `/customers` の取得結果 | ★フック★ | 5画面で同じ。画面ごとに書くと取得条件がずれる |
* | 絞り込みUIを出してよいか(`available`) | ★フック★ | 取得の成否を知っているのはフックだけ |
* | 選択肢の配列(`groupOptions` / `customerOptions`) | ★フック★ | カスケードの絞り込み込み |
* | ★入力中の選択(draft)★ | ★フック★ | セレクトの `value` そのもの。カスケードの整合(傘下外の屋号を外す)をここで守る |
* | ★確定した選択★ | ★★画面★★ | 「検索」ボタンの有無・位置・他の条件との同時確定は画面ごとに違う |
* | クエリ文字列の組み立て | ★画面★ | 期間・施設名など他の条件と混ぜる場所が画面だから |
*
* ★draft と確定を分ける理由★は `ReportsScreen.tsx`(:84-92)と同じ —
* ★1操作ごとに API を叩かないため★。画面側は「検索」ボタンで
* `setConfirmed(classification.draft)` と写し取り、確定値だけを
* `appendClassificationParams()` でクエリに載せる。
*
* ```tsx
* const { t } = useTheme();
* const classification = useClassificationFilter(t);
* const [confirmed, setConfirmed] = useState(EMPTY_SELECTION);
*
* const query = useMemo(() => {
* const params = new URLSearchParams();
* params.set("date", date);
* return appendClassificationParams(params, confirmed).toString();
* }, [date, confirmed]);
*
* const handleSearch = (e: React.FormEvent) => {
* e.preventDefault();
* setConfirmed(classification.draft); // ← ここで初めて確定する
* };
*
* {classification.available && (
* <>
* {classification.hasGroups && (
* <select
* value={String(classification.draft.groupId ?? ALL_OPTION_VALUE)}
* onChange={(e) => classification.selectGroup(e.target.value)}
* >
* {classification.groupOptions.map((o) => (
* <option key={o.value} value={o.value}>{o.label}</option>
* ))}
* </select>
* )}
* <select
* value={String(classification.draft.customerId ?? ALL_OPTION_VALUE)}
* onChange={(e) => classification.selectCustomer(e.target.value)}
* >
* {classification.customerOptions.map((o) => (
* <option key={o.value} value={o.value}>{o.label}</option>
* ))}
* </select>
* </>
* )}
* ```
*
* ★`t` を引数で受ける理由★ — `useTheme()` を直接呼ぶと `lib/` が
* `components/theme/ThemeProvider` に依存する(依存の向きが逆になる)。
* 画面は既に `useTheme()` を呼んでいるので、`t` を1つ渡すだけで済む。
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ALL_CUSTOMERS_LABEL,
ALL_GROUPS_LABEL,
buildCustomerOptions,
buildGroupOptions,
EMPTY_SELECTION,
hasGroupChoices,
isClassificationAvailable,
loadClassificationMasters,
normalizeSelection,
parseOptionValue,
UNAVAILABLE_MASTERS,
type ClassificationMasters,
type ClassificationSelection,
type MasterOption,
} from "@/lib/masters/options";
/** 取得の状態。`"loading"` の間は ★まだ空のセレクトを出さない★(`available` が false) */
export type ClassificationStatus = "loading" | "ready" | "unavailable";
export interface ClassificationFilter {
status: ClassificationStatus;
/**
* ★絞り込みUIを出してよいか★。取得中・取得失敗・屋号0件はすべて `false`
* (指示書AK 10-3 / 落とし穴11)。
*/
available: boolean;
/**
* ホールディングスのセレクトを出してよいか。
* ★0件なら「全ホールディングス」しか並ばないので出さない★。
* `available` が false のときは常に false。
*/
hasGroups: boolean;
/** ★入力中★ の選択。セレクトの `value` に使う */
draft: ClassificationSelection;
groupOptions: MasterOption[];
/** ★`draft.groupId` の傘下だけ★に絞られた屋号 */
customerOptions: MasterOption[];
/** `<select onChange>` からの生値を受ける。★屋号の選択が傘下から外れたら自動で外す★ */
selectGroup: (value: string) => void;
selectCustomer: (value: string) => void;
/** draft を「全ホールディングス / 全屋号」に戻す(確定値は画面側が戻す) */
reset: () => void;
/** 取得に失敗したあとの再試行 */
reload: () => void;
}
export function useClassificationFilter(
t: (text: string) => string
): ClassificationFilter {
const [masters, setMasters] =
useState<ClassificationMasters>(UNAVAILABLE_MASTERS);
const [status, setStatus] = useState<ClassificationStatus>("loading");
const [draft, setDraft] = useState<ClassificationSelection>(EMPTY_SELECTION);
// 再取得のトリガ。値そのものに意味はなく、effect を走らせ直すためだけに使う
const [reloadToken, setReloadToken] = useState(0);
useEffect(() => {
let cancelled = false;
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch 起点のマスタ取得
setStatus("loading");
void loadClassificationMasters().then((loaded) => {
// 画面を離れたあとの setState を避ける(マスタ取得は画面本体の fetch より遅れうる)
if (cancelled) return;
setMasters(loaded);
setStatus(loaded.ok ? "ready" : "unavailable");
// 取得できた一覧に照らして draft の整合を取り直す。
// ★マスタが未取得の間に draft が入ることは無い(UIを出していない)ので、
// ここは実質「何もしない」経路だが、reload 後に古い選択が残る場合に効く★
setDraft((prev) => normalizeSelection(prev, loaded.customers));
});
return () => {
cancelled = true;
};
}, [reloadToken]);
const available = status === "ready" && isClassificationAvailable(masters);
const hasGroups = available && hasGroupChoices(masters);
const groupOptions = useMemo(
() => buildGroupOptions(masters.groups, t(ALL_GROUPS_LABEL)),
[masters.groups, t]
);
const customerOptions = useMemo(
() =>
buildCustomerOptions(
masters.customers,
draft.groupId,
t(ALL_CUSTOMERS_LABEL)
),
[masters.customers, draft.groupId, t]
);
const selectGroup = useCallback(
(value: string) => {
const groupId = parseOptionValue(value);
setDraft((prev) =>
normalizeSelection({ ...prev, groupId }, masters.customers)
);
},
[masters.customers]
);
const selectCustomer = useCallback((value: string) => {
// 屋号は `customerOptions`(= 傘下だけ)から選ばれるので、
// ここでホールディングス側を触る必要はない。
setDraft((prev) => ({ ...prev, customerId: parseOptionValue(value) }));
}, []);
const reset = useCallback(() => setDraft(EMPTY_SELECTION), []);
const reload = useCallback(() => setReloadToken((n) => n + 1), []);
return {
status,
available,
hasGroups,
draft,
groupOptions,
customerOptions,
selectGroup,
selectCustomer,
reset,
reload,
};
}