Files
legal-ai/web-ui/src/components/precedents/plans-review-panel.tsx
Chaim 5d75d36e2a
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 9s
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>
2026-06-14 15:27:36 +00:00

359 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { Check, X, Edit2, AlertTriangle, Plus, GitMerge, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import {
usePlansPending, usePlanDuplicates, useUpsertPlan, useUpdatePlan,
useReviewPlan, useMergePlans, type Plan, type PlanEdit,
} from "@/lib/api/plans";
/* Strip bidi marks (mirror of the halacha panel's cleanCitation). */
function clean(s: string | null | undefined): string {
if (!s) return "—";
return s.replace(/[--]/g, "").trim();
}
function fmtDate(iso: string | null | undefined): string {
if (!iso) return "—";
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
if (!m) return iso;
return `${Number(m[3])}.${Number(m[2])}.${m[1]}`;
}
type EditForm = {
plan_number: string;
display_name: string;
plan_type: string;
gazette_date: string;
yalkut_number: string;
purpose: string;
};
const BLANK: EditForm = {
plan_number: "", display_name: "", plan_type: "",
gazette_date: "", yalkut_number: "", purpose: "",
};
function toForm(p: Plan): EditForm {
return {
plan_number: p.plan_number,
display_name: p.display_name,
plan_type: p.plan_type,
gazette_date: p.gazette_date ?? "",
yalkut_number: p.yalkut_number,
purpose: p.purpose,
};
}
/* Client-side mirror of db.format_plan_citation — deterministic preview so the
* chair sees exactly what block-tet will cite (validity is never free-typed). */
function previewCitation(f: EditForm): string {
const name = f.display_name.trim() || f.plan_number.trim();
if (!name) return "—";
let s = name;
const d = fmtDate(f.gazette_date);
if (d !== "—") {
s = `${name} פורסמה למתן תוקף ברשומות ביום ${d}`;
if (f.yalkut_number.trim()) s += `, י${f.yalkut_number.trim()}`;
}
if (f.purpose.trim()) s += `${f.purpose.trim()}`;
return s.endsWith(".") ? s : s + ".";
}
// ─────────────────────────────────────────────────────────────────────────────
export function PlansReviewPanel() {
const { data, isLoading, isError } = usePlansPending();
const [adding, setAdding] = useState(false);
if (isLoading) {
return (
<div className="space-y-4">
{[0, 1, 2].map((i) => <Skeleton key={i} className="h-40 w-full rounded-lg" />)}
</div>
);
}
if (isError) {
return <p className="text-danger text-sm">שגיאה בטעינת תור-התכניות.</p>;
}
const plans = data?.items ?? [];
return (
<div className="space-y-5">
<div className="flex items-start justify-between gap-4 flex-wrap">
<p className="text-ink-soft text-sm leading-relaxed max-w-3xl">
מרשם-התכניות: זהות + תוקף קנוניים של כל תב&quot;ע, בשימוש חוזר בין תיקים.
רק תכנית מאושרת מצוטטת בבלוק ט; התוקף נכתב בנוסח אחיד{" "}
<span className="text-gold-deep font-semibold">דטרמיניסטי</span>
תאריך-הרשומות ומס&apos; הילקוט לעולם אינם מומצאים.
</p>
<Button
onClick={() => setAdding((v) => !v)}
className="bg-navy text-white hover:bg-navy-soft shrink-0"
>
<Plus className="size-4" /> הוספת תכנית ידנית
</Button>
</div>
{adding && (
<PlanForm
title="הוספת תכנית"
subtitle="מספר-התכנית מנורמל אוטומטית. ברירת-מחדל: מאושר (קלט-יו״ר)."
initial={BLANK}
mode="add"
onClose={() => setAdding(false)}
/>
)}
{plans.length === 0 ? (
<div className="rounded-lg border border-rule bg-surface shadow-sm p-6 text-center text-ink-muted text-sm">
אין תכניות הממתינות לאישור.
</div>
) : (
plans.map((p) => <PlanCard key={p.id} plan={p} />)
)}
</div>
);
}
// ─── one plan card ────────────────────────────────────────────────────────────
function PlanCard({ plan }: { plan: Plan }) {
const [editing, setEditing] = useState(false);
const review = useReviewPlan();
const merge = useMergePlans();
const { data: dups } = usePlanDuplicates(plan.id);
const duplicates = dups?.items ?? [];
const noDate = !plan.gazette_date;
async function decide(status: "approved" | "rejected") {
try {
await review.mutateAsync({ id: plan.id, status });
toast.success(status === "approved" ? "התכנית אושרה" : "התכנית נדחתה");
} catch {
toast.error("שגיאה בעדכון התכנית");
}
}
async function foldIn(sourceId: string, label: string) {
try {
await merge.mutateAsync({ sourceId, targetId: plan.id });
toast.success(`מוזג: ${label}${plan.display_name || plan.plan_number}`);
} catch {
toast.error("שגיאה במיזוג");
}
}
if (editing) {
return (
<PlanForm
title={`עריכת תכנית — ${plan.display_name || plan.plan_number}`}
subtitle="שינוי מספר-התכנית מנורמל אוטומטית; אם הוא מתנגש בתכנית קיימת — תופנה למיזוג."
initial={toForm(plan)}
mode="edit"
planId={plan.id}
onClose={() => setEditing(false)}
/>
);
}
return (
<div className="rounded-lg border border-rule bg-gold-wash shadow-sm p-4 space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<Badge className="bg-gold text-white rounded">תכנית</Badge>
<span className="text-navy font-bold text-[0.95rem]">
{plan.display_name || plan.plan_number}
</span>
{plan.plan_type && (
<span className="rounded-full bg-info-bg text-[#365070] text-[0.72rem] font-semibold px-2.5 py-0.5">
{plan.plan_type}
</span>
)}
{plan.source_case_number && (
<span className="ms-auto text-ink-muted text-[0.72rem]">
מקור: {plan.source_case_number}
</span>
)}
</div>
{duplicates.length > 0 && (
<div className="rounded-md border border-[#e8d3a8] bg-warn-bg px-3 py-2 space-y-1.5">
<div className="flex items-center gap-2 text-[0.78rem] text-gold-deep">
<AlertTriangle className="size-4 shrink-0" />
<span>
כפילות אפשרית ייתכן שאותה תכנית כבר במרשם בצורה אחרת. מזג כדי לא
לפצל תכנית אחת לשתי רשומות.
</span>
</div>
{duplicates.map((d) => (
<div key={d.id} className="flex items-center gap-2 flex-wrap ps-6">
<span className="text-[0.78rem] text-navy font-medium">
{d.display_name || d.plan_number}
</span>
{d.match_reason && (
<span className="text-[0.68rem] text-ink-muted">({d.match_reason})</span>
)}
<Button
size="sm" variant="outline"
className="ms-auto h-7 border-gold text-gold-deep text-[0.72rem]"
disabled={merge.isPending}
onClick={() => foldIn(d.id, d.display_name || d.plan_number)}
>
<GitMerge className="size-3.5" /> מזג לכאן
</Button>
</div>
))}
</div>
)}
<div>
<div className="text-[0.72rem] text-ink-muted font-semibold mb-1.5">
משפט-הציטוט הקנוני (כפי שייכתב בבלוק ט)
</div>
<div className="border-s-[3px] border-gold bg-white/65 rounded-e px-3.5 py-2.5 text-[0.92rem] leading-relaxed font-medium">
{clean(plan.citation_formatted)}
</div>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[0.78rem] text-ink-soft">
<div><span className="text-ink-muted">תאריך רשומות:</span> {fmtDate(plan.gazette_date)}</div>
<div><span className="text-ink-muted">ילקוט פרסומים:</span> {plan.yalkut_number || "—"}</div>
{plan.plan_type && <div><span className="text-ink-muted">סוג:</span> {plan.plan_type}</div>}
</div>
{noDate && (
<div className="text-[0.78rem] text-danger font-medium">
חסר תאריך-תוקף תצוטט ללא תוקף עד השלמה.
</div>
)}
<div className="flex items-center gap-2 flex-wrap justify-end pt-2.5 border-t border-rule-soft">
<Button
variant="ghost" size="sm" className="me-auto text-ink-muted"
onClick={() => setEditing(true)}
>
<Edit2 className="size-4" /> {noDate ? "השלם תוקף" : "ערוך / תקן"}
</Button>
<Button
variant="outline" size="sm" className="border-[#e3c4c4] text-danger"
disabled={review.isPending}
onClick={() => decide("rejected")}
>
<X className="size-4" /> דחה
</Button>
<Button
size="sm" className="bg-gold text-navy hover:bg-gold-deep hover:text-white"
disabled={review.isPending}
onClick={() => decide("approved")}
>
<Check className="size-4" /> {noDate ? "אשר (ללא תוקף)" : "אשר"}
</Button>
</div>
</div>
);
}
// ─── add / edit form (inline) ───────────────────────────────────────────────
function PlanForm({
title, subtitle, initial, mode, planId, onClose,
}: {
title: string;
subtitle: string;
initial: EditForm;
mode: "add" | "edit";
planId?: string;
onClose: () => void;
}) {
const [f, setF] = useState<EditForm>(initial);
const upsert = useUpsertPlan();
const update = useUpdatePlan();
const busy = upsert.isPending || update.isPending;
function set<K extends keyof EditForm>(k: K, v: string) {
setF((prev) => ({ ...prev, [k]: v }));
}
async function save() {
if (!f.plan_number.trim()) {
toast.error("חובה מספר-תכנית");
return;
}
try {
if (mode === "edit" && planId) {
const patch: PlanEdit = {
plan_number: f.plan_number, display_name: f.display_name,
plan_type: f.plan_type, gazette_date: f.gazette_date,
yalkut_number: f.yalkut_number, purpose: f.purpose,
};
await update.mutateAsync({ id: planId, patch });
} else {
await upsert.mutateAsync({
plan_number: f.plan_number, display_name: f.display_name,
plan_type: f.plan_type, gazette_date: f.gazette_date,
yalkut_number: f.yalkut_number, purpose: f.purpose,
review_status: "approved",
});
}
toast.success(mode === "edit" ? "התכנית נשמרה" : "התכנית נוספה");
onClose();
} catch (e) {
// surface the backend's collision/validation message (no silent failure)
const msg = (e as { body?: { detail?: string } })?.body?.detail;
toast.error(msg || "שגיאה בשמירה");
}
}
const field = (
k: keyof EditForm, label: string, hint?: string, full = false,
) => (
<div className={full ? "col-span-2" : ""}>
<Label className="text-[0.72rem] text-ink-muted mb-1 block">{label}</Label>
<Input value={f[k]} onChange={(e) => set(k, e.target.value)} />
{hint && <p className="text-[0.66rem] text-ink-muted mt-1">{hint}</p>}
</div>
);
return (
<div className="rounded-lg border border-rule bg-surface shadow-sm p-4 space-y-3">
<div>
<h3 className="text-navy text-sm font-bold m-0">{title}</h3>
<p className="text-ink-muted text-xs mt-0.5">{subtitle}</p>
</div>
<div className="grid grid-cols-2 gap-3">
{field("plan_number", "מספר תכנית", 'מזהה בלבד (ללא המילה "תכנית")')}
{field("display_name", "שם תצוגה")}
{field("gazette_date", "תאריך פרסום ברשומות", "YYYY-MM-DD")}
{field("yalkut_number", 'מס\' ילקוט פרסומים (י"פ)')}
{field("plan_type", "סוג תכנית", "ארצית / מחוזית / מקומית / מפורטת / כוללנית")}
{field("purpose", "ייעוד (משפט אחד)", undefined, true)}
</div>
<div className="rounded-md border border-dashed border-gold bg-gold-wash px-3.5 py-2.5">
<span className="block text-[0.66rem] text-gold-deep font-bold mb-1">
תצוגה מקדימה כך ייכתב בבלוק ט:
</span>
<span className="text-[0.88rem] leading-relaxed">{previewCitation(f)}</span>
</div>
<div className="flex items-center gap-2 justify-end">
<Button variant="ghost" size="sm" className="me-auto text-ink-muted" onClick={onClose}>
ביטול
</Button>
<Button
size="sm" className="bg-gold text-navy hover:bg-gold-deep hover:text-white"
disabled={busy} onClick={save}
>
<Save className="size-4" /> שמור
</Button>
</div>
</div>
);
}