Files
legal-ai/web-ui/src/app/missing-precedents/page.tsx
Chaim b49cde7c24
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
feat(missing-precedents): חיפוש-טקסט לפי מראה-מקום בדף פסיקה-חסרה
הבעיה: בדף /missing-precedents לא ניתן היה לאתר פסיקה חסרה לפי מספר ההחלטה
החסרה עצמה (למשל 85074). השדה היחיד לחיפוש-תיק עשה get_case_by_number על
מספר ה-ערר שבו צוטטה הפסיקה — ולכן הקלדת מספר-הפסיקה החזירה רשימה ריקה,
למרות שהרשומה קיימת (ערר (ת"א 85074-04-25) ... status=open).

התיקון (הרחבת השדה הקיים, ללא עמוד/שדה חדש — בהנחיית חיים):
- db.list_missing_precedents: פרמטר q חדש — ILIKE על mp.citation +
  mp.case_name + cited-in c.case_number (אינדקס-פרמטר יחיד, additive;
  שאר הקוראים לא נוגעים).
- GET /api/missing-precedents: פרמטר q; case_id/case_number נשארים
  מסננים-מדויקים לקוראים תכנותיים.
- web-ui: התווית "תיק (מספר ערר)" → "מספר תיק", placeholder
  "85074 או 1017-03-26"; השדה שולח q (חיפוש חופשי) במקום case_number.
  Debounce 350ms נשמר.

api:types לא חודש: ה-hook בונה את ה-querystring ידנית וה-response לא
השתנה; חידוש מול prod (שעוד לא נפרס) רק היה מושך drift לא-קשור.

בדיקות: tsc --noEmit נקי, eslint נקי על הקבצים שהשתנו, py_compile נקי.

Invariants: G2 (הרחבת היכולת הקיימת, לא מסלול-חיפוש מקביל), INV-IA1
(שער/דף יחיד לפסיקה-חסרה — בלי עמוד חדש), §6 (ללא בליעת-שגיאות).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:58:29 +00:00

184 lines
7.3 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";
const STATUS_CHIPS: { value: StatusFilter; label: string }[] = [
{ value: "open", label: "פתוח" },
{ value: "uploaded", label: "הועלה" },
{ value: "closed", label: "נסגר" },
{ value: "irrelevant", label: "לא-רלוונטי" },
{ value: "all", 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="up">הועלה</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>{" "}
ונסגר. פריט שאינו רלוונטי מסומן{" "}
<LifecycleChip tone="na">לא-רלוונטי</LifecycleChip> מבלי שתידרש העלאה.
</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>
);
}