Implements the chair-approved redesign of /missing-precedents (Claude Design card 09). The "צוטט ע״י" and "תיק" columns were empty for ~98% of rows because the corpus-decision reliance lives in precedent_internal_citations (keyed to case_law), a different system than missing_precedents (keyed to cases). Backend (G2 — read-time bridge, no stored duplication): - list_missing_precedents: new cited_by_chairs / cited_by_decisions columns, resolved from the citation graph by normalized docket number (same normalization the relinker uses). For an open gap cited by committee decisions, surfaces the chair(s) (e.g. דפנה תמיר) + the deciding case numbers. Frontend (matches approved mockup): - "צוטט ע״י" shows the chair chip (bridge) with priority over the generic discovery-source chips; "תיק" shows the deciding decision number(s) + "+N". - Status filter reduced to פתוח / נסגר (removed הועלה, לא-רלוונטי, הכל). - "פסיקה" column collapses to a single line when case_name is empty — fixes the duplicated-citation two-row rendering. - Lifecycle note simplified to open → closed. Verified: bridge SQL returns chair+decisions on live data; tsc --noEmit clean; py_compile clean. Follow-up (data, separate): LLM backfill of legal_topic (נושא); create missing_precedents rows for the 28 graph-cited rulings not yet tracked. Invariants: G2 (single source of truth — bridge derived at read, no duplicated column) · INV-AH context (closing source gaps). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
182 lines
7.2 KiB
TypeScript
182 lines
7.2 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import Link from "next/link";
|
||
import { AppShell } from "@/components/app-shell";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
useMissingPrecedents,
|
||
type MissingPrecedentStatus,
|
||
} from "@/lib/api/missing-precedents";
|
||
import { MissingPrecedentsTable } from "@/components/missing-precedents/missing-precedents-table";
|
||
|
||
/**
|
||
* Missing-precedents page (TaskMaster #35).
|
||
*
|
||
* Surfaces citations that party briefs invoke but which aren't yet in the
|
||
* precedent_library. A status filter (chips) narrows the table; each row uses
|
||
* the same table component. Drawer (sheet) opens on row click with metadata +
|
||
* upload form that routes to internal_decision_upload (ערר/בל"מ citations) or
|
||
* precedent_library_upload (court rulings).
|
||
*/
|
||
|
||
type StatusFilter = MissingPrecedentStatus | "all";
|
||
|
||
// Only the two states the chair acts on: open gaps to fill, and closed gaps for
|
||
// reference. "הועלה" (transient) and "לא-רלוונטי" were dropped from the filter,
|
||
// and "הכל" with them (chair's request, design 09).
|
||
const STATUS_CHIPS: { value: StatusFilter; label: string }[] = [
|
||
{ value: "open", label: "פתוח" },
|
||
{ value: "closed", label: "נסגר" },
|
||
];
|
||
|
||
export default function MissingPrecedentsPage() {
|
||
const [search, setSearch] = useState("");
|
||
const [legalTopic, setLegalTopic] = useState("");
|
||
const [filter, setFilter] = useState<StatusFilter>("open");
|
||
|
||
/* Debounce the filters so the table fires one query after the user stops
|
||
* typing — not one per keystroke. The search term matches the gap's own
|
||
* מראה-מקום, case name, and cited-in appeal number (server-side ILIKE). */
|
||
const [searchQ, setSearchQ] = useState("");
|
||
const [legalTopicQ, setLegalTopicQ] = useState("");
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setSearchQ(search.trim()), 350);
|
||
return () => clearTimeout(t);
|
||
}, [search]);
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setLegalTopicQ(legalTopic.trim()), 350);
|
||
return () => clearTimeout(t);
|
||
}, [legalTopic]);
|
||
|
||
const counts = useMissingPrecedents({ limit: 1 });
|
||
const byStatus = counts.data?.by_status ?? {};
|
||
|
||
return (
|
||
<AppShell>
|
||
<section className="space-y-6">
|
||
<header className="space-y-3">
|
||
<nav className="text-[0.78rem] text-ink-muted">
|
||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||
<span aria-hidden> · </span>
|
||
<span className="text-navy">פסיקה חסרה בקורפוס</span>
|
||
</nav>
|
||
|
||
{/* title + inline open-count pill (mockup 09 `.open-count`) */}
|
||
<div className="flex items-baseline gap-3.5 flex-wrap">
|
||
<h1 className="text-navy mb-0">פסיקה חסרה בקורפוס</h1>
|
||
{byStatus.open ? (
|
||
<span className="inline-flex items-baseline gap-1.5 rounded-lg border border-rule bg-warn-bg px-3.5 py-1">
|
||
<span className="text-lg font-bold text-warn tabular-nums leading-none">
|
||
{byStatus.open}
|
||
</span>
|
||
<span className="text-[0.8rem] text-ink-soft">פתוחים</span>
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<p className="text-ink-muted text-sm max-w-3xl leading-relaxed">
|
||
פסיקה שצוטטה בכתבי-הטענות אך אינה קיימת בקורפוס. השלמתה מאפשרת
|
||
אימות-הלכה ועיגון-מקור (INV-AH). סוכן המחקר רושם פערים אוטומטית;
|
||
היו"ר סוגר אותם על־ידי העלאת המסמך — ניתוב אוטומטי בין הקורפוס
|
||
הסמכותי (פסקי דין) להחלטות ועדות ערר.
|
||
</p>
|
||
</header>
|
||
|
||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||
|
||
{/* shared filters */}
|
||
<div className="flex items-end gap-3 flex-wrap">
|
||
<div className="flex-1 min-w-[200px]">
|
||
<label className="block text-[0.78rem] text-ink-muted mb-1.5">מספר תיק</label>
|
||
<Input
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder="85074 או 1017-03-26"
|
||
dir="rtl"
|
||
/>
|
||
</div>
|
||
<div className="flex-1 min-w-[200px]">
|
||
<label className="block text-[0.78rem] text-ink-muted mb-1.5">נושא משפטי</label>
|
||
<Input
|
||
value={legalTopic}
|
||
onChange={(e) => setLegalTopic(e.target.value)}
|
||
placeholder="זכות עמידה"
|
||
dir="rtl"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* status filter chips (mockup 09 `.filters`) — active = navy filled */}
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{STATUS_CHIPS.map((c) => {
|
||
const active = filter === c.value;
|
||
const count =
|
||
c.value === "all"
|
||
? undefined
|
||
: (byStatus[c.value as MissingPrecedentStatus] ?? 0);
|
||
return (
|
||
<button
|
||
key={c.value}
|
||
type="button"
|
||
onClick={() => setFilter(c.value)}
|
||
aria-pressed={active}
|
||
className={`rounded-full border px-4 py-1.5 text-[0.82rem] transition-colors ${
|
||
active
|
||
? "bg-navy text-white border-navy font-semibold"
|
||
: "bg-surface text-ink-soft border-rule font-medium hover:bg-rule-soft/50"
|
||
}`}
|
||
>
|
||
{c.label}
|
||
{count ? (
|
||
<span className="ms-1.5 tabular-nums opacity-80">({count})</span>
|
||
) : null}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<MissingPrecedentsTable
|
||
status={filter === "all" ? "" : filter}
|
||
q={searchQ || undefined}
|
||
legalTopic={legalTopicQ || undefined}
|
||
/>
|
||
|
||
{/* lifecycle note (mockup 09 `.lifecycle`) */}
|
||
<div className="rounded-lg border border-rule bg-parchment px-5 py-3.5 text-[0.82rem] text-ink-muted leading-7">
|
||
<b className="text-ink-soft">מחזור-חיים:</b>{" "}
|
||
<LifecycleChip tone="open">פתוח</LifecycleChip> →{" "}
|
||
<LifecycleChip tone="closed">נסגר</LifecycleChip>. פריט נפתח אוטומטית
|
||
בעת חילוץ ציטוט שאין לו תקדים בקורפוס; בהעלאת פסק-הדין הוא מקושר לרשומת
|
||
הפסיקה דרך{" "}
|
||
<code className="rounded border border-rule bg-surface px-1.5 py-0.5 text-[0.75rem] text-gold-deep" dir="ltr">
|
||
linked_case_law_id
|
||
</code>{" "}
|
||
ונסגר.
|
||
</div>
|
||
</section>
|
||
</AppShell>
|
||
);
|
||
}
|
||
|
||
type LifecycleTone = "open" | "up" | "closed" | "na";
|
||
|
||
function LifecycleChip({
|
||
tone,
|
||
children,
|
||
}: {
|
||
tone: LifecycleTone;
|
||
children: React.ReactNode;
|
||
}) {
|
||
const cls: Record<LifecycleTone, string> = {
|
||
open: "bg-warn-bg text-warn",
|
||
up: "bg-info-bg text-info",
|
||
closed: "bg-success-bg text-success",
|
||
na: "bg-rule-soft text-ink-muted",
|
||
};
|
||
return (
|
||
<span className={`inline-block rounded-full px-2.5 py-0.5 text-[0.72rem] font-semibold ${cls[tone]}`}>
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|