"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 (
{label}
);
}
function StatusPill({ p }: { p: Precedent }) {
if (p.extraction_status === "failed") {
return (
נכשל
);
}
if (p.extraction_status === "processing") {
return ;
}
if (p.extraction_status !== "completed") {
return (
בתור
);
}
// 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 ;
}
if (p.metadata_extraction_status === "failed") {
return (
מטא-דאטה נכשל
);
}
if (
p.metadata_extraction_requested_at &&
p.metadata_extraction_status !== "completed"
) {
return (
ממתין למטא-דאטה
);
}
if (p.halacha_extraction_status === "processing") {
return ;
}
if (p.halacha_extraction_status === "failed") {
return (
חילוץ נכשל
);
}
if (p.halacha_extraction_status === "pending") {
if (p.halacha_extraction_requested_at) {
return (
ממתין לחילוץ
);
}
return (
לא חולץ
);
}
if (p.halachot_count === 0) {
return ללא הלכות ;
}
return (
{p.approved_count}/{p.halachot_count} מאושרות
);
}
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 (
{p.citation_formatted ? (
) : (
cleanCitation(p.case_number)
)}
{p.citation_formatted ? (
) : null}
{/* Column "שם / ערכאה" hidden by request (case_name often equals case_number prefix). Keep field in DB; restore by un-hiding. */}
{cleanCitation(p.case_name)}
{p.court ? {p.court}
: null}
{p.date ? formatDate(p.date) : — }
{p.practice_area ? (
{practiceAreaShort(p.practice_area)}
) : — }
{p.precedent_level || — }
onEdit(p.id)}
aria-label={`ערוך את ${p.case_number}`} title="ערוך"
className="text-ink-muted hover:text-navy">
{showExtractHalachot && (
)}
);
}
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 (
{p.citation_formatted ? (
) : (
cleanCitation(p.case_number)
)}
{p.citation_formatted ? (
) : null}
{/* Column "שם" hidden by request (case_name often equals case_number prefix). Keep field in DB; restore by un-hiding. */}
{cleanCitation(p.case_name)}
{p.district || — }
{p.chair_name || — }
{p.date ? formatDate(p.date) : — }
{p.practice_area ? (
{practiceAreaShort(p.practice_area)}
) : — }
onEdit(p.id)}
aria-label={`ערוך את ${p.case_number}`} title="ערוך"
className="text-ink-muted hover:text-navy">
{showExtractHalachot && (
)}
);
}
/**
* 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 (
חילוץ מטא-דאטה פעיל — מעבד {processing}, נותרו {pending} בתור
{peak > 0 ? ` · ${done}/${peak}` : ""}
);
}
function SegButton({
active, onClick, label, count,
}: {
active: boolean;
onClick: () => void;
label: string;
count?: number;
}) {
return (
{label}
{count !== undefined && (
{count}
)}
);
}
function TableSkeleton({ cols }: { cols: number }) {
return (
<>
{[...Array(4)].map((_, i) => (
{[...Array(cols)].map((_, j) => (
))}
))}
>
);
}
export function LibraryListPanel() {
const [practiceArea, setPracticeArea] = useState("");
const [search, setSearch] = useState("");
const [uploadOpen, setUploadOpen] = useState(false);
const [editingId, setEditingId] = useState(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 (
{/* Shared filters */}
{/* Metadata-extraction queue progress (hidden when idle) */}
{/* Segmented control — show one corpus at a time */}
setView("court")}
label="פסיקת בתי משפט"
count={courts.data?.count}
/>
setView("committee")}
label="החלטות ועדות ערר"
count={committee.data?.count}
/>
{/* Table 1 — Court rulings */}
{view === "court" && (
{courts.error ? (
{courts.error.message}
) : (
מס׳ / מראה מקום
{/* "שם / ערכאה" hidden by request — see CourtRow */}
שם / ערכאה
תאריך
תחום
רמה
הלכות
{courts.isPending ? (
) : !courts.data?.items.length ? (
אין פסיקת בתי משפט בקורפוס.
) : (
courts.data.items.map((p) => (
))
)}
)}
)}
{/* Table 2 — Appeals committee decisions */}
{view === "committee" && (
{committee.error ? (
{committee.error.message}
) : (
מספר ערר
{/* "שם" hidden by request — see CommitteeRow */}
שם
מחוז
יו״ר
תאריך
תחום
הלכות
{committee.isPending ? (
) : !committee.data?.items.length ? (
אין החלטות ועדת ערר בקורפוס.
) : (
committee.data.items.map((p) => (
))
)}
)}
)}
{ if (!open) setEditingId(null); }}
/>
);
}