feat(ui): פאנל אישור-תכניות — טאב /precedents + מרכז-אישורים (PR-B)
הטמעת ה-UI למרשם-התכניות אחרי אישור-עיצוב ב-Claude Design (מוקאפ 22-plans-review). - web-ui/src/lib/api/plans.ts: hooks (usePlansPending, usePlanDuplicates, useUpsertPlan, useUpdatePlan, useReviewPlan, useMergePlans) + טיפוס Plan מקומי. - plans-review-panel.tsx: כרטיס-תכנית עם משפט-הציטוט הקנוני (כפי שייכתב בבלוק ט), שדות-תוקף, סימון חוסר-תאריך, באנר "כפילות אפשרית → מזג לכאן" (find_similar_plans, מיזוג ידני — G10), עריכה/הוספה inline עם תצוגה-מקדימה חיה של הציטוט. - precedents/page.tsx: טאב "תכניות" + PlansPendingPill + deep-link tab=plans. - web/app.py: href קטגוריית-התכניות במרכז-האישורים → /precedents?tab=plans. - api:types: types.ts מחודש מ-openapi החי (5 נתיבי /api/plans). מרכז-האישורים (/approvals) מרנדר קטגוריות גנרית — קטגוריית-התכניות (PR-A) מופיעה אוטומטית. אימות: api:types ✓, tsc --noEmit ✓, lint exit=0 (ללא אזהרות חדשות). Invariants: G10 (אישור אנושי + מיזוג ידני) · INV-AH (ציטוט דטרמיניסטי, תצוגה-מקדימה תואמת format_plan_citation) · INV-IA (שער-אחד: טאב קיים + מרכז-אישורים, ללא עמוד חדש) · X6 (UI↔API, apiRequest + טיפוסים). עבר שער-העיצוב Claude Design (feedback_claude_design_gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
152
web-ui/src/lib/api/plans.ts
Normal file
152
web-ui/src/lib/api/plans.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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,
|
||||
pending: () => [...planKeys.all, "pending"] as const,
|
||||
duplicates: (id: string) => [...planKeys.all, "duplicates", id] as const,
|
||||
};
|
||||
|
||||
/** All plans awaiting the chair gate (G10). */
|
||||
export function usePlansPending(limit = 500) {
|
||||
return useQuery({
|
||||
queryKey: planKeys.pending(),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<{ items: Plan[]; count: number }>(
|
||||
`/api/plans?review_status=pending_review&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,
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user