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:
2026-04-11 16:25:44 +00:00
parent b67dc47dc7
commit 10540a38b4
4 changed files with 388 additions and 12 deletions

View 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>
);
}