feat(ui): פאנל אישור-תכניות — טאב /precedents + מרכז-אישורים (PR-B)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 9s

הטמעת ה-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:
2026-06-14 15:27:36 +00:00
parent 70ac888592
commit 5d75d36e2a
5 changed files with 1025 additions and 5 deletions

152
web-ui/src/lib/api/plans.ts Normal file
View 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),
});
}