Storage stays UTC (DB TIMESTAMPTZ, API ISO-UTC) — only the display layer is localized, and now deterministically: every timestamp renders pinned to Asia/Jerusalem via a single Intl-based formatter, so SSR (UTC container) and the browser agree on any runtime. No layout/visible-format change — only the tz. - New single date formatter web-ui/src/lib/format-date.ts (G2): formatDate / formatDateShort / formatDateLong / formatDateTime / formatDateTimeFull / formatTime / formatIsoDate + Israel helpers getIsraelYear / israelDayKey / israelMidnightMs / israelParts + formatRelative (long/short/tight wording). - Routed all ad-hoc toLocaleDateString/toLocaleTimeString/toLocaleString + hand-rolled new Date(...).get*() / toISOString().slice(0,10) timestamp call sites (30 files) through it. Number toLocaleString left untouched. - Spec: added INV-UI9 to docs/spec/X6 (UTC storage, Asia/Jerusalem display). Display-only; no layout/design/IA change → Claude Design gate N/A. Invariants: G2 (single date formatter, no parallel ad-hoc formatting), INV-UI9. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
"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";
|
||
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||
|
||
/** 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);
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|