Files
legal-ai/web-ui/src/components/precedents/precedent-upload-sheet.tsx
Chaim 5ad541e54c
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
ui(precedents): upload sheet routes ערר/בל"מ to internal-decisions endpoint
Citations starting with ערר/בל"מ/ARAR are committee decisions and must
carry chair_name + district. The /precedents upload form previously
errored out for these (precedent_library service rejects them) with no
in-UI path forward — internal_decision_upload was only reachable via
the /missing-precedents flow.

The form now auto-detects committee citations, reveals chair_name +
district fields, hides the irrelevant source_type/precedent_level
(derived server-side), and posts to /api/internal-decisions/upload.

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

382 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 { useEffect, useState } from "react";
import { Upload, Loader2, CheckCircle2, AlertCircle } 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, libraryKeys,
isCommitteeCitation, COMMITTEE_DISTRICTS,
type PracticeArea, type SourceType, type CommitteeDistrict,
} from "@/lib/api/precedent-library";
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 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);
// Appeals-committee decisions go to /api/internal-decisions/upload and
// require chair_name + district. Routing is by citation prefix.
const [chairName, setChairName] = useState("");
const [district, setDistrict] = useState<CommitteeDistrict | "">("");
const isCommittee = isCommitteeCitation(citation);
const [taskId, setTaskId] = useState<string | null>(null);
const upload = useUploadPrecedent();
const uploadInternal = useUploadInternalDecision();
const progress = useProgress(taskId);
const qc = useQueryClient();
// Reset form when the sheet closes — fields, file input, and any in-flight
// task subscription. We accept the cascade-render warning because resetting
// form state on close is exactly the intended side effect.
useEffect(() => {
if (open) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setFile(null); setCitation(""); setCaseName(""); setCourt("");
// eslint-disable-next-line react-hooks/set-state-in-effect
setDecisionDate(""); setSourceType(""); setPrecedentLevel("");
// eslint-disable-next-line react-hooks/set-state-in-effect
setPracticeArea(""); setAppealSubtype(""); setSubjectTags("");
// eslint-disable-next-line react-hooks/set-state-in-effect
setHeadnote(""); setIsBinding(true); setTaskId(null);
// eslint-disable-next-line react-hooks/set-state-in-effect
setChairName(""); setDistrict("");
}, [open]);
// Auto-close on completion + refresh library list/stats so the new
// row appears with up-to-date counts (halachot, approved). The mutation's
// onSuccess fires when POST returns the task_id; we need a second
// invalidation when SSE reports terminal status, otherwise the table
// shows stale data.
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;
}
const tags = subjectTags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
try {
if (isCommittee) {
if (!chairName.trim()) {
toast.error("שם יו\"ר חובה להחלטת ועדת ערר");
return;
}
if (!district) {
toast.error("מחוז חובה להחלטת ועדת ערר");
return;
}
const res = await uploadInternal.mutateAsync({
file,
case_number: 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: isBinding,
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) {
toast.error(err instanceof Error ? err.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>
<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)}
placeholder={`עע"מ 3975/22 ב. קרן-נכסים נ' ועדה מקומית`}
disabled={isProcessing} dir="rtl"
/>
{isCommittee && (
<p className="text-[0.72rem] text-ink-muted">
זוהתה כהחלטת ועדת ערר נדרשים שם יו&quot;ר ומחוז (למטה).
</p>
)}
</div>
{isCommittee && (
<div className="grid grid-cols-2 gap-3 rounded-md border border-gold/40 bg-gold-wash/40 p-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>
)}
<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 cursor-pointer">
<input type="checkbox" checked={isBinding}
onChange={(e) => setIsBinding(e.target.checked)}
disabled={isProcessing} />
<span className="text-sm">הלכה מחייבת</span>
</label>
</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>
);
}