feat(plans): עיון+חיפוש בתכניות-מאושרות בטאב התכניות (Phase A)
כרטיס-התכניות בדף /precedents הציג רק review_status=pending_review, כך שתכניות מאושרות (התקינות, בשימוש-חוזר בבלוק ט) לא היו נגישות לעיון. ה-backend כבר תמך ב-?review_status= ו-?q= — חסר רק חוט-UI. - plans.ts: usePlansPending → usePlansAll (טעינת כל המרשם בקריאה אחת; הפאנל מסנן client-side — מרשם קטן, מונה-מדויק לכל סגמנט, חיפוש מיידי) - plans-review-panel: סרגל-מצב (ממתינים/מאושרות/כולן) עם מונים + תיבת-חיפוש fuzzy (מספר/שם/ייעוד/aliases, מנורמל-bidi); הערת-קטיעה אם >1000 (בלי cap שקט) - PlanCard מסתעף לפי review_status: מאושרת/נדחתה → תג-מצב + "החזר לתור" (review→pending_review) במקום אשר/דחה; דדופ-candidates רק בתור עבר דרך שער-עיצוב Claude Design (מוקאפ 22-plans-review מאושר ע"י חיים). ללא שינוי-backend. מרחיב מרשם-V38 הקיים — לא מסלול מקביל. Invariants: G2 (יכולת קיימת, endpoint קיים) · INV-IA2 (שער-יחיד /precedents) · G10 נשמר (review_status שער-יו"ר). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Check, X, Edit2, AlertTriangle, Plus, GitMerge, Save } from "lucide-react";
|
import {
|
||||||
|
Check, X, Edit2, AlertTriangle, Plus, GitMerge, Save, Search, Undo2, BadgeCheck,
|
||||||
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
usePlansPending, usePlanDuplicates, useUpsertPlan, useUpdatePlan,
|
usePlansAll, usePlanDuplicates, useUpsertPlan, useUpdatePlan,
|
||||||
useReviewPlan, useMergePlans, type Plan, type PlanEdit,
|
useReviewPlan, useMergePlans, type Plan, type PlanEdit,
|
||||||
} from "@/lib/api/plans";
|
} 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() {
|
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 [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -80,11 +122,9 @@ export function PlansReviewPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return <p className="text-danger text-sm">שגיאה בטעינת תור-התכניות.</p>;
|
return <p className="text-danger text-sm">שגיאה בטעינת מרשם-התכניות.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const plans = data?.items ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
@@ -102,6 +142,44 @@ export function PlansReviewPanel() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 && (
|
{adding && (
|
||||||
<PlanForm
|
<PlanForm
|
||||||
title="הוספת תכנית"
|
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">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
plans.map((p) => <PlanCard key={p.id} plan={p} />)
|
visible.map((p) => <PlanCard key={p.id} plan={p} />)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -129,15 +219,24 @@ function PlanCard({ plan }: { plan: Plan }) {
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const review = useReviewPlan();
|
const review = useReviewPlan();
|
||||||
const merge = useMergePlans();
|
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 duplicates = dups?.items ?? [];
|
||||||
|
|
||||||
const noDate = !plan.gazette_date;
|
const noDate = !plan.gazette_date;
|
||||||
|
|
||||||
async function decide(status: "approved" | "rejected") {
|
async function decide(status: "approved" | "rejected" | "pending_review") {
|
||||||
try {
|
try {
|
||||||
await review.mutateAsync({ id: plan.id, status });
|
await review.mutateAsync({ id: plan.id, status });
|
||||||
toast.success(status === "approved" ? "התכנית אושרה" : "התכנית נדחתה");
|
toast.success(
|
||||||
|
status === "approved"
|
||||||
|
? "התכנית אושרה"
|
||||||
|
: status === "rejected"
|
||||||
|
? "התכנית נדחתה"
|
||||||
|
: "התכנית הוחזרה לתור",
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("שגיאה בעדכון התכנית");
|
toast.error("שגיאה בעדכון התכנית");
|
||||||
}
|
}
|
||||||
@@ -166,12 +265,27 @@ function PlanCard({ plan }: { plan: Plan }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Badge className="bg-gold text-white rounded">תכנית</Badge>
|
<Badge className="bg-gold text-white rounded">תכנית</Badge>
|
||||||
<span className="text-navy font-bold text-[0.95rem]">
|
<span className="text-navy font-bold text-[0.95rem]">
|
||||||
{plan.display_name || plan.plan_number}
|
{plan.display_name || plan.plan_number}
|
||||||
</span>
|
</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 && (
|
{plan.plan_type && (
|
||||||
<span className="rounded-full bg-info-bg text-[#365070] text-[0.72rem] font-semibold px-2.5 py-0.5">
|
<span className="rounded-full bg-info-bg text-[#365070] text-[0.72rem] font-semibold px-2.5 py-0.5">
|
||||||
{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"
|
variant="ghost" size="sm" className="me-auto text-ink-muted"
|
||||||
onClick={() => setEditing(true)}
|
onClick={() => setEditing(true)}
|
||||||
>
|
>
|
||||||
<Edit2 className="size-4" /> {noDate ? "השלם תוקף" : "ערוך / תקן"}
|
<Edit2 className="size-4" /> {noDate && isPending ? "השלם תוקף" : "ערוך / תקן"}
|
||||||
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,17 +43,23 @@ export type Plan = {
|
|||||||
|
|
||||||
export const planKeys = {
|
export const planKeys = {
|
||||||
all: ["plans"] as const,
|
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,
|
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({
|
return useQuery({
|
||||||
queryKey: planKeys.pending(),
|
queryKey: planKeys.list(),
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
apiRequest<{ items: Plan[]; count: number }>(
|
apiRequest<{ items: Plan[]; count: number }>(
|
||||||
`/api/plans?review_status=pending_review&limit=${limit}`,
|
`/api/plans?limit=${limit}`,
|
||||||
{ signal },
|
{ signal },
|
||||||
),
|
),
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
|
|||||||
Reference in New Issue
Block a user