diff --git a/web-ui/src/components/precedents/plans-review-panel.tsx b/web-ui/src/components/precedents/plans-review-panel.tsx index ee81fc6..a668f8b 100644 --- a/web-ui/src/components/precedents/plans-review-panel.tsx +++ b/web-ui/src/components/precedents/plans-review-panel.tsx @@ -1,15 +1,18 @@ "use client"; -import { useState } from "react"; -import { Check, X, Edit2, AlertTriangle, Plus, GitMerge, Save } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + Check, X, Edit2, AlertTriangle, Plus, GitMerge, Save, Search, Undo2, BadgeCheck, +} 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 { - usePlansPending, usePlanDuplicates, useUpsertPlan, useUpdatePlan, + usePlansAll, usePlanDuplicates, useUpsertPlan, useUpdatePlan, useReviewPlan, useMergePlans, type Plan, type PlanEdit, } from "@/lib/api/plans"; @@ -68,9 +71,48 @@ function previewCitation(f: EditForm): string { // ───────────────────────────────────────────────────────────────────────────── +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() { - const { data, isLoading, isError } = usePlansPending(); + // 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 ( @@ -80,11 +122,9 @@ export function PlansReviewPanel() { ); } if (isError) { - return

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

; + return

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

; } - const plans = data?.items ?? []; - return (
@@ -102,6 +142,44 @@ export function PlansReviewPanel() {
+ {/* status segments + fuzzy search over the whole registry */} +
+
+ {SEGMENTS.map((seg) => ( + + ))} +
+
+ + setQuery(e.target.value)} + placeholder="חיפוש במרשם — מספר-תכנית / שם / ייעוד…" + className="ps-9" + /> +
+
+ {adding && ( )} - {plans.length === 0 ? ( + {truncated && ( +

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

+ )} + + {visible.length === 0 ? (
- אין תכניות הממתינות לאישור. + {query + ? "לא נמצאו תכניות התואמות לחיפוש." + : status === "pending_review" + ? "אין תכניות הממתינות לאישור." + : status === "approved" + ? "אין תכניות מאושרות במרשם." + : "המרשם ריק."}
) : ( - plans.map((p) => ) + visible.map((p) => ) )}
); @@ -129,15 +219,24 @@ function PlanCard({ plan }: { plan: Plan }) { const [editing, setEditing] = useState(false); const review = useReviewPlan(); const merge = useMergePlans(); - const { data: dups } = usePlanDuplicates(plan.id); + 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") { + async function decide(status: "approved" | "rejected" | "pending_review") { try { await review.mutateAsync({ id: plan.id, status }); - toast.success(status === "approved" ? "התכנית אושרה" : "התכנית נדחתה"); + toast.success( + status === "approved" + ? "התכנית אושרה" + : status === "rejected" + ? "התכנית נדחתה" + : "התכנית הוחזרה לתור", + ); } catch { toast.error("שגיאה בעדכון התכנית"); } @@ -166,12 +265,27 @@ function PlanCard({ plan }: { plan: Plan }) { } return ( -
+
תכנית {plan.display_name || plan.plan_number} + {isApproved && ( + + מאושרת + + )} + {!isPending && !isApproved && ( + + נדחתה + + )} {plan.plan_type && ( {plan.plan_type} @@ -240,22 +354,35 @@ function PlanCard({ plan }: { plan: Plan }) { variant="ghost" size="sm" className="me-auto text-ink-muted" onClick={() => setEditing(true)} > - {noDate ? "השלם תוקף" : "ערוך / תקן"} - - - + {isPending ? ( + <> + + + + ) : ( + // Already decided (approved / rejected) — only return it to the queue. + + )}
); diff --git a/web-ui/src/lib/api/plans.ts b/web-ui/src/lib/api/plans.ts index 48e8d8e..c8c0069 100644 --- a/web-ui/src/lib/api/plans.ts +++ b/web-ui/src/lib/api/plans.ts @@ -43,17 +43,23 @@ export type Plan = { export const planKeys = { all: ["plans"] as const, - pending: () => [...planKeys.all, "pending"] as const, + list: () => [...planKeys.all, "list"] as const, duplicates: (id: string) => [...planKeys.all, "duplicates", id] as const, }; -/** All plans awaiting the chair gate (G10). */ -export function usePlansPending(limit = 500) { +/** + * The whole registry in one fetch (every review_status). The panel filters + * client-side by status segment (ממתינים/מאושרות/כולן) and search — the + * registry is small, so one load gives accurate segment counts + instant + * filtering without per-keystroke round-trips. `limit` is a safety ceiling; + * if it is ever hit the panel surfaces a truncation note (no silent cap). + */ +export function usePlansAll(limit = 1000) { return useQuery({ - queryKey: planKeys.pending(), + queryKey: planKeys.list(), queryFn: ({ signal }) => apiRequest<{ items: Plan[]; count: number }>( - `/api/plans?review_status=pending_review&limit=${limit}`, + `/api/plans?limit=${limit}`, { signal }, ), staleTime: 5_000,