feat(missing-precedents): accordion grouping by discovery source
Chair-approved (Claude Design card 09): split the flat table into collapsible
sections by where the gap surfaced, so the high-value "cited by Dafna" rulings
aren't buried among the 330 digest rows.
- Three <details> accordion sections within the active status tab:
· "צוטט ע״י דפנה" — rows with a resolved chair from the citation-graph bridge
(cited_by_chairs non-empty); open by default.
· "יומון" — discovery_source='digest'; collapsed.
· "אחר" — party-cited / manual remainder; collapsed.
- Row JSX extracted to renderRow() and reused across sections; shared
TableHeaderRow(); grouping via useMemo over the current result set.
- Empty sections are hidden; section header shows source chip + count.
No backend change — groups on existing cited_by_chairs / discovery_source.
tsc --noEmit clean.
Invariants: INV-IA* (task-oriented surface, single gate) — UI only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Trash2, Upload, Pencil, ExternalLink } from "lucide-react";
|
import { Trash2, Upload, Pencil, ExternalLink, ChevronDown } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +63,24 @@ function SourceChip({ party }: { party: CitedByParty | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }) {
|
function TableSkeleton({ cols }: { cols: number }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -79,6 +97,38 @@ function TableSkeleton({ cols }: { cols: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 = {
|
type Props = {
|
||||||
status?: MissingPrecedentStatus | "";
|
status?: MissingPrecedentStatus | "";
|
||||||
q?: string;
|
q?: string;
|
||||||
@@ -108,50 +158,30 @@ export function MissingPrecedentsTable({ status, q, legalTopic }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error) {
|
/* Partition the current result set by discovery source so each accordion
|
||||||
return (
|
* section renders only its own rows. A row "cited by the committee" (has a
|
||||||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
|
* resolved chair from the citation-graph bridge) wins over its raw
|
||||||
{error.message}
|
* discovery_source — that's the group the chair cares about. */
|
||||||
</div>
|
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]);
|
||||||
|
|
||||||
return (
|
const renderRow = (mp: MissingPrecedent) => (
|
||||||
<>
|
|
||||||
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
|
||||||
<Table>
|
|
||||||
<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>
|
|
||||||
<TableBody>
|
|
||||||
{isPending ? (
|
|
||||||
<TableSkeleton cols={7} />
|
|
||||||
) : !data?.items.length ? (
|
|
||||||
<TableRow className="border-rule">
|
|
||||||
<TableCell colSpan={7} className="text-center text-ink-muted py-8">
|
|
||||||
אין פסיקות חסרות בקריטריונים הנוכחיים.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
data.items.map((mp) => (
|
|
||||||
<TableRow
|
<TableRow
|
||||||
key={mp.id}
|
key={mp.id}
|
||||||
className="border-rule hover:bg-rule-soft/30 cursor-pointer"
|
className="border-rule hover:bg-rule-soft/30 cursor-pointer"
|
||||||
onClick={() => setOpenId(mp.id)}
|
onClick={() => setOpenId(mp.id)}
|
||||||
>
|
>
|
||||||
<TableCell className="max-w-[440px]">
|
<TableCell className="max-w-[440px]">
|
||||||
{/* Single line when there's no distinct case_name — the old
|
{/* Single line when there's no distinct case_name — the old two-line
|
||||||
two-line layout repeated the citation (name fell back to a
|
layout repeated the citation (name fell back to a truncation of the
|
||||||
truncation of the same citation). Show the name row only
|
same citation). Show the name row only when it adds information. */}
|
||||||
when it adds information. */}
|
|
||||||
{mp.case_name && mp.case_name.trim() ? (
|
{mp.case_name && mp.case_name.trim() ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-sm text-navy font-semibold truncate">
|
<div className="text-sm text-navy font-semibold truncate">
|
||||||
@@ -199,10 +229,10 @@ export function MissingPrecedentsTable({ status, q, legalTopic }: Props) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-ink">
|
<TableCell className="text-sm text-ink">
|
||||||
{mp.cited_by_chairs?.length ? (
|
{mp.cited_by_chairs?.length ? (
|
||||||
/* cited by a committee DECISION in the corpus → show the
|
/* cited by a committee DECISION in the corpus → show the chair who
|
||||||
chair who relied on it (e.g. דפנה תמיר). Bridge from
|
relied on it (e.g. דפנה תמיר). Bridge from
|
||||||
precedent_internal_citations; takes priority over the
|
precedent_internal_citations; takes priority over the generic
|
||||||
generic discovery-source chips. */
|
discovery-source chips. */
|
||||||
<>
|
<>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -316,11 +346,71 @@ export function MissingPrecedentsTable({ status, q, legalTopic }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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
|
<MissingPrecedentDetailDrawer
|
||||||
id={openId}
|
id={openId}
|
||||||
|
|||||||
Reference in New Issue
Block a user