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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user