"use client"; /* * Upload a Daphna decision into the style corpus, from the /training page. * * The flow is three explicit steps inside the same sheet: * 1. file picker → POST /api/upload (gets sanitized filename) * 2. preview → POST /api/training/analyze (proofread + auto-extracted meta) * chair can correct decision_number / decision_date / subjects * 3. commit → POST /api/training/upload (background task) * progress watched via SSE; on completion we invalidate * corpus + style-report so the new row appears. * * The Sheet UX mirrors precedent-upload-sheet.tsx: same dir="rtl", same * loading + error patterns, same toast on success. The reason this isn't * a single one-click upload is that style-corpus rows are write-once * (we don't allow editing full_text), so the chair MUST see the proofread * preview before committing — otherwise a bad OCR/proofread can silently * pollute the style portrait. */ import { useEffect, useState } from "react"; import { Upload, Loader2, CheckCircle2, AlertCircle, FileText } 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 { Progress } from "@/components/ui/progress"; import { Badge } from "@/components/ui/badge"; import { trainingKeys, useAnalyzeTraining, useCommitTrainingUpload, useUploadFile, type AnalyzeTrainingResponse, } from "@/lib/api/training"; import { useProgress } from "@/lib/api/documents"; const ACCEPT = ".pdf,.docx,.doc,.rtf,.txt,.md"; type Props = { open: boolean; onOpenChange: (open: boolean) => void; }; type Stage = "pick" | "analyzing" | "preview" | "committing" | "done" | "error"; export function TrainingUploadDialog({ open, onOpenChange }: Props) { const [stage, setStage] = useState("pick"); const [file, setFile] = useState(null); const [analysis, setAnalysis] = useState(null); // editable copies of the auto-extracted metadata const [decisionNumber, setDecisionNumber] = useState(""); const [decisionDate, setDecisionDate] = useState(""); const [subjectsRaw, setSubjectsRaw] = useState(""); const [title, setTitle] = useState(""); const [taskId, setTaskId] = useState(null); const [errorMsg, setErrorMsg] = useState(""); const uploadFile = useUploadFile(); const analyze = useAnalyzeTraining(); const commit = useCommitTrainingUpload(); const progress = useProgress(taskId); const qc = useQueryClient(); // Reset everything when the sheet closes — important because Sheet keeps // the component mounted between opens. The cascade-render warning is the // intended behavior (reset is the side effect we want). useEffect(() => { if (open) return; /* eslint-disable react-hooks/set-state-in-effect */ setStage("pick"); setFile(null); setAnalysis(null); setDecisionNumber(""); setDecisionDate(""); setSubjectsRaw(""); setTitle(""); setTaskId(null); setErrorMsg(""); /* eslint-enable react-hooks/set-state-in-effect */ }, [open]); // Watch background task. When complete, invalidate corpus + report so the // new row + updated stats show up automatically. The setStage call here // is the deliberate UX (success card → auto-close) — synchronizing UI // with the external SSE stream is exactly what effects are for. useEffect(() => { if (!progress) return; if (progress.status === "completed") { qc.invalidateQueries({ queryKey: trainingKeys.corpus() }); qc.invalidateQueries({ queryKey: trainingKeys.report() }); // eslint-disable-next-line react-hooks/set-state-in-effect setStage("done"); toast.success(`החלטה ${decisionNumber || analysis?.decision_number || ""} נוספה לקורפוס`); const t = window.setTimeout(() => onOpenChange(false), 1500); return () => window.clearTimeout(t); } if (progress.status === "failed") { setStage("error"); setErrorMsg(progress.error || "כשל בעיבוד"); } }, [progress, analysis, decisionNumber, qc, onOpenChange]); const onPickFile = async (f: File | null) => { setFile(f); setErrorMsg(""); if (!f) return; setStage("analyzing"); try { const { filename } = await uploadFile.mutateAsync(f); const result = await analyze.mutateAsync(filename); setAnalysis(result); setDecisionNumber(result.decision_number); setDecisionDate(result.decision_date); setSubjectsRaw(result.subject_categories.join(", ")); // Default title from the original filename stem (chair can override). const stem = f.name.replace(/\.[^.]+$/, ""); setTitle(stem); setStage("preview"); } catch (e) { setStage("error"); setErrorMsg(e instanceof Error ? e.message : "כשל בקריאת הקובץ"); } }; const onCommit = async () => { if (!analysis) return; setStage("committing"); setErrorMsg(""); try { const subjects = subjectsRaw .split(/[,،]/) .map((s) => s.trim()) .filter(Boolean); const res = await commit.mutateAsync({ filename: analysis.filename, decision_number: decisionNumber.trim(), decision_date: decisionDate || "", subject_categories: subjects, title: title.trim() || undefined, }); setTaskId(res.task_id); } catch (e) { setStage("error"); // 409 = duplicate decision_number — surface the backend's Hebrew message. setErrorMsg(e instanceof Error ? e.message : "כשל בהעלאה"); } }; const isProcessing = stage === "analyzing" || stage === "committing" || (taskId !== null && progress?.status !== "completed" && progress?.status !== "failed"); const progressStep = (progress as { step?: string } | null)?.step; return ( העלאת החלטה לקורפוס הסגנון הקובץ יעבור הגהה (סינון Nevo, ניקוד), חילוץ אוטומטי של מספר תיק, תאריך ונושאים, ויוטמע ב-style_corpus עם chunks ו-embeddings. תוכל לתקן את פרטי המטא-דאטה לפני שמירה.
{/* Step 1: pick */} {stage === "pick" && (
onPickFile(e.target.files?.[0] ?? null)} />

המערכת תחלץ מהקובץ את מספר התיק, התאריך והנושאים. תוכל לערוך לפני השמירה.

)} {/* Stage 2: analyzing the file */} {stage === "analyzing" && (

מבצע הגהה וחילוץ מטא-דאטה…

{file?.name}

)} {/* Stage 3: preview + editable metadata */} {stage === "preview" && analysis && (
{ e.preventDefault(); onCommit(); }} >

תצוגה מקדימה של הטקסט הנקי

{analysis.preview}

{analysis.chars.toLocaleString("he-IL")} תווים
setDecisionNumber(e.target.value)} placeholder="1130-25" dir="rtl" />
setDecisionDate(e.target.value)} />
setTitle(e.target.value)} placeholder="ARAR-25-1130 - כרמל יצחק" dir="rtl" />
setSubjectsRaw(e.target.value)} placeholder="חניה, קווי בניין, שימוש חורג" dir="rtl" /> {analysis.subject_categories.length > 0 && (
חולץ אוטומטית: {analysis.subject_categories.map((s) => ( {s} ))}
)}
{errorMsg && (
{errorMsg}
)}
)} {/* Stage 4: committing — background task progress */} {(stage === "committing" || (taskId && stage !== "done" && stage !== "error")) && (
{progressStep || "מעבד את ההחלטה לקורפוס"}
)} {/* Stage 5: success */} {stage === "done" && (
ההחלטה נוספה לקורפוס בהצלחה.
)} {/* Stage 6: error (after a failed analyze or upload) */} {stage === "error" && (
{errorMsg || "שגיאה לא ידועה"}
)}
); }