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) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { CaseHeader } from "@/components/cases/case-header";
|
|||||||
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
||||||
import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
|
import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
|
||||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||||
|
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||||
import { useCase } from "@/lib/api/cases";
|
import { useCase } from "@/lib/api/cases";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -56,6 +57,7 @@ export default function CaseDetailPage({
|
|||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-6 py-5">
|
<CardContent className="px-6 py-5">
|
||||||
<Tabs defaultValue="overview" dir="rtl">
|
<Tabs defaultValue="overview" dir="rtl">
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-1">
|
||||||
<TabsList className="bg-rule-soft/60">
|
<TabsList className="bg-rule-soft/60">
|
||||||
<TabsTrigger value="overview">סקירה</TabsTrigger>
|
<TabsTrigger value="overview">סקירה</TabsTrigger>
|
||||||
<TabsTrigger value="documents">
|
<TabsTrigger value="documents">
|
||||||
@@ -68,6 +70,8 @@ export default function CaseDetailPage({
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="actions">פעולות</TabsTrigger>
|
<TabsTrigger value="actions">פעולות</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
<UploadSheet caseNumber={caseNumber} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<TabsContent value="overview" className="mt-5 space-y-4">
|
<TabsContent value="overview" className="mt-5 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
208
web-ui/src/components/documents/upload-sheet.tsx
Normal file
208
web-ui/src/components/documents/upload-sheet.tsx
Normal file
@@ -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 (
|
||||||
|
<li className="rounded-lg border border-rule bg-parchment/40 px-4 py-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{done ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-success shrink-0" />
|
||||||
|
) : failed ? (
|
||||||
|
<XCircle className="w-4 h-4 text-danger shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Loader2 className="w-4 h-4 text-gold animate-spin shrink-0" />
|
||||||
|
)}
|
||||||
|
<FileText className="w-4 h-4 text-ink-muted shrink-0" />
|
||||||
|
<span className="text-sm text-ink truncate flex-1" title={row.filename}>
|
||||||
|
{row.filename}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[0.72rem] tabular-nums shrink-0 ${
|
||||||
|
done ? "text-success" : failed ? "text-danger" : "text-ink-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.error ?? statusLabel(progress)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={pct}
|
||||||
|
className={failed ? "[&>div]:bg-danger" : done ? "[&>div]:bg-success" : ""}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadSheet({ caseNumber }: { caseNumber: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [docType, setDocType] = useState("auto");
|
||||||
|
const [rows, setRows] = useState<UploadRow[]>([]);
|
||||||
|
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 (
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Upload className="w-4 h-4 me-1" /> העלאת מסמכים
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="w-full sm:max-w-lg" dir="rtl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="text-navy">העלאת מסמכים לתיק {caseNumber}</SheetTitle>
|
||||||
|
<SheetDescription className="text-ink-muted">
|
||||||
|
PDF, DOCX, DOC, TXT, MD — עד 50MB לקובץ. הקבצים מעובדים ברקע
|
||||||
|
והסטטוס מתעדכן בזמן אמת.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-4 px-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-navy mb-1.5">
|
||||||
|
סיווג
|
||||||
|
</label>
|
||||||
|
<Select value={docType} onValueChange={setDocType} dir="rtl">
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DOC_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
{...dropzone.getRootProps()}
|
||||||
|
className={`
|
||||||
|
rounded-lg border-2 border-dashed p-8 text-center cursor-pointer
|
||||||
|
transition-colors
|
||||||
|
${
|
||||||
|
dropzone.isDragActive
|
||||||
|
? "border-gold bg-gold-wash"
|
||||||
|
: "border-rule bg-parchment/40 hover:bg-gold-wash/50 hover:border-gold/60"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<input {...dropzone.getInputProps()} />
|
||||||
|
<Upload className="w-8 h-8 mx-auto mb-2 text-gold-deep" />
|
||||||
|
<p className="text-sm text-navy font-medium">
|
||||||
|
{dropzone.isDragActive
|
||||||
|
? "שחרר כאן להעלאה"
|
||||||
|
: "גרור קבצים או לחץ לבחירה"}
|
||||||
|
</p>
|
||||||
|
<p className="text-[0.72rem] text-ink-muted mt-1">
|
||||||
|
ניתן להעלות מספר קבצים בבת אחת
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<UploadRowView key={row.id} row={row} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
web-ui/src/lib/api/documents.ts
Normal file
111
web-ui/src/lib/api/documents.ts
Normal file
@@ -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<UploadTaggedResponse> {
|
||||||
|
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<UploadVars, "caseNumber">) =>
|
||||||
|
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<ProgressEvent | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskId) return;
|
||||||
|
setEvent(null);
|
||||||
|
const close = openSSE<ProgressEvent>(
|
||||||
|
`/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;
|
||||||
|
}
|
||||||
53
web-ui/src/lib/sse.ts
Normal file
53
web-ui/src/lib/sse.ts
Normal file
@@ -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<T> = {
|
||||||
|
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<T>(
|
||||||
|
url: string,
|
||||||
|
{ onMessage, onError, onClose }: SSEHandlers<T>,
|
||||||
|
): () => 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user