Files
legal-ai/web-ui/src/components/precedents/precedent-upload-sheet.tsx
Chaim 1f1a025509 fix(lint): תיקון 10 שגיאות ESLint + ניקוי directives מיותרים
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>
2026-06-06 13:31:31 +00:00

463 lines
21 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, 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">
זוהתה כהחלטת ועדת ערר נדרשים שם יו&quot;ר ומחוז (למטה).
</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">שם יו&quot;ר (חובה)</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">
החלטות ועדת ערר אינן מהוות הלכה מחייבת ההלכות שיחולצו יסומנו כ&quot;משכנעת&quot; (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>
);
}