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:
358
web-ui/src/components/precedents/plans-review-panel.tsx
Normal file
358
web-ui/src/components/precedents/plans-review-panel.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
"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">
|
||||
מרשם-התכניות: זהות + תוקף קנוניים של כל תב"ע, בשימוש חוזר בין תיקים.
|
||||
רק תכנית מאושרת מצוטטת בבלוק ט; התוקף נכתב בנוסח אחיד{" "}
|
||||
<span className="text-gold-deep font-semibold">דטרמיניסטי</span> —
|
||||
תאריך-הרשומות ומס' הילקוט לעולם אינם מומצאים.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user