@@ -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 < 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 (
@@ -80,11 +122,9 @@ export function PlansReviewPanel() {
) ;
}
if ( isError ) {
return < p className = "text-danger text-sm" > ש ג י א ה ב ט ע י נ ת ת ו ר - ה ת כ נ י ו ת . < / p > ;
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" >
@@ -102,6 +142,44 @@ export function PlansReviewPanel() {
< / 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 = "הוספת תכנית"
@@ -112,12 +190,24 @@ export function PlansReviewPanel() {
/ >
) }
{ plans . length === 0 ? (
{ 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 >
) : (
plans . map ( ( p ) = > < PlanCard key = { p . id } plan = { p } / > )
visible . map ( ( p ) = > < PlanCard key = { p . id } plan = { p } / > )
) }
< / div >
) ;
@@ -129,15 +219,24 @@ function PlanCard({ plan }: { plan: Plan }) {
const [ editing , setEditing ] = useState ( false ) ;
const review = useReviewPlan ( ) ;
const merge = useMergePlans ( ) ;
const { data : dups } = usePlanDuplic ate s( plan . id ) ;
const isPending = plan . review_st atu s === "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 (
< div className = "rounded-lg border border-rule bg-gold-wash shadow-sm p-4 space-y-3" >
< 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 }
@@ -240,8 +354,10 @@ function PlanCard({ plan }: { plan: Plan }) {
variant = "ghost" size = "sm" className = "me-auto text-ink-muted"
onClick = { ( ) = > setEditing ( true ) }
>
< Edit2 className = "size-4" / > { noDate ? "השלם תוקף" : "ערוך / תקן" }
< Edit2 className = "size-4" / > { noDate && isPending ? "השלם תוקף" : "ערוך / תקן" }
< / Button >
{ isPending ? (
< >
< Button
variant = "outline" size = "sm" className = "border-[#e3c4c4] text-danger"
disabled = { review . isPending }
@@ -256,6 +372,17 @@ function PlanCard({ plan }: { plan: Plan }) {
>
< 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 >
) ;