"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("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 (
{[0, 1, 2].map((i) => )}
); } if (isError) { return

שגיאה בטעינת מרשם-התכניות.

; } return (

מרשם-התכניות: זהות + תוקף קנוניים של כל תב"ע, בשימוש חוזר בין תיקים. רק תכנית מאושרת מצוטטת בבלוק ט; התוקף נכתב בנוסח אחיד{" "} דטרמיניסטי — תאריך-הרשומות ומס' הילקוט לעולם אינם מומצאים.

{/* status segments + fuzzy search over the whole registry */}
{SEGMENTS.map((seg) => ( ))}
setQuery(e.target.value)} placeholder="חיפוש במרשם — מספר-תכנית / שם / ייעוד…" className="ps-9" />
{adding && ( setAdding(false)} /> )} {truncated && (

⚠ המרשם חרג מ-1000 רשומות — לא כל התכניות מוצגות. יש להוסיף עימוד.

)} {visible.length === 0 ? (
{query ? "לא נמצאו תכניות התואמות לחיפוש." : status === "pending_review" ? "אין תכניות הממתינות לאישור." : status === "approved" ? "אין תכניות מאושרות במרשם." : "המרשם ריק."}
) : ( visible.map((p) => ) )}
); } // ─── 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 ( setEditing(false)} /> ); } return (
תכנית {plan.display_name || plan.plan_number} {isApproved && ( מאושרת )} {!isPending && !isApproved && ( נדחתה )} {plan.plan_type && ( {plan.plan_type} )} {plan.source_case_number && ( מקור: {plan.source_case_number} )}
{duplicates.length > 0 && (
כפילות אפשרית — ייתכן שאותה תכנית כבר במרשם בצורה אחרת. מזג כדי לא לפצל תכנית אחת לשתי רשומות.
{duplicates.map((d) => (
{d.display_name || d.plan_number} {d.match_reason && ( ({d.match_reason}) )}
))}
)}
משפט-הציטוט הקנוני (כפי שייכתב בבלוק ט)
{clean(plan.citation_formatted)}
תאריך רשומות: {fmtDate(plan.gazette_date)}
ילקוט פרסומים: {plan.yalkut_number || "—"}
{plan.plan_type &&
סוג: {plan.plan_type}
}
{noDate && (
⚠ חסר תאריך-תוקף — תצוטט ללא תוקף עד השלמה.
)}
{isPending ? ( <> ) : ( // Already decided (approved / rejected) — only return it to the queue. )}
); } // ─── 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(initial); const [sourceUrl, setSourceUrl] = useState(""); const upsert = useUpsertPlan(); const update = useUpdatePlan(); const fetchMavat = useFetchPlan(); const busy = upsert.isPending || update.isPending; function set(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, ) => (
set(k, e.target.value)} /> {hint &&

{hint}

}
); return (

{title}

{subtitle}

{/* pull validity from the official source (mavat) into the fields below */}
{fetchMavat.isPending ? "מושך ממנהל-התכנון — עד כדקה (דפדפן חי)…" : 'הקלד מספר-תכנית ולחץ — שם, תאריך-רשומות, מס\' ילקוט (י"פ), סוג וייעוד יימשכו מהמקור הרשמי. שדה שהמקור אינו חושף יישאר ריק — לא מומצא.'}
{field("plan_number", "מספר תכנית", 'מזהה בלבד (ללא המילה "תכנית")')} {field("display_name", "שם תצוגה")} {field("gazette_date", "תאריך פרסום ברשומות", "YYYY-MM-DD")} {field("yalkut_number", 'מס\' ילקוט פרסומים (י"פ)')} {field("plan_type", "סוג תכנית", "ארצית / מחוזית / מקומית / מפורטת / כוללנית")} {field("purpose", "ייעוד (משפט אחד)", undefined, true)}
תצוגה מקדימה — כך ייכתב בבלוק ט: {previewCitation(f)} {sourceUrl && ( מקור: מנהל-התכנון )}
); }