Merge pull request 'feat(plans): עיון+חיפוש בתכניות-מאושרות בטאב התכניות (Phase A)' (#289) from worktree-plans-approved-view into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 47s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s

This commit was merged in pull request #289.
This commit is contained in:
2026-06-17 10:06:17 +00:00
2 changed files with 167 additions and 34 deletions

View File

@@ -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,22 +354,35 @@ function PlanCard({ plan }: { plan: Plan }) {
variant="ghost" size="sm" className="me-auto text-ink-muted"
onClick={() => setEditing(true)}
>
<Edit2 className="size-4" /> {noDate ? "השלם תוקף" : "ערוך / תקן"}
</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 ? "אשר (ללא תוקף)" : "אשר"}
<Edit2 className="size-4" /> {noDate && isPending ? "השלם תוקף" : "ערוך / תקן"}
</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>
);

View File

@@ -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,