/**
* 分類(ホールディングス → 屋号)のカスケード絞り込み。★画面側ロジックの土台★
* (指示書AK 段階7 の下準備)。
*
* 5画面(清掃詳細ログ / アラート / レポート / D-Secログ / D-Secアラート)が
* 同じ2つの `<select>` を持つ(モック :556 / :610 / :669 / :836 / :923)。
* ★選択肢の組み立て・カスケードの絞り込み・クエリパラメータ化をここ1箇所に置く★ —
* 画面ごとに写し取ると「どれか1つだけ挙動が違う」状態が生まれても誰も気づけない
* (`lib/api/master-id.ts` の冒頭と同じ考え方)。
*
* ★React に依存させないこと★ — 選択肢の計算はすべて純粋関数にしてある。
* `lib/hooks/use-classification-filter.ts` が state と取得タイミングだけを受け持つ。
* テスト環境は `environment: "node"`(`vitest.config.ts`)で DOM が無く、
* React のレンダリングはテストできない。★判断の本体を純粋関数側に置いてあるので、
* 絞り込みの規則そのものはテストで固定できる★。
*
* ★★往復を増やさない設計★★(指示書AK 10-2 は `GET /customers?group_id=X` と書いているが、
* ★このモジュールはマウント時の2本(`/groups` と `/customers`)だけに抑えている★):
* - `/customers` は ★ページングしない★(`lib/masters/query.ts` 冒頭)。全件が1回で取れる
* - 返る形は `{ id, customer_name, group_id }` の3項目だけで、P1 の件数(屋号は数件)では
* 全件取っても傘下だけ取っても実質同じ大きさ
* - サーバ側の絞り込みは `filterCustomersByGroup()` そのもの。★同じ関数を手元で呼べば
* 同じ結果になる★ので、ホールディングスを変えるたびに往復しても得るものが無い
* - 往復すると「屋号セレクトだけ一瞬空になる」状態と、
* 連続操作時の応答の追い越し(後から来た古い応答で上書き)を自前で捌く必要が出る
* ★屋号が数百件規模になったら、この判断を測り直すこと★(指示書AK §12-2 の200件の壁と同じ)。
*/
import { apiFetch } from "@/lib/api/client";
import { filterCustomersByGroup } from "@/lib/masters/query";
import type { Customer, Group, ListResponse } from "@/lib/types";
/**
* 「全ホールディングス」「全顧客」を表す `<option>` の値。★空文字★
*
* `"0"` や `"all"` にしないこと。0 は「ホールディングスなし」のセンチネル(D-011)で、
* ★API に漏らすと「グループ0」という存在しないグループで絞り込みが走る★(落とし穴3)。
* 空文字は `parseMasterIdParam()` が「未指定」として受ける形でもある。
*/
export const ALL_OPTION_VALUE = "";
/**
* 「全件」の見出し語。★`lib/i18n.ts` のキー(日本語原文)そのもの★ —
* 呼び出し側が `t()` を通して使う(このモジュールは React にも i18n にも依存しない)。
*/
export const ALL_GROUPS_LABEL = "全ホールディングス";
export const ALL_CUSTOMERS_LABEL = "全顧客";
/** `<option value={value}>{label}</option>` にそのまま渡せる形 */
export interface MasterOption {
/** `ALL_OPTION_VALUE`(空文字)= 絞り込まない */
value: string;
/** 表示文言。★「全件」の行だけは i18n キーなので呼び出し側が `t()` を通す★ */
label: string;
}
/** 画面が持つ「いま選ばれている分類」。★`null` = 絞り込まない★ */
export interface ClassificationSelection {
groupId: number | null;
customerId: number | null;
}
/** 初期値・リセット値。★毎回新しいオブジェクトを作らない★(state の無駄な更新を避ける) */
export const EMPTY_SELECTION: ClassificationSelection = {
groupId: null,
customerId: null,
};
/** `/groups` と `/customers` の取得結果 */
export interface ClassificationMasters {
/**
* ★取得に成功したか★。`false` なら絞り込みUIを出さない
* (指示書AK 10-3 / 落とし穴11。★空の `<select>` は D-053 で消した
* 「押しても何も起きないUI」への逆戻り★)。
*/
ok: boolean;
groups: Group[];
customers: Customer[];
}
/** 取得に失敗したときの値。★`ok: false` でも配列は空配列★(呼び出し側に null 分岐を作らせない) */
export const UNAVAILABLE_MASTERS: ClassificationMasters = {
ok: false,
groups: [],
customers: [],
};
/**
* `/groups` と `/customers` を1回ずつ取る。★並列★
*
* ★例外を投げない★ — 失敗は `ok: false` で返す。分類マスタが取れないことは
* 「絞り込みUIを出さない」という表示上の判断であって、画面全体のエラーではない
* (一覧本体の取得失敗は画面側が別途エラー表示する)。
*
* ★`fetch` を直書きしないこと★(`lib/api/client.ts` の冒頭)。
*/
export async function loadClassificationMasters(): Promise<ClassificationMasters> {
try {
const [groups, customers] = await Promise.all([
apiFetch<ListResponse<Group>>("/api/groups"),
apiFetch<ListResponse<Customer>>("/api/customers"),
]);
return { ok: true, groups: groups.items, customers: customers.items };
} catch {
// 2本のうち1本でも失敗したら絞り込みUI自体を出さない。
// 片方だけ出すと「ホールディングスは選べるのに屋号が空」という半端な状態になる。
return UNAVAILABLE_MASTERS;
}
}
/**
* 絞り込みUIを出してよいか。
*
* ★屋号が0件なら出さない★ — 出しても「全屋号」しか並ばず、
* ★押しても何も起きないUI★になる(落とし穴11)。
* ホールディングスが0件でも屋号があれば屋号だけで絞れるので、
* その判断は `hasGroupChoices()` に分けてある。
*/
export function isClassificationAvailable(
masters: ClassificationMasters
): boolean {
return masters.ok && masters.customers.length > 0;
}
/**
* ホールディングスのセレクトを出してよいか。
* ★0件なら「全ホールディングス」しか並ばないので出さない★(同上)。
*/
export function hasGroupChoices(masters: ClassificationMasters): boolean {
return masters.ok && masters.groups.length > 0;
}
/**
* ホールディングスの選択肢。先頭が「全ホールディングス」。
*
* ★並べ替えはしない★ — `/groups` が id 昇順で返す契約(`d-watch-api.yaml`)で、
* `localeCompare` で並べ直すと `USE_FIXTURES` の true/false で順序が変わる
* (`lib/masters/query.ts` の `sortGroups()` の JSDoc と同じ理由)。
*/
export function buildGroupOptions(
groups: Group[],
allLabel: string
): MasterOption[] {
return [
{ value: ALL_OPTION_VALUE, label: allLabel },
...groups.map((g) => ({ value: String(g.id), label: g.group_name })),
];
}
/**
* 屋号の選択肢。先頭が「全屋号」。
*
* ★`groupId` が指定されていればその傘下だけ★ — 絞り込みの規則は
* `filterCustomersByGroup()`(サーバ側と同じ関数)に任せる。
* ★未指定(`null`)と「該当0件」を取り違えないこと★は、その関数が守っている。
*/
export function buildCustomerOptions(
customers: Customer[],
groupId: number | null,
allLabel: string
): MasterOption[] {
return [
{ value: ALL_OPTION_VALUE, label: allLabel },
...filterCustomersByGroup(customers, groupId).map((c) => ({
value: String(c.id),
label: c.customer_name,
})),
];
}
/**
* `<select>` の値を ID に戻す。
*
* ★`Number.parseInt()` を使わないこと★ — `"1abc"` が `1` として通る(D-123。落とし穴7)。
* ★`0` も弾く★(ホールディングスなしのセンチネル。D-011)。
* 想定外の値は ★`null`(絞り込まない)★ に倒す。
*/
export function parseOptionValue(raw: string): number | null {
const value = raw.trim();
if (!/^\d+$/.test(value)) return null;
const n = Number(value);
if (!Number.isSafeInteger(n) || n < 1) return null;
return n;
}
/**
* カスケードの整合を取る。★ホールディングスを変えたら、傘下でない屋号の選択を外す★
*
* これをしないと「ホールディングスA + A傘下でない屋号」という組み合わせが API に飛び、
* AND 条件(`d-watch-api.yaml` の `GroupId`)で ★必ず0件★ になる。
* 画面上は屋号セレクトにその屋号が並んでいないのに条件だけ残る状態で、
* ★「0件ヒット」なのか「絞り込みが壊れている」のか誰も判別できない★。
*
* ★`customers` が空(=マスタ未取得)のときは何もしない★ —
* 「傘下に居ないから外す」と「まだ一覧を知らない」を取り違えないため。
*/
export function normalizeSelection(
selection: ClassificationSelection,
customers: Customer[]
): ClassificationSelection {
if (selection.groupId === null || selection.customerId === null) {
return selection;
}
if (customers.length === 0) return selection;
const inGroup = filterCustomersByGroup(customers, selection.groupId).some(
(c) => c.id === selection.customerId
);
if (inGroup) return selection;
return { groupId: selection.groupId, customerId: null };
}
/**
* 確定済みの選択をクエリパラメータに載せる。★`params` を破壊的に更新して返す★
*
* ★`null` のときはキー自体を付けない★ — `?group_id=` でも API は未指定として扱うが
* (`parseMasterIdParam()`)、URL に空のキーが並ぶと `useMemo` のキーが無駄に変わる。
* ★1未満の値は載せない★(0 を漏らさない。落とし穴3)。
*/
export function appendClassificationParams(
params: URLSearchParams,
selection: ClassificationSelection
): URLSearchParams {
if (selection.groupId !== null && selection.groupId >= 1) {
params.set("group_id", String(selection.groupId));
}
if (selection.customerId !== null && selection.customerId >= 1) {
params.set("customer_id", String(selection.customerId));
}
return params;
}