All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 46s
The "ערוך פרטים" sheet labeled the case_number field "מראה מקום" and marked it read-only — confusing because the formal citation IS supposed to be editable. Rename the read-only field to "מספר תיק (מזהה ייחודי)" to clarify it's the system key, and add a separate Textarea for the true formal citation (citation_formatted) with the same markdown-bold convention used by the inline editor on the detail page. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
351 lines
15 KiB
TypeScript
351 lines
15 KiB
TypeScript
"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 = {
|
||
citation: 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 = {
|
||
citation: "", 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({
|
||
citation: 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;
|
||
// citation (case_number) is the unique key; we don't allow editing it
|
||
// here to avoid orphaning halachot. To rename, delete + re-upload.
|
||
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">
|
||
כל השדות ניתנים לעריכה חוץ ממספר התיק (מזהה ייחודי במערכת).
|
||
כפתור "חלץ מטא-דאטה" שולח בקשה לתור מקומי שאני מרוקן
|
||
מ-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-start gap-3">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-[0.78rem] text-ink-muted">מספר תיק (מזהה ייחודי — לא ניתן לעריכה)</div>
|
||
<div className="text-navy font-mono text-sm break-all" dir="ltr">
|
||
{record.case_number}
|
||
</div>
|
||
</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 **עורר נ' הוועדה המקומית** (נבו 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>
|
||
);
|
||
}
|