Files
legal-ai/web-ui/src/components/precedents/library-list-panel.tsx
Chaim a3ef9e5e34 fix(ui): ברירת-מחדל של ספריית הפסיקה — החלטות ועדות ערר ראשונות
מתג-המקטעים נפתח כעת על "החלטות ועדות ערר" (הקורפוס המרכזי של היו"ר)
במקום "פסיקת בתי משפט".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:26:49 +00:00

622 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import Link from "next/link";
import { Trash2, Plus, Pencil, Wand2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
usePrecedents,
useDeletePrecedent,
useRequestHalachotExtraction,
isPrecedentActive,
type Precedent,
type PracticeArea,
} from "@/lib/api/precedent-library";
import { PRACTICE_AREAS, practiceAreaShort } from "./practice-area";
import { PrecedentUploadSheet } from "./precedent-upload-sheet";
import { PrecedentEditSheet } from "./precedent-edit-sheet";
import {
FormattedCitation,
CitationCopyButton,
} from "./formatted-citation";
function formatDate(iso: string | null) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL");
} catch {
return iso;
}
}
function cleanCitation(s: string | null | undefined): string {
if (!s) return "—";
return s.replace(/[--]/g, "").trim();
}
// Show the "extract halachot" button only when the precedent hasn't had a
// successful (or even attempted) extraction yet. Hide while processing or
// after completion to avoid duplicate requests.
function needsHalachaExtraction(p: Precedent): boolean {
if (p.extraction_status !== "completed") return false; // text not ready
if (p.halacha_extraction_status === "processing") return false;
if (p.halacha_extraction_status === "completed") return false;
if (p.halacha_extraction_status === "pending" && p.halacha_extraction_requested_at) {
return false; // already queued
}
// Remaining cases: pending+no-requested_at (never tried) or failed (retry).
return true;
}
function ActivePill({ label }: { label: string }) {
return (
<Badge
variant="outline"
className="bg-gold-wash text-gold-deep border-gold/40 shimmer-active"
>
{label}
</Badge>
);
}
function StatusPill({ p }: { p: Precedent }) {
if (p.extraction_status === "failed") {
return (
<Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">
נכשל
</Badge>
);
}
if (p.extraction_status === "processing") {
return <ActivePill label="מעבד טקסט" />;
}
if (p.extraction_status !== "completed") {
return (
<Badge variant="outline" className="bg-rule-soft text-ink-muted">
בתור
</Badge>
);
}
// Metadata extraction (orthogonal one-off re-extract). Surface it while
// active so the chair sees the queued button actually ran on something.
if (p.metadata_extraction_status === "processing") {
return <ActivePill label="מחלץ מטא-דאטה" />;
}
if (p.metadata_extraction_status === "failed") {
return (
<Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">
מטא-דאטה נכשל
</Badge>
);
}
if (
p.metadata_extraction_requested_at &&
p.metadata_extraction_status !== "completed"
) {
return (
<Badge variant="outline" className="bg-rule-soft text-ink-muted">
ממתין למטא-דאטה
</Badge>
);
}
if (p.halacha_extraction_status === "processing") {
return <ActivePill label="מחלץ הלכות" />;
}
if (p.halacha_extraction_status === "failed") {
return (
<Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">
חילוץ נכשל
</Badge>
);
}
if (p.halacha_extraction_status === "pending") {
if (p.halacha_extraction_requested_at) {
return (
<Badge variant="outline" className="bg-rule-soft text-ink-muted">
ממתין לחילוץ
</Badge>
);
}
return (
<Badge variant="outline" className="bg-rule-soft text-ink-muted">
לא חולץ
</Badge>
);
}
if (p.halachot_count === 0) {
return <Badge variant="outline">ללא הלכות</Badge>;
}
return (
<Badge variant="outline" className="bg-gold-wash text-gold-deep border-gold/40">
{p.approved_count}/{p.halachot_count} מאושרות
</Badge>
);
}
function CourtRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void }) {
const del = useDeletePrecedent();
const reqHalachot = useRequestHalachotExtraction();
const active = isPrecedentActive(p);
const showExtractHalachot = needsHalachaExtraction(p);
const onDelete = async () => {
if (active) {
toast.error("מתבצע עיבוד — לא ניתן למחוק עכשיו.");
return;
}
if (!window.confirm(`למחוק את ${p.case_number}?`)) return;
try {
await del.mutateAsync(p.id);
toast.success("נמחק");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
const onExtractHalachot = async () => {
try {
await reqHalachot.mutateAsync(p.id);
toast.success("סומן לחילוץ הלכות. הריצי מ-Claude Code: precedent_process_pending_halachot");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
return (
<TableRow className="border-rule hover:bg-gold-wash/30 align-top">
<TableCell
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3"
dir="rtl"
>
<div className="flex items-start justify-between gap-2">
<Link
href={`/precedents/${p.id}`}
className="hover:underline hover:text-gold-deep block min-w-0"
dir="auto"
>
{p.citation_formatted ? (
<FormattedCitation
citation={p.citation_formatted}
className="block leading-snug"
/>
) : (
cleanCitation(p.case_number)
)}
</Link>
{p.citation_formatted ? (
<CitationCopyButton citation={p.citation_formatted} size="xs" />
) : null}
</div>
</TableCell>
{/* Column "שם / ערכאה" hidden by request (case_name often equals case_number prefix). Keep field in DB; restore by un-hiding. */}
<TableCell className="hidden text-ink whitespace-normal break-words max-w-[260px] py-3">
<div className="font-medium">{cleanCitation(p.case_name)}</div>
{p.court ? <div className="text-[0.72rem] text-ink-muted">{p.court}</div> : null}
</TableCell>
<TableCell className="text-ink-muted">{p.date ? formatDate(p.date) : <span className="text-ink-light"></span>}</TableCell>
<TableCell>
{p.practice_area ? (
<Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30">
{practiceAreaShort(p.practice_area)}
</Badge>
) : <span className="text-ink-light"></span>}
</TableCell>
<TableCell className="text-ink-muted text-[0.78rem]">
{p.precedent_level || <span className="text-ink-light"></span>}
</TableCell>
<TableCell><StatusPill p={p} /></TableCell>
<TableCell className="text-end">
<div className="flex items-center gap-1 justify-end">
<Button variant="ghost" size="sm" onClick={() => onEdit(p.id)}
aria-label={`ערוך את ${p.case_number}`} title="ערוך"
className="text-ink-muted hover:text-navy">
<Pencil className="w-4 h-4" />
</Button>
{showExtractHalachot && (
<Button variant="ghost" size="sm" onClick={onExtractHalachot}
disabled={reqHalachot.isPending}
aria-label={`חלץ הלכות מ-${p.case_number}`}
title="חלץ הלכות"
className="text-gold-deep hover:text-gold-deep hover:bg-gold-wash disabled:opacity-30">
<Wand2 className="w-4 h-4" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={onDelete}
disabled={del.isPending || active}
aria-label={`מחק את ${p.case_number}`}
title={active ? "מתבצע עיבוד — לא ניתן למחוק" : "מחק"}
className="text-danger hover:text-danger hover:bg-danger-bg disabled:opacity-30">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
}
function CommitteeRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void }) {
const del = useDeletePrecedent();
const reqHalachot = useRequestHalachotExtraction();
const active = isPrecedentActive(p);
const showExtractHalachot = needsHalachaExtraction(p);
const onDelete = async () => {
if (active) {
toast.error("מתבצע עיבוד — לא ניתן למחוק עכשיו.");
return;
}
if (!window.confirm(`למחוק את ${p.case_number}?`)) return;
try {
await del.mutateAsync(p.id);
toast.success("נמחק");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
const onExtractHalachot = async () => {
try {
await reqHalachot.mutateAsync(p.id);
toast.success("סומן לחילוץ הלכות. הריצי מ-Claude Code: precedent_process_pending_halachot");
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
return (
<TableRow className="border-rule hover:bg-gold-wash/30 align-top">
<TableCell
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[200px] max-w-[320px] py-3"
dir="rtl"
>
<div className="flex items-start justify-between gap-2">
<Link
href={`/precedents/${p.id}`}
className="hover:underline hover:text-gold-deep block min-w-0"
dir="auto"
>
{p.citation_formatted ? (
<FormattedCitation
citation={p.citation_formatted}
className="block leading-snug"
/>
) : (
cleanCitation(p.case_number)
)}
</Link>
{p.citation_formatted ? (
<CitationCopyButton citation={p.citation_formatted} size="xs" />
) : null}
</div>
</TableCell>
{/* Column "שם" hidden by request (case_name often equals case_number prefix). Keep field in DB; restore by un-hiding. */}
<TableCell className="hidden text-ink whitespace-normal break-words max-w-[220px] py-3">
<div className="font-medium">{cleanCitation(p.case_name)}</div>
</TableCell>
<TableCell className="text-ink-muted text-[0.78rem]">
{p.district || <span className="text-ink-light"></span>}
</TableCell>
<TableCell className="text-ink-muted text-[0.78rem]">
{p.chair_name || <span className="text-ink-light"></span>}
</TableCell>
<TableCell className="text-ink-muted">{p.date ? formatDate(p.date) : <span className="text-ink-light"></span>}</TableCell>
<TableCell>
{p.practice_area ? (
<Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30">
{practiceAreaShort(p.practice_area)}
</Badge>
) : <span className="text-ink-light"></span>}
</TableCell>
<TableCell><StatusPill p={p} /></TableCell>
<TableCell className="text-end">
<div className="flex items-center gap-1 justify-end">
<Button variant="ghost" size="sm" onClick={() => onEdit(p.id)}
aria-label={`ערוך את ${p.case_number}`} title="ערוך"
className="text-ink-muted hover:text-navy">
<Pencil className="w-4 h-4" />
</Button>
{showExtractHalachot && (
<Button variant="ghost" size="sm" onClick={onExtractHalachot}
disabled={reqHalachot.isPending}
aria-label={`חלץ הלכות מ-${p.case_number}`}
title="חלץ הלכות"
className="text-gold-deep hover:text-gold-deep hover:bg-gold-wash disabled:opacity-30">
<Wand2 className="w-4 h-4" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={onDelete}
disabled={del.isPending || active}
aria-label={`מחק את ${p.case_number}`}
title={active ? "מתבצע עיבוד — לא ניתן למחוק" : "מחק"}
className="text-danger hover:text-danger hover:bg-danger-bg disabled:opacity-30">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
}
/**
* Live banner for the metadata-extraction queue. The local-MCP worker
* (`precedent_process_pending kind=metadata`) drains stamped rows serially;
* while it runs, rows carry metadata_extraction_status='processing' (current
* item) or a request timestamp (still queued). We surface a real, advancing
* percentage by tracking the session high-water-mark of the queue depth:
* percent = (peak - remaining) / peak. Hidden when the queue is empty.
*/
function MetadataQueueBanner({ items }: { items: Precedent[] }) {
const processing = items.filter(
(p) => p.metadata_extraction_status === "processing",
).length;
const pending = items.filter(
(p) =>
p.metadata_extraction_status !== "processing" &&
p.metadata_extraction_requested_at !== null &&
p.metadata_extraction_status !== "completed" &&
p.metadata_extraction_status !== "failed",
).length;
const remaining = processing + pending;
// Session high-water-mark of the queue depth, using React's documented
// "adjust state during render" pattern (no effect, no ref-in-render). React
// re-renders immediately without committing the intermediate UI. Resets to 0
// when the queue drains, so the next batch starts a fresh 0→100% sweep.
const [peak, setPeak] = useState(0);
if (remaining === 0) {
if (peak !== 0) setPeak(0);
} else if (remaining > peak) {
setPeak(remaining);
}
if (remaining === 0) return null;
const done = Math.max(0, peak - remaining);
const percent = peak > 0 ? Math.round((done / peak) * 100) : 0;
return (
<div className="rounded-lg border border-gold/40 bg-gold-wash/50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm text-gold-deep">
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
<span>
חילוץ מטא-דאטה פעיל מעבד {processing}, נותרו {pending} בתור
{peak > 0 ? ` · ${done}/${peak}` : ""}
</span>
</div>
<Progress value={percent} className="h-1.5" />
</div>
);
}
function SegButton({
active, onClick, label, count,
}: {
active: boolean;
onClick: () => void;
label: string;
count?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
"flex items-center gap-2 rounded-md px-4 py-1.5 text-sm font-semibold transition-colors",
active
? "bg-surface text-navy shadow-sm"
: "text-ink-muted hover:text-navy",
)}
>
{label}
{count !== undefined && (
<Badge
variant="outline"
className={cn(
"text-[0.72rem]",
active ? "border-navy/30 text-navy" : "text-ink-muted",
)}
>
{count}
</Badge>
)}
</button>
);
}
function TableSkeleton({ cols }: { cols: number }) {
return (
<>
{[...Array(4)].map((_, i) => (
<TableRow key={i} className="border-rule">
{[...Array(cols)].map((_, j) => (
<TableCell key={j}><Skeleton className="h-5 w-full" /></TableCell>
))}
</TableRow>
))}
</>
);
}
export function LibraryListPanel() {
const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
const [search, setSearch] = useState("");
const [uploadOpen, setUploadOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
// Which of the two corpora to show. Both queries stay mounted (counts +
// cross-table polling), but only the active table renders — no long scroll.
// Default to committee decisions (the chair's primary corpus).
const [view, setView] = useState<"court" | "committee">("committee");
const sharedFilters = {
practiceArea: practiceArea || undefined,
search: search.trim() || undefined,
limit: 200,
};
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload", sourceType: "court_ruling" });
const committee = usePrecedents({ ...sharedFilters, sourceKind: "all_committees" });
// Metadata-queue banner draws on both corpora (a stamped row can be either).
const allItems = [
...(courts.data?.items ?? []),
...(committee.data?.items ?? []),
];
return (
<div className="space-y-8">
{/* Shared filters */}
<div className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]">
<label className="text-[0.78rem] text-ink-muted">חיפוש (מספר תיק / שם)</label>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="עע״מ 3975/22 / 1200-25"
dir="rtl"
/>
</div>
<div className="min-w-[180px]">
<label className="text-[0.78rem] text-ink-muted">תחום</label>
<Select value={practiceArea || "_all"} onValueChange={(v) => setPracticeArea(v === "_all" ? "" : v as PracticeArea)}>
<SelectTrigger><SelectValue placeholder="הכל" /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">הכל</SelectItem>
{PRACTICE_AREAS.map((a) => (
<SelectItem key={a.value} value={a.value}>{a.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={() => setUploadOpen(true)} className="bg-navy text-parchment hover:bg-navy-soft">
<Plus className="w-4 h-4 me-1" />
העלאת פסיקה
</Button>
</div>
{/* Metadata-extraction queue progress (hidden when idle) */}
<MetadataQueueBanner items={allItems} />
{/* Segmented control — show one corpus at a time */}
<div className="inline-flex rounded-lg border border-rule bg-rule-soft/40 p-1 gap-1">
<SegButton
active={view === "court"}
onClick={() => setView("court")}
label="פסיקת בתי משפט"
count={courts.data?.count}
/>
<SegButton
active={view === "committee"}
onClick={() => setView("committee")}
label="החלטות ועדות ערר"
count={committee.data?.count}
/>
</div>
{/* Table 1 — Court rulings */}
{view === "court" && (
<section>
{courts.error ? (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
{courts.error.message}
</div>
) : (
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-rule-soft/60">
<TableRow className="border-rule">
<TableHead className="text-navy text-right">מס׳ / מראה מקום</TableHead>
{/* "שם / ערכאה" hidden by request — see CourtRow */}
<TableHead className="hidden text-navy text-right">שם / ערכאה</TableHead>
<TableHead className="text-navy text-right">תאריך</TableHead>
<TableHead className="text-navy text-right">תחום</TableHead>
<TableHead className="text-navy text-right">רמה</TableHead>
<TableHead className="text-navy text-right">הלכות</TableHead>
<TableHead className="text-navy" />
</TableRow>
</TableHeader>
<TableBody>
{courts.isPending ? (
<TableSkeleton cols={7} />
) : !courts.data?.items.length ? (
<TableRow className="border-rule">
<TableCell colSpan={7} className="text-center text-ink-muted py-8">
אין פסיקת בתי משפט בקורפוס.
</TableCell>
</TableRow>
) : (
courts.data.items.map((p) => (
<CourtRow key={p.id} p={p} onEdit={setEditingId} />
))
)}
</TableBody>
</Table>
</div>
)}
</section>
)}
{/* Table 2 — Appeals committee decisions */}
{view === "committee" && (
<section>
{committee.error ? (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
{committee.error.message}
</div>
) : (
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-rule-soft/60">
<TableRow className="border-rule">
<TableHead className="text-navy text-right">מספר ערר</TableHead>
{/* "שם" hidden by request — see CommitteeRow */}
<TableHead className="hidden text-navy text-right">שם</TableHead>
<TableHead className="text-navy text-right">מחוז</TableHead>
<TableHead className="text-navy text-right">יו״ר</TableHead>
<TableHead className="text-navy text-right">תאריך</TableHead>
<TableHead className="text-navy text-right">תחום</TableHead>
<TableHead className="text-navy text-right">הלכות</TableHead>
<TableHead className="text-navy" />
</TableRow>
</TableHeader>
<TableBody>
{committee.isPending ? (
<TableSkeleton cols={8} />
) : !committee.data?.items.length ? (
<TableRow className="border-rule">
<TableCell colSpan={8} className="text-center text-ink-muted py-8">
אין החלטות ועדת ערר בקורפוס.
</TableCell>
</TableRow>
) : (
committee.data.items.map((p) => (
<CommitteeRow key={p.id} p={p} onEdit={setEditingId} />
))
)}
</TableBody>
</Table>
</div>
)}
</section>
)}
<PrecedentUploadSheet open={uploadOpen} onOpenChange={setUploadOpen} />
<PrecedentEditSheet
caseLawId={editingId}
onOpenChange={(open) => { if (!open) setEditingId(null); }}
/>
</div>
);
}