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";
|
||||
|
||||
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 } = 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 (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user