Files
legal-ai/web-ui/src/components/missing-precedents/missing-precedent-detail-drawer.tsx
Chaim 0629f19d5f
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m21s
ui(missing-precedents): drawer = notes + upload only
The drawer was showing a full metadata form (legal topic, case name,
legal issue, cited-by-party + name, status) — most of it duplicated
fields that get auto-extracted from the file once it's uploaded, or
that are already known from when the row was detected. The visible
placeholder text ('לינדאב בע"מ', 'אנטרים', 'זכות עמידה') looked like
real data and confused readers.

Strip the form down to a single "הערות" textarea — that's the only
field the chair actually needs to edit. Reasons for who cited the
decision and in what context belong there too. Everything else (shape
of the precedent on the case_law side) is the LLM extractor's job.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:58:23 +00:00

415 lines
17 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 { Upload, Save, Loader2, CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
useMissingPrecedent,
useUpdateMissingPrecedent,
useUploadMissingPrecedent,
STATUS_LABELS,
type MissingPrecedentPatch,
} from "@/lib/api/missing-precedents";
import {
PRACTICE_AREAS, PRECEDENT_LEVELS, DISTRICTS,
} from "@/components/precedents/practice-area";
type Props = {
id: string | null;
onOpenChange: (open: boolean) => void;
};
const ACCEPT = ".pdf,.docx,.doc,.rtf,.txt,.md";
function isCommitteeCitation(citation: string): boolean {
const norm = citation.trim();
return /^(ערר[\s(]|בל"מ[\s(]|ARAR )/.test(norm);
}
export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) {
const open = id !== null;
const { data: mp, isPending } = useMissingPrecedent(id);
const update = useUpdateMissingPrecedent();
const upload = useUploadMissingPrecedent();
// The only chair-editable field on the missing-precedent is `notes` —
// free-text. Everything else (citation, who-cited-whom, status) is set
// when the row was detected, and updates automatically when the file
// is uploaded. The metadata of the *uploaded* precedent (case_name,
// chair, district, …) is auto-extracted by the LLM and lives on the
// case_law row, not here.
const [notes, setNotes] = useState("");
// Upload form fields.
const [file, setFile] = useState<File | null>(null);
const [decisionDate, setDecisionDate] = useState("");
const [court, setCourt] = useState("");
const [practiceArea, setPracticeArea] = useState<string>("");
const [appealSubtype, setAppealSubtype] = useState("");
const [precedentLevel, setPrecedentLevel] = useState("");
const [chairName, setChairName] = useState("");
const [district, setDistrict] = useState("");
const [committeeCaseNumber, setCommitteeCaseNumber] = useState("");
const [summary, setSummary] = useState("");
// Sync form from record when it loads or id changes.
const [syncedId, setSyncedId] = useState<string | null>(null);
if (mp && mp.id !== syncedId) {
setSyncedId(mp.id);
setNotes(mp.notes ?? "");
}
// Reset on close. The cascading-render warning is the intended side
// effect here — wiping the form when the drawer closes.
useEffect(() => {
if (open) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setFile(null);
setSyncedId(null);
setDecisionDate(""); setCourt(""); setPracticeArea("");
setAppealSubtype(""); setPrecedentLevel(""); setChairName("");
setDistrict(""); setCommitteeCaseNumber(""); setSummary("");
}, [open]);
const handleSaveNotes = async () => {
if (!mp) return;
const patch: MissingPrecedentPatch = { notes };
try {
await update.mutateAsync({ id: mp.id, patch });
toast.success("הערות נשמרו");
} catch (e) {
toast.error("שמירה נכשלה");
console.error(e);
}
};
const isCommittee = mp ? isCommitteeCitation(mp.citation) : false;
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault();
if (!mp || !file) {
toast.error("בחר קובץ");
return;
}
try {
await upload.mutateAsync({
id: mp.id,
file,
case_number: isCommittee ? committeeCaseNumber || undefined : undefined,
chair_name: isCommittee ? chairName || undefined : undefined,
district: isCommittee ? district || undefined : undefined,
court: court || undefined,
decision_date: decisionDate || undefined,
practice_area: practiceArea || undefined,
appeal_subtype: appealSubtype || undefined,
precedent_level: precedentLevel || undefined,
source_type: isCommittee ? "appeals_committee" : "court_ruling",
summary: summary || undefined,
});
toast.success(
"הקובץ הועלה. חילוץ המטא־דאטה (שם, ערכאה, תאריך, יו״ר, מחוז…) מתבצע ברקע ויסתיים בתוך כדקה.",
);
onOpenChange(false);
} catch (e: unknown) {
const msg =
e instanceof Error
? e.message
: typeof e === "string"
? e
: "כשל העלאה";
toast.error(msg);
console.error(e);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="left"
className="w-full sm:max-w-2xl overflow-y-auto"
>
<SheetHeader className="space-y-1">
<SheetTitle className="text-navy">
פסיקה חסרה
{mp ? (
<Badge
variant="outline"
className="ms-2 align-middle"
>
{STATUS_LABELS[mp.status]}
</Badge>
) : null}
</SheetTitle>
<SheetDescription>
פרטים מלאים והעלאת הפסיקה לקורפוס.
</SheetDescription>
</SheetHeader>
{isPending || !mp ? (
<div className="space-y-3 px-6 py-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
) : (
<div className="space-y-6 px-6 py-4">
{/* ── Citation block (read-only) ── */}
<section className="space-y-2">
<div className="text-[0.78rem] text-ink-muted">מראה מקום</div>
<div className="text-sm text-navy font-medium bg-rule-soft/40 rounded-md px-3 py-2 leading-relaxed">
{mp.citation}
</div>
{mp.claim_quote ? (
<>
<div className="text-[0.78rem] text-ink-muted mt-3">ציטוט מכתב הטענות</div>
<div className="text-xs text-ink bg-gold-wash/30 border-s-2 border-gold rounded-md px-3 py-2 leading-relaxed">
{mp.claim_quote}
</div>
</>
) : null}
</section>
{/* ── Linked record (if closed) ── */}
{mp.linked_case_law_id ? (
<section className="space-y-1 bg-emerald-50 border border-emerald-200 rounded-lg p-3">
<div className="flex items-center gap-2 text-emerald-800 font-medium text-sm">
<CheckCircle2 className="w-4 h-4" />
מקושר ל
</div>
<div className="text-sm text-emerald-900 truncate">
{mp.linked_case_law_name || "—"}
</div>
<div className="text-[0.72rem] text-emerald-700 truncate">
{mp.linked_case_law_number}
</div>
</section>
) : null}
{/* ── Notes (only chair-editable field; everything else is
auto-detected or auto-extracted from the file). ── */}
<section className="space-y-2">
<Label htmlFor="notes" className="text-sm font-semibold text-navy">
הערות
</Label>
<p className="text-[0.72rem] text-ink-muted leading-relaxed">
שדה חופשי לדוגמה: מי מצטט (הוועדה / העורר / המשיב) ובאיזה הקשר.
שאר השדות (שם, ערכאה, יו״ר, מחוז, תאריך, תת־סוג, תקציר) יחולצו
אוטומטית מהקובץ בעת ההעלאה.
</p>
<Textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
dir="rtl"
/>
<Button
onClick={handleSaveNotes}
disabled={update.isPending}
variant="outline"
size="sm"
className="border-rule"
>
{update.isPending ? (
<Loader2 className="w-4 h-4 me-1 animate-spin" />
) : (
<Save className="w-4 h-4 me-1" />
)}
שמור הערות
</Button>
</section>
{/* ── Upload section ── */}
{!mp.linked_case_law_id ? (
<section className="space-y-3 border-t border-rule pt-5">
<h3 className="text-sm font-semibold text-navy">
העלאת הפסיקה לקורפוס
</h3>
<div className="text-[0.78rem] text-ink-muted leading-relaxed">
ניתוב אוטומטי לפי הציטוט:&nbsp;
<strong className="text-navy">
{isCommittee ? "החלטת ועדת ערר (internal)" : "פסק דין (library)"}
</strong>
<br />
שדות נוספים (שם, ערכאה, תאריך, יו״ר, מחוז, תת־סוג) יחולצו אוטומטית
מהקובץ ברקע.
</div>
<form onSubmit={handleUpload} className="space-y-3">
<div>
<Label htmlFor="file">קובץ (PDF / DOCX / RTF / TXT / MD)</Label>
<Input
id="file"
type="file"
accept={ACCEPT}
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
required
/>
</div>
<details className="group rounded-md border border-rule bg-rule-soft/30">
<summary className="cursor-pointer select-none px-3 py-2 text-[0.78rem] text-ink-muted hover:text-navy">
אופציונלי דריסה ידנית של שדות שיחולצו אוטומטית
</summary>
<div className="space-y-3 border-t border-rule px-3 py-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="court">ערכאה</Label>
<Input
id="court"
value={court}
onChange={(e) => setCourt(e.target.value)}
placeholder="בית המשפט העליון"
dir="rtl"
/>
</div>
<div>
<Label htmlFor="decision_date">תאריך</Label>
<Input
id="decision_date"
type="date"
value={decisionDate}
onChange={(e) => setDecisionDate(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="practice_area">תחום</Label>
<Select value={practiceArea} onValueChange={setPracticeArea}>
<SelectTrigger>
<SelectValue placeholder="ללא" />
</SelectTrigger>
<SelectContent>
{PRACTICE_AREAS.map((a) => (
<SelectItem key={a.value} value={a.value}>
{a.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="appeal_subtype">תת־סוג</Label>
<Input
id="appeal_subtype"
value={appealSubtype}
onChange={(e) => setAppealSubtype(e.target.value)}
placeholder="זכות עמידה"
dir="rtl"
/>
</div>
</div>
{isCommittee ? (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="chair_name">יו״ר</Label>
<Input
id="chair_name"
value={chairName}
onChange={(e) => setChairName(e.target.value)}
placeholder="דפנה תמיר"
dir="rtl"
/>
</div>
<div>
<Label htmlFor="district">מחוז</Label>
<Select value={district} onValueChange={setDistrict}>
<SelectTrigger>
<SelectValue placeholder="בחר" />
</SelectTrigger>
<SelectContent>
{DISTRICTS.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="committee_case_number">
מספר ערר (לציטוט הקטן)
</Label>
<Input
id="committee_case_number"
value={committeeCaseNumber}
onChange={(e) => setCommitteeCaseNumber(e.target.value)}
placeholder="ערר 1112/22 ..."
dir="rtl"
/>
</div>
</>
) : (
<div>
<Label htmlFor="precedent_level">רמת תקדים</Label>
<Select
value={precedentLevel}
onValueChange={setPrecedentLevel}
>
<SelectTrigger>
<SelectValue placeholder="ללא" />
</SelectTrigger>
<SelectContent>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label htmlFor="summary">תקציר</Label>
<Textarea
id="summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={2}
dir="rtl"
/>
</div>
</div>
</details>
<Button
type="submit"
disabled={!file || upload.isPending}
className="bg-navy text-parchment hover:bg-navy-soft"
>
{upload.isPending ? (
<Loader2 className="w-4 h-4 me-1 animate-spin" />
) : (
<Upload className="w-4 h-4 me-1" />
)}
העלאה וסגירה
</Button>
</form>
</section>
) : null}
</div>
)}
</SheetContent>
</Sheet>
);
}