Files
legal-ai/web-ui/src/components/precedents/library-list-panel.tsx
Chaim cbc7a1e336
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m25s
feat(precedents): formal citation per Israeli citation rules + copy/edit UI
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>
2026-05-27 07:14:34 +00:00

495 lines
19 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 } 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>
);
}