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>
112 lines
3.1 KiB
TypeScript
112 lines
3.1 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|