Merge pull request 'feat(missing-precedents): accordion grouping by discovery source' (#312) from worktree-mp-accordion into main
This commit was merged in pull request #312.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Trash2, Upload, Pencil, ExternalLink } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Trash2, Upload, Pencil, ExternalLink, ChevronDown } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
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 }) {
|
||||
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 = {
|
||||
status?: MissingPrecedentStatus | "";
|
||||
q?: string;
|
||||
@@ -108,6 +158,196 @@ export function MissingPrecedentsTable({ status, q, legalTopic }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
/* 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">
|
||||
@@ -116,211 +356,61 @@ export function MissingPrecedentsTable({ status, q, legalTopic }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
if (isPending) {
|
||||
return (
|
||||
<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>
|
||||
<TableHeaderRow />
|
||||
<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
|
||||
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>
|
||||
))
|
||||
)}
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user