All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m25s
Until now, "case_number" was the only stored identifier for a precedent.
But a *citation per the Israeli unified citation rules* is a different
beast — it has bold parties, an unbold prefix (court abbrev + panel/
district parenthetical + case number), and an unbold trailing reporter
(נבו / פ"ד...). Without storing it as a first-class field we couldn't
hand the chair a one-click "copy as citation" experience for pasting
into decisions.
Changes:
- Schema V19: case_law.citation_formatted TEXT (Markdown — parties
wrapped in **…** so the copy helper can render <strong> for Word/Docs
paste and keep plain-text fallback meaningful).
- Metadata extractor: composes citation_formatted from the document
text per the unified citation rules, with worked examples for ע"א /
עת"מ / ערר / בל"מ in the prompt. Refuses to store half-formed strings.
- PATCH /api/precedent-library/{id} accepts citation_formatted so the
chair can correct LLM mistakes.
- /precedents/[id]: dedicated "מראה מקום" block with bold rendering,
a copy-to-clipboard button (text/html + text/plain so Word keeps
the bolds), and an inline edit textarea.
- /precedents list rows: link displays the formatted citation when
available, with a small inline copy button — falls back to the bare
case_number for older rows.
Backfill of existing rows happens by re-stamping the extraction queue
once V19 has rolled out and the new field is reachable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
495 lines
19 KiB
TypeScript
495 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import Link from "next/link";
|
||
import { Trash2, Plus, Pencil, Wand2 } 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 { Skeleton } from "@/components/ui/skeleton";
|
||
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>
|
||
);
|
||
}
|
||
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>
|
||
);
|
||
}
|
||
|
||
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);
|
||
|
||
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" });
|
||
|
||
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>
|
||
|
||
{/* Table 1 — Court rulings */}
|
||
<section>
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<h3 className="text-base font-semibold text-navy">פסיקת בתי משפט</h3>
|
||
{courts.data && (
|
||
<Badge variant="outline" className="text-ink-muted text-[0.72rem]">
|
||
{courts.data.count}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
{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 */}
|
||
<section>
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<h3 className="text-base font-semibold text-navy">החלטות ועדות ערר</h3>
|
||
{committee.data && (
|
||
<Badge variant="outline" className="text-ink-muted text-[0.72rem]">
|
||
{committee.data.count}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
{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>
|
||
);
|
||
}
|