Files
legal-ai/web-ui/src/components/precedents/plans-review-panel.tsx
Chaim 2b1fb18dfd
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
feat(plans): כפתור "משוך מ-מנהל-התכנון" בטופס-התכנית (Phase C טריגר 1)
טריגר 1 הידני: בטופס PlanForm, כפתור "משוך מ-מנהל-התכנון" — היו"ר
מקליד מספר-תכנית, לוחץ, והשדות (שם/תאריך-רשומות/י"פ/סוג/ייעוד)
מתמלאים מ-mavat דרך POST /api/plans/fetch (#292). היו"ר בודק ושומר —
שער-היו"ר נשמר (שום שמירה אוטומטית).

- plans.ts: useFetchPlan + PlanFetchResult.
- PlanForm: כפתור עם spinner (~דקה, דפדפן חי), מילוי-שדות (מחליף
  בערך-mavat היכן שקיים, שומר ערך-יו"ר היכן ש-mavat ריק), קישור-מקור
  "מקור: מנהל-התכנון" בתצוגה-המקדימה (פרובננס INV-AH).

עבר שער-עיצוב (מוקאפ 22-plans-review מאושר). ההוק ידני (לא תלוי
types שנוצרים). tsc  lint  (0 errors).

INV-AH: source_url מוצג; שדה-חסר ריק לא מומצא. G10: מילוי-טופס בלבד,
שמירה דרך plan_upsert הקיים. G2: צורך את /api/plans/fetch (#292).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 11:32:32 +00:00

550 lines
21 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 { useMemo, useState } from "react";
import {
Check, X, Edit2, AlertTriangle, Plus, GitMerge, Save, Search, Undo2, BadgeCheck,
DownloadCloud, Loader2, ExternalLink,
} 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 { cn } from "@/lib/utils";
import {
usePlansAll, usePlanDuplicates, useUpsertPlan, useUpdatePlan,
useReviewPlan, useMergePlans, useFetchPlan, 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 + ".";
}
// ─────────────────────────────────────────────────────────────────────────────
type StatusFilter = "pending_review" | "approved" | "all";
const SEGMENTS: { value: StatusFilter; label: string }[] = [
{ value: "pending_review", label: "ממתינים" },
{ value: "approved", label: "מאושרות" },
{ value: "all", label: "כולן" },
];
/** Normalized substring match over a plan's identity + purpose fields. */
function matchesQuery(p: Plan, q: string): boolean {
const needle = clean(q).toLowerCase();
if (needle === "—" || !needle.trim()) return true;
const hay = [p.plan_number, p.display_name, p.purpose, ...(p.aliases ?? [])]
.map((s) => clean(s).toLowerCase())
.join(" ");
return hay.includes(needle);
}
export function PlansReviewPanel() {
// One fetch of the whole registry; status segments + search filter client-side
// (the registry is small — instant filtering, accurate counts, no round-trips).
const { data, isLoading, isError } = usePlansAll();
const [adding, setAdding] = useState(false);
const [status, setStatus] = useState<StatusFilter>("pending_review");
const [query, setQuery] = useState("");
const all = useMemo(() => data?.items ?? [], [data]);
const counts = useMemo(
() => ({
pending_review: all.filter((p) => p.review_status === "pending_review").length,
approved: all.filter((p) => p.review_status === "approved").length,
all: all.length,
}),
[all],
);
const visible = useMemo(() => {
const byStatus =
status === "all" ? all : all.filter((p) => p.review_status === status);
return byStatus.filter((p) => matchesQuery(p, query));
}, [all, status, query]);
const truncated = (data?.count ?? 0) >= 1000;
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>;
}
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>
{/* status segments + fuzzy search over the whole registry */}
<div className="flex items-center gap-3 flex-wrap">
<div className="inline-flex gap-0.5 rounded-lg border border-rule bg-parchment p-1">
{SEGMENTS.map((seg) => (
<button
key={seg.value}
type="button"
onClick={() => setStatus(seg.value)}
className={cn(
"inline-flex items-center gap-2 rounded-md px-4 py-1.5 text-sm font-semibold transition-colors",
status === seg.value
? "bg-surface text-navy shadow-sm"
: "text-ink-muted hover:text-ink-soft",
)}
>
{seg.label}
<span
className={cn(
"rounded-full px-1.5 text-[0.7rem] font-bold tabular-nums",
status === seg.value ? "bg-gold text-white" : "bg-rule text-ink-muted",
)}
>
{counts[seg.value]}
</span>
</button>
))}
</div>
<div className="relative min-w-[220px] max-w-sm flex-1">
<Search className="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-ink-muted" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="חיפוש במרשם — מספר-תכנית / שם / ייעוד…"
className="ps-9"
/>
</div>
</div>
{adding && (
<PlanForm
title="הוספת תכנית"
subtitle="מספר-התכנית מנורמל אוטומטית. ברירת-מחדל: מאושר (קלט-יו״ר)."
initial={BLANK}
mode="add"
onClose={() => setAdding(false)}
/>
)}
{truncated && (
<p className="text-warn text-[0.78rem] font-medium">
המרשם חרג מ-1000 רשומות לא כל התכניות מוצגות. יש להוסיף עימוד.
</p>
)}
{visible.length === 0 ? (
<div className="rounded-lg border border-rule bg-surface shadow-sm p-6 text-center text-ink-muted text-sm">
{query
? "לא נמצאו תכניות התואמות לחיפוש."
: status === "pending_review"
? "אין תכניות הממתינות לאישור."
: status === "approved"
? "אין תכניות מאושרות במרשם."
: "המרשם ריק."}
</div>
) : (
visible.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 isPending = plan.review_status === "pending_review";
const isApproved = plan.review_status === "approved";
// Dedup candidates only matter while the plan is in the chair queue.
const { data: dups } = usePlanDuplicates(plan.id, isPending);
const duplicates = dups?.items ?? [];
const noDate = !plan.gazette_date;
async function decide(status: "approved" | "rejected" | "pending_review") {
try {
await review.mutateAsync({ id: plan.id, status });
toast.success(
status === "approved"
? "התכנית אושרה"
: status === "rejected"
? "התכנית נדחתה"
: "התכנית הוחזרה לתור",
);
} 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={cn(
"rounded-lg border border-rule shadow-sm p-4 space-y-3",
isPending ? "bg-gold-wash" : "bg-surface",
)}
>
<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>
{isApproved && (
<span className="inline-flex items-center gap-1 rounded-full bg-success-bg text-success text-[0.72rem] font-semibold px-2.5 py-0.5">
<BadgeCheck className="size-3.5" /> מאושרת
</span>
)}
{!isPending && !isApproved && (
<span className="rounded-full bg-danger-bg text-danger text-[0.72rem] font-semibold px-2.5 py-0.5">
נדחתה
</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 && isPending ? "השלם תוקף" : "ערוך / תקן"}
</Button>
{isPending ? (
<>
<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>
</>
) : (
// Already decided (approved / rejected) — only return it to the queue.
<Button
variant="outline" size="sm" className="border-gold text-gold-deep"
disabled={review.isPending}
onClick={() => decide("pending_review")}
>
<Undo2 className="size-4" /> החזר לתור
</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 [sourceUrl, setSourceUrl] = useState("");
const upsert = useUpsertPlan();
const update = useUpdatePlan();
const fetchMavat = useFetchPlan();
const busy = upsert.isPending || update.isPending;
function set<K extends keyof EditForm>(k: K, v: string) {
setF((prev) => ({ ...prev, [k]: v }));
}
// Pull identity + validity from the official source (mavat) into the fields.
// Fills each field mavat returns (keeps a chair value only where mavat is
// empty); the chair still reviews + saves. Slow — a real browser on the host.
async function pullFromMavat() {
const num = f.plan_number.trim();
if (!num) {
toast.error("הקלד מספר-תכנית למשיכה");
return;
}
try {
const r = await fetchMavat.mutateAsync(num);
setF((prev) => ({
plan_number: r.plan_number || prev.plan_number,
display_name: r.display_name || prev.display_name,
plan_type: r.plan_type || prev.plan_type,
gazette_date: r.gazette_date || prev.gazette_date,
yalkut_number: r.yalkut_number || prev.yalkut_number,
purpose: r.purpose || prev.purpose,
}));
setSourceUrl(r.source_url || "");
toast.success(
r.yalkut_number || r.gazette_date
? "נמשך ממנהל-התכנון — בדוק ואשר"
: "נמצא במנהל-התכנון אך ללא תוקף מפורסם — השלם ידנית",
);
} catch (e) {
const msg = (e as { body?: { detail?: string } })?.body?.detail;
toast.error(msg || "שגיאה במשיכה ממנהל-התכנון");
}
}
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>
{/* pull validity from the official source (mavat) into the fields below */}
<div className="flex items-center gap-3 flex-wrap rounded-md border border-[#cdd9e8] bg-info-bg px-3 py-2.5">
<Button
size="sm"
className="bg-navy text-white hover:bg-navy-soft shrink-0"
disabled={fetchMavat.isPending}
onClick={pullFromMavat}
>
{fetchMavat.isPending ? (
<><Loader2 className="size-4 animate-spin" /> מושך</>
) : (
<><DownloadCloud className="size-4" /> משוך מ-מנהל-התכנון</>
)}
</Button>
<span className="text-[0.72rem] text-ink-soft leading-relaxed flex-1 min-w-[220px]">
{fetchMavat.isPending
? "מושך ממנהל-התכנון — עד כדקה (דפדפן חי)…"
: 'הקלד מספר-תכנית ולחץ — שם, תאריך-רשומות, מס\' ילקוט (י"פ), סוג וייעוד יימשכו מהמקור הרשמי. שדה שהמקור אינו חושף יישאר ריק — לא מומצא.'}
</span>
</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>
{sourceUrl && (
<a
href={sourceUrl} target="_blank" rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-[0.7rem] text-[#365070] hover:underline"
>
<ExternalLink className="size-3" /> מקור: מנהל-התכנון
</a>
)}
</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>
);
}