טריגר 1 הידני: בטופס PlanForm, כפתור "משוך מ-מנהל-התכנון" — היו"ר מקליד מספר-תכנית, לוחץ, והשדות (שם/תאריך-רשומות/י"פ/סוג/ייעוד) מתמלאים מ-mavat דרך POST /api/plans/fetch (#292). היו"ר בודק ושומר — שער-היו"ר נשמר (שום שמירה אוטומטית). - plans.ts: useFetchPlan + PlanFetchResult. - PlanForm: כפתור עם spinner (~דקה, דפדפן חי), מילוי-שדות (מחליף בערך-mavat היכן שקיים, שומר ערך-יו"ר היכן ש-mavat ריק), קישור-מקור "מקור: מנהל-התכנון" בתצוגה-המקדימה (פרובננס INV-AH). עבר שער-עיצוב (מוקאפ 22-plans-review מאושר). ההוק ידני (לא תלוי types שנוצרים). tsc ✅ lint ✅ (0 errors). INV-AH: source_url מוצג; שדה-חסר ריק לא מומצא. G10: מילוי-טופס בלבד, שמירה דרך plan_upsert הקיים. G2: צורך את /api/plans/fetch (#292). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
185 lines
5.6 KiB
TypeScript
185 lines
5.6 KiB
TypeScript
/**
|
||
* Planning-schemes registry (מרשם-התכניות, V38) — typed API hooks.
|
||
*
|
||
* SSOT for a plan's identity + validity, reused across cases. LLM-extracted rows
|
||
* arrive pending_review; only approved validity feeds block-tet (INV-DM5/G10).
|
||
* Variant duplicates are surfaced (usePlanDuplicates), never auto-merged — the
|
||
* chair merges manually. Mirrors the precedent-library hook conventions.
|
||
*/
|
||
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { apiRequest } from "./client";
|
||
|
||
export type PlanReviewStatus = "pending_review" | "approved" | "rejected";
|
||
|
||
export type PlanDiscrepancy = {
|
||
field: string;
|
||
old: string;
|
||
new: string;
|
||
source_case_number?: string;
|
||
via?: string;
|
||
};
|
||
|
||
export type Plan = {
|
||
id: string;
|
||
plan_number: string;
|
||
display_name: string;
|
||
aliases: string[];
|
||
plan_type: string;
|
||
gazette_date: string | null; // ISO YYYY-MM-DD
|
||
yalkut_number: string;
|
||
purpose: string;
|
||
citation_formatted: string; // the deterministic block-tet sentence
|
||
review_status: PlanReviewStatus;
|
||
source_case_number: string;
|
||
source_document_id: string | null;
|
||
model_used: string;
|
||
discrepancies: PlanDiscrepancy[];
|
||
created_at: string;
|
||
updated_at: string;
|
||
/** Only present on a duplicate-candidate hit. */
|
||
match_reason?: string;
|
||
};
|
||
|
||
export const planKeys = {
|
||
all: ["plans"] as const,
|
||
list: () => [...planKeys.all, "list"] as const,
|
||
duplicates: (id: string) => [...planKeys.all, "duplicates", id] as const,
|
||
};
|
||
|
||
/**
|
||
* The whole registry in one fetch (every review_status). The panel filters
|
||
* client-side by status segment (ממתינים/מאושרות/כולן) and search — the
|
||
* registry is small, so one load gives accurate segment counts + instant
|
||
* filtering without per-keystroke round-trips. `limit` is a safety ceiling;
|
||
* if it is ever hit the panel surfaces a truncation note (no silent cap).
|
||
*/
|
||
export function usePlansAll(limit = 1000) {
|
||
return useQuery({
|
||
queryKey: planKeys.list(),
|
||
queryFn: ({ signal }) =>
|
||
apiRequest<{ items: Plan[]; count: number }>(
|
||
`/api/plans?limit=${limit}`,
|
||
{ signal },
|
||
),
|
||
staleTime: 5_000,
|
||
refetchOnMount: "always",
|
||
});
|
||
}
|
||
|
||
/** Near-duplicate candidates for a plan — surfaced for manual merge. */
|
||
export function usePlanDuplicates(planId: string, enabled = true) {
|
||
return useQuery({
|
||
queryKey: planKeys.duplicates(planId),
|
||
queryFn: ({ signal }) =>
|
||
apiRequest<{ items: Plan[]; count: number }>(
|
||
`/api/plans/${encodeURIComponent(planId)}/duplicates`,
|
||
{ signal },
|
||
),
|
||
enabled: enabled && !!planId,
|
||
staleTime: 10_000,
|
||
});
|
||
}
|
||
|
||
/** The candidate mavat returns for the form (INV-AH: every value carries source_url). */
|
||
export type PlanFetchResult = {
|
||
plan_number: string;
|
||
display_name: string;
|
||
plan_type: string;
|
||
purpose: string;
|
||
gazette_date: string; // ISO YYYY-MM-DD, "" if mavat doesn't expose it
|
||
yalkut_number: string;
|
||
yalkut_page: string;
|
||
source_url: string; // the mavat plan page
|
||
};
|
||
|
||
/**
|
||
* Pull a plan's identity + validity from mavat (מנהל התכנון) to prefill the form.
|
||
* Slow — drives a real browser on the host bridge (~½–1 min); surface a spinner.
|
||
*/
|
||
export function useFetchPlan() {
|
||
return useMutation({
|
||
mutationFn: (planNumber: string) =>
|
||
apiRequest<PlanFetchResult>(`/api/plans/fetch`, {
|
||
method: "POST",
|
||
body: { plan_number: planNumber },
|
||
}),
|
||
});
|
||
}
|
||
|
||
export type PlanUpsert = {
|
||
plan_number: string;
|
||
display_name?: string;
|
||
plan_type?: string;
|
||
gazette_date?: string; // ISO; "" = unknown
|
||
yalkut_number?: string;
|
||
purpose?: string;
|
||
review_status?: PlanReviewStatus;
|
||
aliases?: string[];
|
||
};
|
||
|
||
export type PlanEdit = {
|
||
plan_number: string;
|
||
display_name?: string;
|
||
plan_type?: string;
|
||
gazette_date?: string;
|
||
yalkut_number?: string;
|
||
purpose?: string;
|
||
aliases?: string[] | null;
|
||
};
|
||
|
||
function invalidate(qc: ReturnType<typeof useQueryClient>) {
|
||
qc.invalidateQueries({ queryKey: planKeys.all });
|
||
// the chair-pending aggregate (/approvals) carries a plans count (INV-IA2)
|
||
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
|
||
}
|
||
|
||
/** Manual chair add/upsert (idempotent on normalized plan_number). */
|
||
export function useUpsertPlan() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: (body: PlanUpsert) =>
|
||
apiRequest<Plan>(`/api/plans`, { method: "POST", body }),
|
||
onSuccess: () => invalidate(qc),
|
||
});
|
||
}
|
||
|
||
/** Edit/fix a plan by id (refuses a number collision → use merge). */
|
||
export function useUpdatePlan() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: ({ id, patch }: { id: string; patch: PlanEdit }) =>
|
||
apiRequest<Plan>(
|
||
`/api/plans/${encodeURIComponent(id)}`,
|
||
{ method: "PATCH", body: patch },
|
||
),
|
||
onSuccess: () => invalidate(qc),
|
||
});
|
||
}
|
||
|
||
/** Chair gate (G10): approve / reject / reset. */
|
||
export function useReviewPlan() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: ({ id, status }: { id: string; status: PlanReviewStatus }) =>
|
||
apiRequest<Plan>(
|
||
`/api/plans/${encodeURIComponent(id)}/review`,
|
||
{ method: "POST", body: { review_status: status } },
|
||
),
|
||
onSuccess: () => invalidate(qc),
|
||
});
|
||
}
|
||
|
||
/** Fold a source plan into a target (manual dedup); source is deleted. */
|
||
export function useMergePlans() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: ({ sourceId, targetId }: { sourceId: string; targetId: string }) =>
|
||
apiRequest<Plan>(
|
||
`/api/plans/merge`,
|
||
{ method: "POST", body: { source_id: sourceId, target_id: targetId } },
|
||
),
|
||
onSuccess: () => invalidate(qc),
|
||
});
|
||
}
|