10 שגיאות (כולן קיימות-מראש, לא מהפיצ'רים האחרונים): - react/no-unescaped-entities (3): legal-arguments-panel, precedent-edit-sheet — escaping של מרכאות ב-JSX (“/") - react-hooks/set-state-in-effect (6): documents-panel, chair-editor, content-checklists, discussion-rules, golden-ratios, documents.ts — disable-comment לדפוסי sync/reset לגיטימיים (false-positive ידוע) - React Compiler reassign (1): subject-donut — refactor לחישוב prefix-sums ללא mutable accumulator ניקוי: הסרת 5 eslint-disable directives מיותרים (halacha-review-panel, precedent-upload-sheet). תוצאה: 0 errors (היה 10), 24→ warnings (היה 29). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
463 lines
21 KiB
TypeScript
463 lines
21 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { Upload, Loader2, CheckCircle2, AlertCircle, Trash2, RefreshCw } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { useQueryClient } from "@tanstack/react-query";
|
||
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 {
|
||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Progress } from "@/components/ui/progress";
|
||
import {
|
||
useUploadPrecedent, useUploadInternalDecision, useDeletePrecedent,
|
||
useRequestHalachotExtraction, libraryKeys,
|
||
isCommitteeCitation, COMMITTEE_DISTRICTS,
|
||
type PracticeArea, type SourceType, type CommitteeDistrict,
|
||
} from "@/lib/api/precedent-library";
|
||
import { ApiError } from "@/lib/api/client";
|
||
import { useProgress } from "@/lib/api/documents";
|
||
import {
|
||
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES,
|
||
} from "./practice-area";
|
||
|
||
const ACCEPT = ".pdf,.docx,.doc,.rtf,.txt,.md";
|
||
|
||
type DuplicateConflict = {
|
||
error: string;
|
||
case_law_id: string;
|
||
citation: string;
|
||
case_name: string;
|
||
court: string;
|
||
date: string | null;
|
||
halacha_extraction_status: string;
|
||
};
|
||
|
||
type Props = {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
};
|
||
|
||
export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [citation, setCitation] = useState("");
|
||
const [caseName, setCaseName] = useState("");
|
||
const [court, setCourt] = useState("");
|
||
const [decisionDate, setDecisionDate] = useState("");
|
||
const [sourceType, setSourceType] = useState<SourceType>("");
|
||
const [precedentLevel, setPrecedentLevel] = useState("");
|
||
const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
|
||
const [appealSubtype, setAppealSubtype] = useState("");
|
||
const [subjectTags, setSubjectTags] = useState("");
|
||
const [headnote, setHeadnote] = useState("");
|
||
const [isBinding, setIsBinding] = useState(true);
|
||
|
||
const [chairName, setChairName] = useState("");
|
||
const [district, setDistrict] = useState<CommitteeDistrict | "">("");
|
||
const [caseNumber, setCaseNumber] = useState("");
|
||
const isCommittee = isCommitteeCitation(citation);
|
||
|
||
const [taskId, setTaskId] = useState<string | null>(null);
|
||
const [conflict, setConflict] = useState<DuplicateConflict | null>(null);
|
||
|
||
const upload = useUploadPrecedent();
|
||
const uploadInternal = useUploadInternalDecision();
|
||
const deletePrecedent = useDeletePrecedent();
|
||
const requestHalachot = useRequestHalachotExtraction();
|
||
const progress = useProgress(taskId);
|
||
const qc = useQueryClient();
|
||
|
||
useEffect(() => {
|
||
if (open) return;
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset form on close
|
||
setFile(null); setCitation(""); setCaseName(""); setCourt("");
|
||
setDecisionDate(""); setSourceType(""); setPrecedentLevel("");
|
||
setPracticeArea(""); setAppealSubtype(""); setSubjectTags("");
|
||
setHeadnote(""); setIsBinding(true); setTaskId(null); setConflict(null);
|
||
setChairName(""); setDistrict(""); setCaseNumber("");
|
||
}, [open]);
|
||
|
||
useEffect(() => {
|
||
if (progress?.status === "completed") {
|
||
qc.invalidateQueries({ queryKey: libraryKeys.all });
|
||
toast.success("הפסיקה הוכנסה לקורפוס. ההלכות ממתינות לאישור.");
|
||
const t = window.setTimeout(() => onOpenChange(false), 1200);
|
||
return () => window.clearTimeout(t);
|
||
}
|
||
if (progress?.status === "failed") {
|
||
qc.invalidateQueries({ queryKey: libraryKeys.all });
|
||
toast.error(`כשל בעיבוד: ${progress.error || "שגיאה לא ידועה"}`);
|
||
}
|
||
}, [progress, onOpenChange, qc]);
|
||
|
||
const onSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!file) { toast.error("בחר קובץ"); return; }
|
||
if (!citation.trim()) { toast.error("מראה המקום (citation) חובה"); return; }
|
||
setConflict(null);
|
||
|
||
const tags = subjectTags.split(",").map((t) => t.trim()).filter(Boolean);
|
||
|
||
try {
|
||
if (isCommittee) {
|
||
if (!caseNumber.trim()) { toast.error("מספר תיק חובה להחלטת ועדת ערר"); return; }
|
||
if (!chairName.trim()) { toast.error("שם יו\"ר חובה להחלטת ועדת ערר"); return; }
|
||
if (!district) { toast.error("מחוז חובה להחלטת ועדת ערר"); return; }
|
||
const res = await uploadInternal.mutateAsync({
|
||
file, case_number: caseNumber.trim(), citation: citation.trim(),
|
||
chair_name: chairName.trim(), district, case_name: caseName.trim(),
|
||
court: court.trim(), decision_date: decisionDate || undefined,
|
||
practice_area: practiceArea, appeal_subtype: appealSubtype.trim(),
|
||
subject_tags: tags, is_binding: false, summary: headnote.trim(),
|
||
});
|
||
setTaskId(res.task_id);
|
||
return;
|
||
}
|
||
|
||
const res = await upload.mutateAsync({
|
||
file, citation: citation.trim(), case_name: caseName.trim(),
|
||
court: court.trim(), decision_date: decisionDate || undefined,
|
||
source_type: sourceType || undefined, precedent_level: precedentLevel || undefined,
|
||
practice_area: practiceArea, appeal_subtype: appealSubtype.trim(),
|
||
subject_tags: tags, is_binding: isBinding, headnote: headnote.trim(),
|
||
});
|
||
setTaskId(res.task_id);
|
||
} catch (err) {
|
||
if (err instanceof ApiError && err.status === 409) {
|
||
const body = err.body as { detail?: DuplicateConflict } | null;
|
||
if (body?.detail?.error === "duplicate_external_upload") {
|
||
setConflict(body.detail!);
|
||
return;
|
||
}
|
||
}
|
||
toast.error(err instanceof Error ? err.message : "כשל בהעלאה");
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (!conflict) return;
|
||
if (!window.confirm("למחוק את הרשומה הקיימת? פעולה זו תמחק גם את כל ההלכות שחולצו ממנה.")) return;
|
||
try {
|
||
await deletePrecedent.mutateAsync(conflict.case_law_id);
|
||
toast.success("הרשומה נמחקה — כעת ניתן להעלות מחדש");
|
||
setConflict(null);
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "שגיאה במחיקה");
|
||
}
|
||
};
|
||
|
||
const handleReextract = async () => {
|
||
if (!conflict) return;
|
||
try {
|
||
await requestHalachot.mutateAsync(conflict.case_law_id);
|
||
toast.success("חילוץ הלכות הועמד בתור — ההלכות החדשות יופיעו בתור האישור");
|
||
qc.invalidateQueries({ queryKey: libraryKeys.all });
|
||
onOpenChange(false);
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "שגיאה בהפעלת חילוץ");
|
||
}
|
||
};
|
||
|
||
const isProcessing = taskId !== null && progress?.status !== "completed" && progress?.status !== "failed";
|
||
const isSubmitting = upload.isPending || uploadInternal.isPending;
|
||
const stage = (progress as { stage?: string; percent?: number; step?: string } | null)?.stage;
|
||
const percent = (progress as { percent?: number } | null)?.percent ?? 0;
|
||
|
||
return (
|
||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||
<SheetContent side="left" className="w-full sm:max-w-2xl overflow-y-auto" dir="rtl">
|
||
<SheetHeader>
|
||
<SheetTitle className="text-navy">העלאת פסיקה לקורפוס הסמכותי</SheetTitle>
|
||
<SheetDescription className="text-ink-muted">
|
||
הקובץ יעבור חילוץ טקסט, embeddings, וחילוץ אוטומטי של מטא־דאטה
|
||
(שם, ערכאה, תאריך, תחום, תת־סוג, תגיות) והלכות. ההלכות ימתינו
|
||
לאישורך לפני שיהיו זמינות לסוכני הכתיבה.
|
||
</SheetDescription>
|
||
</SheetHeader>
|
||
|
||
{/* ─── Conflict banner ─── */}
|
||
{conflict && (
|
||
<div className="mx-6 mt-4 rounded-lg border border-danger bg-danger-bg p-4 space-y-3">
|
||
<div className="flex items-start gap-2">
|
||
<AlertCircle className="w-5 h-5 text-danger shrink-0 mt-0.5" />
|
||
<div className="space-y-1 flex-1 min-w-0">
|
||
<p className="font-semibold text-danger text-sm">הפסיקה כבר קיימת במאגר</p>
|
||
<p className="text-danger/80 text-[0.78rem]" dir="rtl">
|
||
{conflict.citation}
|
||
{conflict.case_name && ` — ${conflict.case_name}`}
|
||
{conflict.court && ` · ${conflict.court}`}
|
||
{conflict.date && ` · ${new Date(conflict.date).toLocaleDateString("he-IL")}`}
|
||
</p>
|
||
{conflict.halacha_extraction_status && (
|
||
<p className="text-danger/60 text-[0.72rem]">
|
||
סטטוס חילוץ הלכות: {conflict.halacha_extraction_status}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2 justify-end">
|
||
<Button
|
||
size="sm" variant="ghost"
|
||
onClick={() => setConflict(null)}
|
||
className="text-ink-muted"
|
||
>
|
||
ביטול
|
||
</Button>
|
||
<Button
|
||
size="sm" variant="outline"
|
||
onClick={handleReextract}
|
||
disabled={requestHalachot.isPending}
|
||
className="border-danger/40 text-danger hover:bg-danger-bg"
|
||
>
|
||
{requestHalachot.isPending
|
||
? <Loader2 className="w-3.5 h-3.5 animate-spin me-1" />
|
||
: <RefreshCw className="w-3.5 h-3.5 me-1" />}
|
||
הפעל חילוץ מחדש
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
onClick={handleDelete}
|
||
disabled={deletePrecedent.isPending}
|
||
className="bg-danger text-white hover:bg-danger/90"
|
||
>
|
||
{deletePrecedent.isPending
|
||
? <Loader2 className="w-3.5 h-3.5 animate-spin me-1" />
|
||
: <Trash2 className="w-3.5 h-3.5 me-1" />}
|
||
מחק את הרשומה
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<form onSubmit={onSubmit} className="px-6 pb-6 space-y-4 mt-4">
|
||
{/* File */}
|
||
<div className="space-y-1">
|
||
<Label htmlFor="file">קובץ (PDF / DOCX / DOC / RTF / TXT / MD)</Label>
|
||
<Input
|
||
id="file" type="file" accept={ACCEPT}
|
||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||
disabled={isProcessing}
|
||
/>
|
||
</div>
|
||
|
||
{/* Citation */}
|
||
<div className="space-y-1">
|
||
<Label htmlFor="citation">מראה המקום (חובה)</Label>
|
||
<Input
|
||
id="citation" value={citation}
|
||
onChange={(e) => { setCitation(e.target.value); setConflict(null); }}
|
||
placeholder={`עע"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית`}
|
||
disabled={isProcessing} dir="rtl"
|
||
/>
|
||
{isCommittee && (
|
||
<p className="text-[0.72rem] text-ink-muted">
|
||
זוהתה כהחלטת ועדת ערר — נדרשים שם יו"ר ומחוז (למטה).
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{isCommittee && (
|
||
<div className="space-y-3 rounded-md border border-gold/40 bg-gold-wash/40 p-3">
|
||
<div className="space-y-1">
|
||
<Label htmlFor="committee-case-number">מספר תיק — מזהה ייחודי (חובה)</Label>
|
||
<Input
|
||
id="committee-case-number" value={caseNumber}
|
||
onChange={(e) => setCaseNumber(e.target.value)}
|
||
placeholder="8027-25"
|
||
disabled={isProcessing} dir="rtl"
|
||
/>
|
||
<p className="text-[0.72rem] text-ink-muted">
|
||
המזהה הנקי בלבד (למשל 8027-25) — לא המראה-מקום המלא.
|
||
</p>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<Label htmlFor="chair-name">שם יו"ר (חובה)</Label>
|
||
<Input
|
||
id="chair-name" value={chairName}
|
||
onChange={(e) => setChairName(e.target.value)}
|
||
placeholder='עו"ד פלוני אלמוני'
|
||
disabled={isProcessing} dir="rtl"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="district">מחוז (חובה)</Label>
|
||
<Select
|
||
value={district || "_none"}
|
||
onValueChange={(v) =>
|
||
setDistrict(v === "_none" ? "" : (v as CommitteeDistrict))
|
||
}
|
||
disabled={isProcessing}
|
||
>
|
||
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="_none">—</SelectItem>
|
||
{COMMITTEE_DISTRICTS.map((d) => (
|
||
<SelectItem key={d} value={d}>{d}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</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 className="space-y-1">
|
||
<Label htmlFor="case-name">שם קצר</Label>
|
||
<Input id="case-name" value={caseName}
|
||
onChange={(e) => setCaseName(e.target.value)}
|
||
placeholder="ב. קרן-נכסים" disabled={isProcessing} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="court">ערכאה</Label>
|
||
<Input id="court" value={court}
|
||
onChange={(e) => setCourt(e.target.value)}
|
||
placeholder='בית משפט עליון / בג"ץ / מנהלי / ועדת ערר'
|
||
disabled={isProcessing} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="date">תאריך החלטה</Label>
|
||
<Input id="date" type="date" value={decisionDate}
|
||
onChange={(e) => setDecisionDate(e.target.value)}
|
||
disabled={isProcessing} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="appeal-subtype">תת-סוג (חופשי)</Label>
|
||
<Input id="appeal-subtype" value={appealSubtype}
|
||
onChange={(e) => setAppealSubtype(e.target.value)}
|
||
placeholder="שימוש חורג / סופיות ההחלטה" disabled={isProcessing} />
|
||
</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={practiceArea === a.value}
|
||
onChange={() => setPracticeArea(a.value as PracticeArea)}
|
||
disabled={isProcessing}
|
||
/>
|
||
<span className="text-sm text-ink">{a.label}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{!isCommittee && (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<Label htmlFor="source-type">סוג מקור</Label>
|
||
<Select value={sourceType || "_none"}
|
||
onValueChange={(v) => setSourceType(v === "_none" ? "" : v as SourceType)}
|
||
disabled={isProcessing}>
|
||
<SelectTrigger><SelectValue placeholder="—" /></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={precedentLevel || "_none"}
|
||
onValueChange={(v) => setPrecedentLevel(v === "_none" ? "" : v)}
|
||
disabled={isProcessing}>
|
||
<SelectTrigger><SelectValue placeholder="—" /></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={subjectTags}
|
||
onChange={(e) => setSubjectTags(e.target.value)}
|
||
placeholder="חניה, קווי בניין, שיקול דעת" disabled={isProcessing} />
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<Label htmlFor="headnote">תקציר / headnote</Label>
|
||
<Textarea id="headnote" value={headnote} rows={2}
|
||
onChange={(e) => setHeadnote(e.target.value)}
|
||
placeholder="תקציר חופשי שיוצג ברשימה" disabled={isProcessing} dir="rtl" />
|
||
</div>
|
||
|
||
<label className={`flex items-center gap-2 ${isCommittee ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}>
|
||
<input type="checkbox" checked={isCommittee ? false : isBinding}
|
||
onChange={(e) => setIsBinding(e.target.checked)}
|
||
disabled={isProcessing || isCommittee} />
|
||
<span className="text-sm">הלכה מחייבת</span>
|
||
</label>
|
||
{isCommittee && (
|
||
<p className="text-[0.72rem] text-ink-muted">
|
||
החלטות ועדת ערר אינן מהוות הלכה מחייבת — ההלכות שיחולצו יסומנו כ"משכנעת" (persuasive).
|
||
</p>
|
||
)}
|
||
</div>
|
||
</details>
|
||
|
||
{isProcessing && (
|
||
<div className="rounded-lg border border-rule bg-rule-soft/40 p-4 space-y-2">
|
||
<div className="flex items-center gap-2 text-sm text-navy">
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
<span>{(progress as { step?: string } | null)?.step || stage || "מעבד"}</span>
|
||
</div>
|
||
<Progress value={percent} className="h-1.5" />
|
||
</div>
|
||
)}
|
||
|
||
{progress?.status === "completed" && (
|
||
<div className="rounded-lg border border-gold/40 bg-gold-wash p-4 flex items-center gap-2 text-gold-deep text-sm">
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
נכנס לקורפוס. ההלכות ממתינות בתור האישור.
|
||
</div>
|
||
)}
|
||
|
||
{progress?.status === "failed" && (
|
||
<div className="rounded-lg border border-danger/40 bg-danger-bg p-4 flex items-center gap-2 text-danger text-sm">
|
||
<AlertCircle className="w-4 h-4" />
|
||
{progress.error || "שגיאה"}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-2 justify-end pt-2">
|
||
<Button type="button" variant="ghost"
|
||
onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||
ביטול
|
||
</Button>
|
||
<Button type="submit"
|
||
disabled={isSubmitting || isProcessing}
|
||
className="bg-navy text-parchment hover:bg-navy-soft">
|
||
<Upload className="w-4 h-4 me-1" />
|
||
העלה
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|