Files
legal-ai/web-ui/src/app/missing-precedents/page.tsx
Chaim a18ed8ffb7
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
feat(missing-precedents): bridge cited-by-chair + decision number; open/closed tabs; single-line citation
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>
2026-06-20 12:28:38 +00:00

182 lines
7.2 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 { 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). סוכן המחקר רושם פערים אוטומטית;
היו&quot;ר סוגר אותם על־ידי העלאת המסמך ניתוב אוטומטי בין הקורפוס
הסמכותי (פסקי דין) להחלטות ועדות ערר.
</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>
);
}