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),
});
}

View File

@@ -2947,9 +2947,11 @@ export interface paths {
* Operations Drain Toggle
* @description Switch a cron drain on/off (the 'startup type' in the services panel).
*
* Written straight to drain_controls — no host roundtrip; the drain reads the
* flag at startup and no-ops when disabled (pm2 cron_restart can't be trusted
* to stay stopped).
* Written to drain_controls so the drain no-ops at its NEXT startup (pm2
* cron_restart can't be trusted to stay stopped). On disable we ALSO stop any
* currently-running process immediately via the host pm2 bridge — the DB flag
* alone wouldn't halt a drain mid-run. Best-effort: a bridge failure doesn't
* fail the toggle (the supervisor stops it on its next tick as a backstop).
*/
post: operations["operations_drain_toggle_api_operations_drains__name__disabled_post"];
delete?: never;
@@ -3087,6 +3089,33 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/operations/agents/migrate-adapter": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Operations Agent Migrate Adapter
* @description Migrate an agent (or 'all') to a target adapter, in both companies.
*
* The migration is host-side (it needs the host filesystem — generated
* instruction copies, the gemini settings file — and the embedded board DB),
* so this proxies scripts/migrate_agent_adapter.py through the court-fetch host
* bridge, Bearer-authenticated exactly like the pm2 controls. The script's exit
* code + stdout/stderr are relayed verbatim so the dashboard can show preflight
* warnings (a non-zero --check is a refusal to render, not a transport error).
*/
post: operations["operations_agent_migrate_adapter_api_operations_agents_migrate_adapter_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/digests/{digest_id}": {
parameters: {
query?: never;
@@ -3428,6 +3457,111 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/plans": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Plans List
* @description List the plans registry; filter by review_status (queue) or fuzzy q (search).
*/
get: operations["plans_list_api_plans_get"];
put?: never;
/**
* Plan Create
* @description Manual chair add/upsert (idempotent on normalized plan_number).
*/
post: operations["plan_create_api_plans_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/plans/{plan_id}/duplicates": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Plan Duplicates
* @description Near-duplicate candidates for a plan — for the chair to merge (G10, no auto-merge).
*/
get: operations["plan_duplicates_api_plans__plan_id__duplicates_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/plans/{plan_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Plan Get */
get: operations["plan_get_api_plans__plan_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Plan Edit
* @description Edit/fix a plan by id (chair). Refuses a number collision (→ merge instead).
*/
patch: operations["plan_edit_api_plans__plan_id__patch"];
trace?: never;
};
"/api/plans/{plan_id}/review": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Plan Review
* @description Chair gate (G10): approve / reject / reset.
*/
post: operations["plan_review_api_plans__plan_id__review_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/plans/merge": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Plan Merge
* @description Fold source plan into target (chair-initiated dedup); source is deleted.
*/
post: operations["plan_merge_api_plans_merge_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/missing-precedents": {
parameters: {
query?: never;
@@ -3556,6 +3690,22 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** AdapterMigrationRequest */
AdapterMigrationRequest: {
/** Action */
action: string;
/** Agent */
agent?: string | null;
/** To */
to?: string | null;
/** Model */
model?: string | null;
/**
* Relax Tools
* @default false
*/
relax_tools: boolean;
};
/** AgentCommentRequest */
AgentCommentRequest: {
/** Body */
@@ -4428,6 +4578,90 @@ export interface components {
*/
appeal_type: string;
};
/** PlanEditRequest */
PlanEditRequest: {
/** Plan Number */
plan_number: string;
/**
* Display Name
* @default
*/
display_name: string;
/**
* Plan Type
* @default
*/
plan_type: string;
/**
* Gazette Date
* @default
*/
gazette_date: string;
/**
* Yalkut Number
* @default
*/
yalkut_number: string;
/**
* Purpose
* @default
*/
purpose: string;
/** Aliases */
aliases?: string[] | null;
};
/** PlanMergeRequest */
PlanMergeRequest: {
/** Source Id */
source_id: string;
/** Target Id */
target_id: string;
};
/** PlanReviewRequest */
PlanReviewRequest: {
/** Review Status */
review_status: string;
};
/** PlanUpsertRequest */
PlanUpsertRequest: {
/** Plan Number */
plan_number: string;
/**
* Display Name
* @default
*/
display_name: string;
/**
* Plan Type
* @default
*/
plan_type: string;
/**
* Gazette Date
* @default
*/
gazette_date: string;
/**
* Yalkut Number
* @default
*/
yalkut_number: string;
/**
* Purpose
* @default
*/
purpose: string;
/**
* Review Status
* @default approved
*/
review_status: string;
/**
* Aliases
* @default []
*/
aliases: string[];
};
/** PrecedentCreateRequest */
PrecedentCreateRequest: {
/** Quote */
@@ -9354,6 +9588,39 @@ export interface operations {
};
};
};
operations_agent_migrate_adapter_api_operations_agents_migrate_adapter_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AdapterMigrationRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
digest_get_api_digests__digest_id__get: {
parameters: {
query?: never;
@@ -10016,6 +10283,237 @@ export interface operations {
};
};
};
plans_list_api_plans_get: {
parameters: {
query?: {
review_status?: string;
q?: string;
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
plan_create_api_plans_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PlanUpsertRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
plan_duplicates_api_plans__plan_id__duplicates_get: {
parameters: {
query?: never;
header?: never;
path: {
plan_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
plan_get_api_plans__plan_id__get: {
parameters: {
query?: never;
header?: never;
path: {
plan_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
plan_edit_api_plans__plan_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
plan_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PlanEditRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
plan_review_api_plans__plan_id__review_post: {
parameters: {
query?: never;
header?: never;
path: {
plan_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PlanReviewRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
plan_merge_api_plans_merge_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PlanMergeRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
missing_precedents_list_api_missing_precedents_get: {
parameters: {
query?: {