Files
legal-ai/web-ui/src/components/precedents/precedent-edit-sheet.tsx
Chaim 7be1c3162c fix(#77 frontend): separate מספר-תיק field on committee upload + editable case_number in edit sheet
Pairs with the backend PR. Stops the citation (מראה-מקום) from being stored
as the identifier, and lets a wrong identifier be corrected after the fact.

- upload sheet: new required 'מספר תיק (מזהה ייחודי)' field for committee
  decisions → sent as case_number; the citation field is now sent as the
  separate citation (→ citation_formatted) instead of as case_number.
- edit sheet: the case_number block is now an editable input (was read-only).
  Halachot/chunks key off case_law_id (UUID), so renaming case_number is safe.
- precedent-library.ts: InternalDecisionUploadInput += citation; PrecedentPatch
  += case_number.
- types.ts: regenerated (api:types) — PrecedentUpdateRequest now carries
  case_number.

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

358 lines
16 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 { useState } from "react";
import { Save, Sparkles } from "lucide-react";
import { toast } from "sonner";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "@/components/ui/dialog";
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 { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
usePrecedent,
useUpdatePrecedent,
useRequestMetadataExtraction,
type PracticeArea,
type SourceType,
} from "@/lib/api/precedent-library";
import {
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, DISTRICTS, appealSubtypeLabel,
} from "./practice-area";
import { ExtractedHalachotSection } from "./extracted-halachot";
type Props = {
caseLawId: string | null;
onOpenChange: (open: boolean) => void;
};
/* All editable fields. Pulled fresh from /api/precedent-library/{id}
* each time the sheet opens so the form reflects any auto-fill that
* happened in the background. */
type FormState = {
case_number: string;
citation_formatted: string;
case_name: string;
court: string;
district: string;
chair_name: string;
decision_date: string;
practice_area: PracticeArea;
appeal_subtype: string;
source_type: SourceType;
precedent_level: string;
is_binding: boolean;
subject_tags: string;
summary: string;
headnote: string;
key_quote: string;
};
const EMPTY: FormState = {
case_number: "", citation_formatted: "",
case_name: "", court: "", district: "", chair_name: "",
decision_date: "", practice_area: "", appeal_subtype: "", source_type: "",
precedent_level: "", is_binding: true, subject_tags: "",
summary: "", headnote: "", key_quote: "",
};
export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
const open = caseLawId !== null;
const { data: record, isPending } = usePrecedent(caseLawId);
const update = useUpdatePrecedent();
const requestMetadata = useRequestMetadataExtraction();
const [form, setForm] = useState<FormState>(EMPTY);
// React-approved derived-state pattern: sync form whenever a different
// record arrives (including after save+refetch). Using setState during
// render avoids the one-frame flash that useEffect would produce.
const [syncedRecordId, setSyncedRecordId] = useState<string | null>(null);
if (record && record.id !== syncedRecordId) {
setSyncedRecordId(record.id as string);
setForm({
case_number: record.case_number || "",
citation_formatted: record.citation_formatted || "",
case_name: record.case_name || "",
court: record.court || "",
district: record.district || "",
chair_name: record.chair_name || "",
decision_date: record.date ? record.date.slice(0, 10) : "",
practice_area: (record.practice_area || "") as PracticeArea,
appeal_subtype: appealSubtypeLabel(record.appeal_subtype),
source_type: (record.source_type || "") as SourceType,
precedent_level: record.precedent_level || "",
is_binding: record.is_binding ?? true,
subject_tags: (record.subject_tags || []).join(", "),
summary: record.summary || "",
headnote: record.headnote || "",
key_quote: (record as { key_quote?: string }).key_quote || "",
});
}
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!caseLawId) return;
try {
const patch: Record<string, unknown> = {
citation_formatted: form.citation_formatted.trim(),
case_name: form.case_name.trim(),
court: form.court.trim(),
district: form.district.trim(),
chair_name: form.chair_name.trim(),
practice_area: form.practice_area || undefined,
appeal_subtype: form.appeal_subtype.trim(),
source_type: form.source_type || undefined,
precedent_level: form.precedent_level || undefined,
is_binding: form.is_binding,
subject_tags: form.subject_tags
.split(",").map((t) => t.trim()).filter(Boolean),
summary: form.summary.trim(),
headnote: form.headnote.trim(),
key_quote: form.key_quote.trim(),
};
if (form.decision_date) patch.decision_date = form.decision_date;
// case_number is the canonical identifier. Editing it is safe —
// halachot/chunks reference case_law_id (UUID), not the case_number
// text — so a wrong id captured at upload can be corrected here.
if (form.case_number.trim()) patch.case_number = form.case_number.trim();
await update.mutateAsync({ id: caseLawId, patch });
toast.success("נשמר");
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "שגיאה");
}
};
const onRequestMetadata = async () => {
if (!caseLawId) return;
try {
await requestMetadata.mutateAsync(caseLawId);
toast.success(
"סומן לחילוץ מטא-דאטה. הריצי מ-Claude Code: precedent_process_pending",
);
} catch (err) {
toast.error(err instanceof Error ? err.message : "שגיאה");
}
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onOpenChange(false); }}>
<DialogContent
className="sm:max-w-4xl max-h-[90vh] overflow-y-auto p-0"
dir="rtl"
>
<DialogHeader className="px-6 pt-6">
<DialogTitle className="text-navy">עריכת פרטי פסיקה</DialogTitle>
<DialogDescription className="text-ink-muted">
כל השדות ניתנים לעריכה, כולל מספר התיק (המזהה הייחודי).
כפתור &quot;חלץ מטא-דאטה&quot; שולח בקשה לתור מקומי שאני מרוקן
מ-Claude Code (ה-LLM רץ מקומית עם <code>claude session</code>,
לא ב-API).
</DialogDescription>
</DialogHeader>
{isPending || !record ? (
<div className="px-6 pb-6 mt-4 space-y-3">
{[...Array(6)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : (
<>
<form onSubmit={onSubmit} className="px-6 pb-6 space-y-4 mt-4">
<div className="rounded-lg border border-rule bg-rule-soft/40 p-3 flex items-end gap-3">
<div className="flex-1 min-w-0 space-y-1">
<Label htmlFor="case-number" className="text-[0.78rem] text-ink-muted">
מספר תיק (מזהה ייחודי)
</Label>
<Input
id="case-number" value={form.case_number}
onChange={(e) => setForm({ ...form, case_number: e.target.value })}
className="font-mono text-sm" dir="ltr"
placeholder="8027-25"
/>
</div>
<Button
type="button" size="sm" variant="outline"
onClick={onRequestMetadata}
disabled={requestMetadata.isPending}
className="shrink-0"
title="שולח בקשה לחילוץ מטא-דאטה לתור המקומי"
>
<Sparkles className="w-3.5 h-3.5 me-1" />
חלץ מטא-דאטה
</Button>
</div>
<div className="space-y-1">
<Label htmlFor="citation-formatted">
מראה מקום (כללי הציטוט האחיד)
</Label>
<Textarea
id="citation-formatted"
value={form.citation_formatted}
onChange={(e) =>
setForm({ ...form, citation_formatted: e.target.value })
}
rows={3}
dir="rtl"
className="font-mono text-sm"
placeholder='ערר (ועדות ערר ...) 1234/24 **עורר נ&apos; הוועדה המקומית** (נבו 1.2.2025)'
/>
<p className="text-[0.7rem] text-ink-muted">
הקף את שמות הצדדים בכפול-כוכבית <code className="font-mono">**שם**</code> להדגשה. שדה זה משמש את כפתור ההעתקה בעמוד הפסיקה.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="case-name">שם קצר</Label>
<Input id="case-name" value={form.case_name}
onChange={(e) => setForm({ ...form, case_name: e.target.value })}
placeholder="ערר 403/17 / אהרון ברק" />
</div>
<div className="space-y-1">
<Label htmlFor="court">ערכאה</Label>
<Input id="court" value={form.court}
onChange={(e) => setForm({ ...form, court: e.target.value })} />
</div>
<div className="space-y-1">
<Label htmlFor="district">מחוז</Label>
<Select value={form.district || "_none"}
onValueChange={(v) => setForm({ ...form, district: v === "_none" ? "" : v })}>
<SelectTrigger id="district"><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{DISTRICTS.map((d) => (
<SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>
))}
{/* Preserve legacy free-text values that don't match any
known district (e.g. older imports with typos). */}
{form.district && !DISTRICTS.some((d) => d.value === form.district) && (
<SelectItem value={form.district}>{form.district}</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="chair-name">יו"ר</Label>
<Input id="chair-name" value={form.chair_name}
onChange={(e) => setForm({ ...form, chair_name: e.target.value })}
placeholder="עו״ד דפנה תמיר" />
</div>
<div className="space-y-1">
<Label htmlFor="date">תאריך</Label>
<Input id="date" type="date" value={form.decision_date}
onChange={(e) => setForm({ ...form, decision_date: e.target.value })} />
</div>
<div className="space-y-1">
<Label htmlFor="appeal-subtype">תת-סוג</Label>
<Input id="appeal-subtype" value={form.appeal_subtype}
onChange={(e) => setForm({ ...form, appeal_subtype: e.target.value })}
placeholder="תכנית רחביה / סופיות ההחלטה" />
</div>
</div>
<div className="space-y-1">
<Label>תחום</Label>
<div className="flex gap-4 flex-wrap">
{PRACTICE_AREAS.map((a) => (
<label key={a.value} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="practice_area" value={a.value}
checked={form.practice_area === a.value}
onChange={() => setForm({ ...form, practice_area: a.value as PracticeArea })} />
<span className="text-sm">{a.label}</span>
</label>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="source-type">סוג מקור</Label>
<Select value={form.source_type || "_none"}
onValueChange={(v) => setForm({ ...form, source_type: v === "_none" ? "" : v as SourceType })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_none">—</SelectItem>
{SOURCE_TYPES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="precedent-level">רמת תקדים</Label>
<Select value={form.precedent_level || "_none"}
onValueChange={(v) => setForm({ ...form, precedent_level: v === "_none" ? "" : v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_none">—</SelectItem>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="tags">תגיות נושא (מופרדות בפסיקים)</Label>
<Input id="tags" value={form.subject_tags}
onChange={(e) => setForm({ ...form, subject_tags: e.target.value })}
placeholder="חניה, קווי בניין, שיקול דעת" />
</div>
<div className="space-y-1">
<Label htmlFor="summary">תקציר (2-3 משפטים)</Label>
<Textarea id="summary" value={form.summary} rows={3} dir="rtl"
onChange={(e) => setForm({ ...form, summary: e.target.value })} />
</div>
<div className="space-y-1">
<Label htmlFor="headnote">Headnote (משפט-שניים)</Label>
<Textarea id="headnote" value={form.headnote} rows={2} dir="rtl"
onChange={(e) => setForm({ ...form, headnote: e.target.value })} />
</div>
<div className="space-y-1">
<Label htmlFor="key-quote">ציטוט מרכזי</Label>
<Textarea id="key-quote" value={form.key_quote} rows={3} dir="rtl"
onChange={(e) => setForm({ ...form, key_quote: e.target.value })} />
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.is_binding}
onChange={(e) => setForm({ ...form, is_binding: e.target.checked })} />
<span className="text-sm">הלכה מחייבת (binding)</span>
<span className="text-[0.7rem] text-ink-muted">
— בדרך כלל רק עליון/מנהלי. ועדות ערר אחרות = לא מחייב.
</span>
</label>
<div className="flex gap-2 justify-end pt-2 border-t border-rule-soft">
<Button type="button" variant="ghost"
onClick={() => onOpenChange(false)} disabled={update.isPending}>
ביטול
</Button>
<Button type="submit" disabled={update.isPending}
className="bg-navy text-parchment hover:bg-navy-soft">
<Save className="w-4 h-4 me-1" />
שמור
</Button>
</div>
</form>
<div className="px-6 pb-8 pt-2 border-t border-rule">
<ExtractedHalachotSection halachot={record.halachot ?? []} />
</div>
</>
)}
</DialogContent>
</Dialog>
);
}