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,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;
}