/** * 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(`/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) { 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(`/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( `/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( `/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( `/api/plans/merge`, { method: "POST", body: { source_id: sourceId, target_id: targetId } }, ), onSuccess: () => invalidate(qc), }); }