From 10540a38b49865041f144d32eeefaf7ad58d46eb Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 11 Apr 2026 16:25:44 +0000 Subject: [PATCH] Phase 4c: bulk document upload with live SSE progress New UploadSheet on the case detail page wraps react-dropzone + a selector for doc_type. Files post to POST /api/cases/{n}/documents/upload-tagged as multipart form-data; the returned task_id is streamed via GET /api/progress/{task_id} through the new lib/sse.ts EventSource wrapper. Each upload row shows a per-file progress bar that transitions to success/error on the terminal SSE payload. Closing the stream inside the message handler avoids EventSource's auto-reconnect after EOF. Phase 4 (task 86) is now complete end-to-end: create, upload, edit. Co-Authored-By: Claude Opus 4.6 (1M context) --- web-ui/src/app/cases/[caseNumber]/page.tsx | 28 ++- .../src/components/documents/upload-sheet.tsx | 208 ++++++++++++++++++ web-ui/src/lib/api/documents.ts | 111 ++++++++++ web-ui/src/lib/sse.ts | 53 +++++ 4 files changed, 388 insertions(+), 12 deletions(-) create mode 100644 web-ui/src/components/documents/upload-sheet.tsx create mode 100644 web-ui/src/lib/api/documents.ts create mode 100644 web-ui/src/lib/sse.ts diff --git a/web-ui/src/app/cases/[caseNumber]/page.tsx b/web-ui/src/app/cases/[caseNumber]/page.tsx index 00fea46..2af35cb 100644 --- a/web-ui/src/app/cases/[caseNumber]/page.tsx +++ b/web-ui/src/app/cases/[caseNumber]/page.tsx @@ -11,6 +11,7 @@ import { CaseHeader } from "@/components/cases/case-header"; import { CaseEditDialog } from "@/components/cases/case-edit-dialog"; import { WorkflowTimeline } from "@/components/cases/workflow-timeline"; import { DocumentsPanel } from "@/components/cases/documents-panel"; +import { UploadSheet } from "@/components/documents/upload-sheet"; import { useCase } from "@/lib/api/cases"; /* @@ -56,18 +57,21 @@ export default function CaseDetailPage({ - - סקירה - - מסמכים - {data?.documents && ( - - ({data.documents.length}) - - )} - - פעולות - +
+ + סקירה + + מסמכים + {data?.documents && ( + + ({data.documents.length}) + + )} + + פעולות + + +
diff --git a/web-ui/src/components/documents/upload-sheet.tsx b/web-ui/src/components/documents/upload-sheet.tsx new file mode 100644 index 0000000..af03884 --- /dev/null +++ b/web-ui/src/components/documents/upload-sheet.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useDropzone } from "react-dropzone"; +import { Upload, FileText, CheckCircle2, XCircle, Loader2 } from "lucide-react"; +import { + Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { useUploadDocument, useProgress, type ProgressEvent } from "@/lib/api/documents"; + +/* + * Upload sheet — drag-drop zone + doc-type selector, with live SSE + * progress for the most-recent upload. Intentionally sequential: + * a single file at a time keeps the SSE subscription simple and + * matches how the FastAPI processor handles one task_id per file. + */ + +const DOC_TYPES: { value: string; label: string }[] = [ + { value: "auto", label: "זיהוי אוטומטי" }, + { value: "appeal", label: "כתב ערר" }, + { value: "response", label: "כתב תשובה" }, + { value: "protocol", label: "פרוטוקול דיון" }, + { value: "decision", label: "החלטת ועדה מקומית" }, + { value: "plan", label: "תכנית" }, + { value: "reference",label: "חומר רקע" }, +]; + +type UploadRow = { + id: string; + filename: string; + taskId: string | null; + error?: string; +}; + +function statusLabel(event: ProgressEvent | null): string { + if (!event) return "מתחיל…"; + if (event.status === "queued") return "בתור"; + if (event.status === "processing") + return event.step ? `בעיבוד · ${event.step}` : "בעיבוד"; + if (event.status === "completed") return "הושלם"; + if (event.status === "failed") return event.error ?? "נכשל"; + return event.status; +} + +function progressPercent(event: ProgressEvent | null): number { + if (!event) return 5; + if (event.status === "queued") return 10; + if (event.status === "processing") return 55; + if (event.status === "completed") return 100; + if (event.status === "failed") return 100; + return 25; +} + +function UploadRowView({ row }: { row: UploadRow }) { + const progress = useProgress(row.taskId); + const pct = row.error ? 100 : progressPercent(progress); + const failed = row.error || progress?.status === "failed"; + const done = progress?.status === "completed"; + + return ( +
  • +
    + {done ? ( + + ) : failed ? ( + + ) : ( + + )} + + + {row.filename} + + + {row.error ?? statusLabel(progress)} + +
    + div]:bg-danger" : done ? "[&>div]:bg-success" : ""} + /> +
  • + ); +} + +export function UploadSheet({ caseNumber }: { caseNumber: string }) { + const [open, setOpen] = useState(false); + const [docType, setDocType] = useState("auto"); + const [rows, setRows] = useState([]); + const mutate = useUploadDocument(caseNumber); + + const onDrop = useCallback( + async (files: File[]) => { + for (const file of files) { + const rowId = crypto.randomUUID(); + setRows((r) => [ + ...r, + { id: rowId, filename: file.name, taskId: null }, + ]); + try { + const res = await mutate.mutateAsync({ file, docType }); + setRows((r) => + r.map((row) => + row.id === rowId ? { ...row, taskId: res.task_id } : row, + ), + ); + } catch (e) { + setRows((r) => + r.map((row) => + row.id === rowId + ? { ...row, error: e instanceof Error ? e.message : "שגיאה" } + : row, + ), + ); + } + } + }, + [docType, mutate], + ); + + const dropzone = useDropzone({ + onDrop, + accept: { + "application/pdf": [".pdf"], + "application/msword": [".doc"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"], + "text/plain": [".txt"], + "text/markdown": [".md"], + }, + maxSize: 50 * 1024 * 1024, + }); + + return ( + + + + + + + העלאת מסמכים לתיק {caseNumber} + + PDF, DOCX, DOC, TXT, MD — עד 50MB לקובץ. הקבצים מעובדים ברקע + והסטטוס מתעדכן בזמן אמת. + + + +
    +
    + + +
    + +
    + + +

    + {dropzone.isDragActive + ? "שחרר כאן להעלאה" + : "גרור קבצים או לחץ לבחירה"} +

    +

    + ניתן להעלות מספר קבצים בבת אחת +

    +
    + + {rows.length > 0 && ( +
      + {rows.map((row) => ( + + ))} +
    + )} +
    +
    +
    + ); +} diff --git a/web-ui/src/lib/api/documents.ts b/web-ui/src/lib/api/documents.ts new file mode 100644 index 0000000..ad1c1c1 --- /dev/null +++ b/web-ui/src/lib/api/documents.ts @@ -0,0 +1,111 @@ +/** + * Document upload + progress hooks. + * + * Upload hits `POST /api/cases/{n}/documents/upload-tagged` as multipart + * form-data (FastAPI UploadFile), and receives a `task_id` that streams + * progress events via `GET /api/progress/{task_id}` (SSE). We expose + * both as a single `useUploadDocument` mutation returning the task id + * plus a `useProgress(taskId)` hook that subscribes to the stream. + */ + +import { useEffect, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ApiError } from "./client"; +import { casesKeys } from "./cases"; +import { openSSE } from "@/lib/sse"; + +export type UploadTaggedResponse = { + task_id: string; + filename: string; + original_name: string; + doc_type: string; +}; + +export type ProgressEvent = { + status: "queued" | "processing" | "completed" | "failed" | string; + filename?: string; + step?: string; + error?: string; + result?: unknown; + case_number?: string; + doc_type?: string; +}; + +export type UploadVars = { + caseNumber: string; + file: File; + docType?: string; + partyName?: string; + title?: string; +}; + +async function uploadTagged({ + caseNumber, + file, + docType = "auto", + partyName = "", + title = "", +}: UploadVars): Promise { + const fd = new FormData(); + fd.append("file", file); + fd.append("doc_type", docType); + fd.append("party_name", partyName); + fd.append("title", title); + + const res = await fetch( + `/api/cases/${encodeURIComponent(caseNumber)}/documents/upload-tagged`, + { method: "POST", body: fd }, + ); + const contentType = res.headers.get("content-type") ?? ""; + const parsed = contentType.includes("application/json") + ? await res.json().catch(() => null) + : await res.text().catch(() => null); + if (!res.ok) { + throw new ApiError( + `Upload failed with ${res.status}`, + res.status, + parsed, + ); + } + return parsed as UploadTaggedResponse; +} + +export function useUploadDocument(caseNumber: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (vars: Omit) => + uploadTagged({ caseNumber, ...vars }), + onSuccess: () => { + /* Nudge the case detail to refetch so the new document row appears + * immediately — the actual "processing" badge will update once the + * SSE stream reports status=completed. */ + qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) }); + }, + }); +} + +export function useProgress(taskId: string | null) { + const [event, setEvent] = useState(null); + + useEffect(() => { + if (!taskId) return; + setEvent(null); + const close = openSSE( + `/api/progress/${encodeURIComponent(taskId)}`, + { + onMessage: (data) => { + setEvent(data); + if (data.status === "completed" || data.status === "failed") { + /* Close from within the callback — the backend ends the stream + * naturally, but closing eagerly avoids the auto-reconnect loop + * EventSource does after EOF. */ + close(); + } + }, + }, + ); + return () => close(); + }, [taskId]); + + return event; +} diff --git a/web-ui/src/lib/sse.ts b/web-ui/src/lib/sse.ts new file mode 100644 index 0000000..3c3fe9a --- /dev/null +++ b/web-ui/src/lib/sse.ts @@ -0,0 +1,53 @@ +/** + * Minimal SSE helper — wraps `EventSource` so consumers get typed event + * callbacks and a single `close()` for cleanup. + * + * Used by the upload flow to stream /api/progress/{task_id} events. + * Kept framework-agnostic so any component can drive it; a thin React + * hook layer sits on top in lib/api/documents.ts. + */ + +export type SSEHandlers = { + onMessage: (data: T) => void; + onError?: (err: Event) => void; + /* Called when the server closes the stream cleanly. EventSource has + * no native "closed" event, so the backend signals completion via a + * terminal payload and we close from the onMessage handler — callers + * can return `true` to trigger this path. */ + onClose?: () => void; +}; + +export function openSSE( + url: string, + { onMessage, onError, onClose }: SSEHandlers, +): () => void { + const es = new EventSource(url); + let closed = false; + + const close = () => { + if (closed) return; + closed = true; + es.close(); + onClose?.(); + }; + + es.addEventListener("message", (ev) => { + if (closed) return; + try { + const payload = JSON.parse(ev.data) as T; + onMessage(payload); + } catch { + /* backend sends heartbeats as comments — EventSource filters them, + * so any non-JSON message here is a protocol bug worth ignoring */ + } + }); + + es.addEventListener("error", (ev) => { + if (closed) return; + onError?.(ev); + /* EventSource auto-reconnects; we only close on an explicit terminal + * payload from the server, not on transient network errors */ + }); + + return close; +}