Files
legal-ai/web-ui/src/components/missing-precedents/missing-precedents-table.tsx
Chaim dd0312e457
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
fix(missing-precedents): load full set so accordion counts match the header
The table fetched limit:200 while the header shows the true open count (~497,
via a separate COUNT). With accordion grouping this became visible: sections
summed to exactly 200 (e.g. 16 chair / 173 digest / 11 other) — and 90 of the
106 committee rows (the high-value "cited by Dafna" ones) were hidden past row
200. True buckets: chair 106 / digest 328 / other 63.

- web-ui: list limit 200 → 1000 so all open rows load; accordion section counts
  (computed from the loaded set) now equal the header total.
- backend: list cap 500 → 2000 to allow it (response stays a few hundred KB).

Logic/data only — no visual change (bug-fix exception to the design gate).
tsc --noEmit clean; py_compile clean.

Follow-up: when the backlog (digests grow daily) approaches the cap, move to
server-side per-bucket counts + pagination/virtualization (design gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:34:36 +00:00

428 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { useMemo, useState } from "react";
import { Trash2, Upload, Pencil, ExternalLink, ChevronDown } from "lucide-react";
import { toast } from "sonner";
import Link from "next/link";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
useMissingPrecedents,
useDeleteMissingPrecedent,
CITED_BY_PARTY_LABELS,
STATUS_LABELS,
type CitedByParty,
type MissingPrecedent,
type MissingPrecedentStatus,
} from "@/lib/api/missing-precedents";
import { MissingPrecedentDetailDrawer } from "./missing-precedent-detail-drawer";
function formatDate(iso: string | null) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL");
} catch {
return iso;
}
}
/** Status chip — mockup 09 tones (open=warn, uploaded=info, closed=success,
* irrelevant=muted). Pill-shaped, whitespace-nowrap. */
function StatusBadge({ status }: { status: MissingPrecedentStatus }) {
const variants: Record<MissingPrecedentStatus, string> = {
open: "bg-warn-bg text-warn border-transparent",
uploaded: "bg-info-bg text-info border-transparent",
closed: "bg-success-bg text-success border-transparent",
irrelevant: "bg-rule-soft text-ink-muted border-transparent",
};
return (
<Badge variant="outline" className={`rounded-full whitespace-nowrap ${variants[status]}`}>
{STATUS_LABELS[status]}
</Badge>
);
}
/** Citing-party chip — colored by side (mockup 09 source chips). */
function SourceChip({ party }: { party: CitedByParty | null }) {
if (!party) return <span className="text-ink-muted text-sm"></span>;
const variants: Record<CitedByParty, string> = {
appellant: "bg-info-bg text-info border-transparent",
respondent: "bg-gold-wash text-gold-deep border-rule",
committee: "bg-success-bg text-success border-transparent",
permit_applicant: "bg-info-bg text-info border-transparent",
unknown: "bg-rule-soft text-ink-muted border-transparent",
};
return (
<Badge variant="outline" className={`rounded-full whitespace-nowrap ${variants[party]}`}>
{CITED_BY_PARTY_LABELS[party]}
</Badge>
);
}
const COLS = 7;
function TableHeaderRow() {
return (
<TableHeader className="bg-parchment">
<TableRow className="border-rule hover:bg-transparent">
<TableHead className="text-ink-muted text-right font-medium text-xs">פסיקה</TableHead>
<TableHead className="text-ink-muted text-right font-medium text-xs">נושא</TableHead>
<TableHead className="text-ink-muted text-right font-medium text-xs">תיק</TableHead>
<TableHead className="text-ink-muted text-right font-medium text-xs">צוטט ע״י</TableHead>
<TableHead className="text-ink-muted text-right font-medium text-xs">סטטוס</TableHead>
<TableHead className="text-ink-muted text-right font-medium text-xs">נוצר</TableHead>
<TableHead className="text-navy" />
</TableRow>
</TableHeader>
);
}
function TableSkeleton({ cols }: { cols: number }) {
return (
<>
{Array.from({ length: 4 }).map((_, i) => (
<TableRow key={i} className="border-rule">
{Array.from({ length: cols }).map((__, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))}
</>
);
}
/** Accordion section meta — groups rows by discovery source (chair's request).
* "צוטט ע״י דפנה" (corpus-decision reliance) is the high-value group and opens
* by default; the noisy יומון group and the misc "אחר" group start collapsed. */
type SectionKey = "chair" | "digest" | "other";
const SECTION_META: Record<
SectionKey,
{ label: string; title: string; desc: string; chip: string; defaultOpen: boolean }
> = {
chair: {
label: "צוטט ע״י דפנה",
title: "פסיקה שהוועדה נסמכת עליה",
desc: "מצוטטת בהחלטות-הקורפוס (גרף-הציטוטים)",
chip: "bg-success-bg text-success",
defaultOpen: true,
},
digest: {
label: "יומון",
title: "זוהתה ביומון יומי",
desc: "מצביע, טרם צוטט בהחלטה",
chip: "bg-teal-bg text-teal",
defaultOpen: false,
},
other: {
label: "אחר",
title: "כתב-טענות / ידני",
desc: "צוטט בערר חי או נרשם ידנית",
chip: "bg-info-bg text-info",
defaultOpen: false,
},
};
const SECTION_ORDER: SectionKey[] = ["chair", "digest", "other"];
type Props = {
status?: MissingPrecedentStatus | "";
q?: string;
legalTopic?: string;
};
export function MissingPrecedentsTable({ status, q, legalTopic }: Props) {
const [openId, setOpenId] = useState<string | null>(null);
const { data, isPending, error } = useMissingPrecedents({
status: status === "" ? undefined : status,
q,
legalTopic,
// Load the full set so the accordion section counts (chair/digest/other)
// reflect the real totals — at limit:200 the page showed only the first
// page (e.g. 16 of 106 committee rows) while the header showed the true
// count, so the sections didn't sum to it. Backend caps at 2000.
limit: 1000,
});
const del = useDeleteMissingPrecedent();
const handleDelete = async (mp: MissingPrecedent) => {
if (!confirm(`למחוק את הרשומה? ${mp.case_name || mp.citation.slice(0, 60)}...`)) {
return;
}
try {
await del.mutateAsync(mp.id);
toast.success("הרשומה נמחקה");
} catch (e) {
toast.error("מחיקה נכשלה");
console.error(e);
}
};
/* Partition the current result set by discovery source so each accordion
* section renders only its own rows. A row "cited by the committee" (has a
* resolved chair from the citation-graph bridge) wins over its raw
* discovery_source — that's the group the chair cares about. */
const groups = useMemo(() => {
const g: Record<SectionKey, MissingPrecedent[]> = { chair: [], digest: [], other: [] };
for (const mp of data?.items ?? []) {
if (mp.cited_by_chairs?.length) g.chair.push(mp);
else if (mp.discovery_source === "digest") g.digest.push(mp);
else g.other.push(mp);
}
return g;
}, [data]);
const renderRow = (mp: MissingPrecedent) => (
<TableRow
key={mp.id}
className="border-rule hover:bg-rule-soft/30 cursor-pointer"
onClick={() => setOpenId(mp.id)}
>
<TableCell className="max-w-[440px]">
{/* Single line when there's no distinct case_name — the old two-line
layout repeated the citation (name fell back to a truncation of the
same citation). Show the name row only when it adds information. */}
{mp.case_name && mp.case_name.trim() ? (
<>
<div className="text-sm text-navy font-semibold truncate">
{mp.case_name}
</div>
<div className="text-[0.72rem] text-ink-muted truncate" dir="rtl">
{mp.citation}
</div>
</>
) : (
<div className="text-sm text-navy font-semibold truncate" dir="rtl">
{mp.citation}
</div>
)}
</TableCell>
<TableCell>
<span className="text-sm text-ink">{mp.legal_topic || "—"}</span>
</TableCell>
<TableCell>
{mp.cited_by_decisions?.length ? (
/* committee decision(s) that cite this missing ruling
(corpus citation-graph bridge) */
<span className="inline-flex items-baseline gap-1.5">
<span className="text-sm text-navy font-semibold tabular-nums" dir="ltr">
{mp.cited_by_decisions[0]}
</span>
{mp.cited_by_decisions.length > 1 ? (
<span className="text-[0.7rem] text-ink-muted">
+{mp.cited_by_decisions.length - 1}
</span>
) : null}
</span>
) : mp.cited_in_case_number ? (
<Link
href={`/cases/${encodeURIComponent(mp.cited_in_case_number)}`}
onClick={(e) => e.stopPropagation()}
className="text-sm text-navy hover:text-gold-deep inline-flex items-center gap-1"
>
{mp.cited_in_case_number}
<ExternalLink className="w-3 h-3" />
</Link>
) : (
<span className="text-ink-muted text-sm"></span>
)}
</TableCell>
<TableCell className="text-sm text-ink">
{mp.cited_by_chairs?.length ? (
/* cited by a committee DECISION in the corpus → show the chair who
relied on it (e.g. דפנה תמיר). Bridge from
precedent_internal_citations; takes priority over the generic
discovery-source chips. */
<>
<Badge
variant="outline"
className="rounded-full whitespace-nowrap bg-success-bg text-success border-transparent"
>
{mp.cited_by_chairs[0]}
</Badge>
{mp.cited_by_chairs.length > 1 ? (
<div className="text-[0.7rem] text-ink-muted mt-1">
+{mp.cited_by_chairs.length - 1} יו״ר
</div>
) : null}
</>
) : mp.discovery_source === "cited_only" ? (
<>
<Badge
variant="outline"
className="rounded-full whitespace-nowrap bg-plum-bg text-plum border-transparent"
>
פסיקה בקורפוס
</Badge>
{mp.cited_by_precedents?.length ? (
<div className="text-[0.7rem] text-ink-muted truncate max-w-[180px] mt-1">
מצוטט ע״י: {mp.cited_by_precedents.join(", ")}
</div>
) : null}
</>
) : mp.discovery_source === "digest" ? (
<>
<Badge
variant="outline"
className="rounded-full whitespace-nowrap bg-teal-bg text-teal border-transparent"
>
יומון
</Badge>
{mp.yomon_number ? (
<div className="text-[0.7rem] text-ink-muted mt-1">
מס׳ {mp.yomon_number}
</div>
) : null}
</>
) : (
<>
<SourceChip party={mp.cited_by_party} />
{mp.cited_by_party_name ? (
<div className="text-[0.7rem] text-ink-muted truncate max-w-[160px] mt-1">
{mp.cited_by_party_name}
</div>
) : null}
</>
)}
</TableCell>
<TableCell>
<StatusBadge status={mp.status} />
{mp.linked_case_law_number ? (
<div className="text-[0.7rem] text-success mt-1">
{mp.linked_case_law_name || mp.linked_case_law_number}
</div>
) : null}
</TableCell>
<TableCell className="text-[0.78rem] text-ink-muted">
{formatDate(mp.created_at)}
</TableCell>
<TableCell className="text-end">
<div className="flex items-center justify-end gap-2">
{mp.status === "open" ? (
/* gold "העלה והשלם" CTA (mockup 09 `.btn`) */
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
setOpenId(mp.id);
}}
className="h-7 bg-gold text-white hover:bg-gold-deep border-transparent text-[0.78rem] font-semibold"
>
<Upload className="w-3.5 h-3.5 me-1" />
העלה והשלם
</Button>
) : (
/* passive "done" label for non-open rows */
<span className="inline-flex items-center gap-1 rounded-md bg-rule-soft text-ink-muted text-[0.78rem] font-medium px-2.5 py-1">
{mp.status === "closed" ? "קושר" : STATUS_LABELS[mp.status]}
</span>
)}
{mp.status !== "open" ? (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setOpenId(mp.id);
}}
title="פרטים"
>
<Pencil className="w-4 h-4" />
</Button>
) : null}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDelete(mp);
}}
disabled={del.isPending}
className="text-danger hover:text-danger"
title="מחיקה"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
if (error) {
return (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
{error.message}
</div>
);
}
if (isPending) {
return (
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
<Table>
<TableHeaderRow />
<TableBody>
<TableSkeleton cols={COLS} />
</TableBody>
</Table>
</div>
);
}
if (!data?.items.length) {
return (
<div className="rounded-lg border border-rule bg-surface shadow-sm px-6 py-10 text-center text-ink-muted text-sm">
אין פסיקות חסרות בקריטריונים הנוכחיים.
</div>
);
}
return (
<>
<div className="space-y-3.5">
{SECTION_ORDER.map((key) => {
const items = groups[key];
if (!items.length) return null;
const meta = SECTION_META[key];
return (
<details
key={key}
open={meta.defaultOpen}
className="group rounded-lg border border-rule bg-surface shadow-sm overflow-hidden"
>
<summary className="flex items-center gap-3 px-4 py-3.5 bg-parchment cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
<ChevronDown className="w-4 h-4 text-ink-muted transition-transform -rotate-90 group-open:rotate-0 shrink-0" />
<span className={`rounded-full px-2.5 py-0.5 text-[0.72rem] font-semibold whitespace-nowrap ${meta.chip}`}>
{meta.label}
</span>
<span className="font-bold text-navy text-sm">{meta.title}</span>
<span className="hidden sm:inline text-ink-muted text-[0.78rem]">
{meta.desc}
</span>
<span className="ms-auto tabular-nums text-[0.8rem] text-ink-soft bg-surface border border-rule rounded-full px-2.5 py-0.5 font-semibold">
{items.length}
</span>
</summary>
<Table>
<TableHeaderRow />
<TableBody>{items.map(renderRow)}</TableBody>
</Table>
</details>
);
})}
</div>
<MissingPrecedentDetailDrawer
id={openId}
onOpenChange={(open) => {
if (!open) setOpenId(null);
}}
/>
</>
);
}