import { useRef, useState } from "react"; import { AlertCircle, Upload } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../../../components/ui/dialog.tsx"; import { Button } from "../../../components/ui/button.tsx"; import { Alert, AlertDescription } from "../../../components/ui/alert.tsx"; import type { DiagnosticOrderQueueRow } from "../../../hooks/useDiagnosticOrderQueue.ts"; import { useImagingUploadService, type OwnedRequest, type ReconcileAnswer } from "../../../services/ImagingUploadService.tsx"; import { useOptionalAuthServiceContext } from "../../../services/AuthService.tsx"; import { authSessionId } from "../../../services/authStorage"; import { SaveRetiredError, isSaveRetired, startOwnedSave, useSaveScope, type SaveOperation } from "../../../utils/saveOperation"; import { relayServerFailure } from "../../../utils/fetchFailureMessage"; import "./RecordRefusalDialog.css"; type ImageCount = { studies: number; instances: number }; const POLL_ATTEMPTS = 10; const POLL_DELAY_MS = 3000; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const imagesLine = ({ instances }: ImageCount) => instances + (instances === 1 ? " image" : " images") + " filed against this order."; /** * Files a performed study against its order; the server finds the patient from the order. The import * finishes out of band, so the dialog waits for the count, reconciles a job whose completion event was * dropped, and otherwise says the study is still being filed rather than inviting a second upload. */ const UploadOrderImagesDialog = ({ row, onClose, onImported, recount, pollDelayMs = POLL_DELAY_MS, }: { row?: DiagnosticOrderQueueRow; onClose: () => void; onImported: (row: DiagnosticOrderQueueRow) => void; recount: (orderId: string) => Promise; pollDelayMs?: number; }) => { const { uploadStudy, uploadStudyFromZip, reconcileImport } = useImagingUploadService(); const folderInput = useRef(null); const zipInput = useRef(null); const [progress, setProgress] = useState<{ done: number; total: number }>(); const [phase, setPhase] = useState<"choose" | "uploading" | "filing" | "filed" | "processing" | "conflict">("choose"); const [filed, setFiled] = useState(); const [error, setError] = useState(); // This opening, its order, practice and sign-in own the upload and every check of it; closing retires them. const wpOrganization = useOptionalAuthServiceContext()?.wpOrganization; const practiceId = wpOrganization?.current?.id; // A change of practice or sign-in ends the opening, so no check it can no longer send stays on offer. const uploads = useSaveScope(`${row?.orderId ?? ""}|${practiceId ?? ""}|${authSessionId() ?? ""}`, onClose); const [conflict, setConflict] = useState<{ upload: SaveOperation; jobId: string; message: string }>(); if (!row?.orderId) return null; const orderId = row.orderId; const target = { orderId }; const onProgress = (done: number, total: number) => setProgress({ done, total }); const waitForImages = async (): Promise => { for (let attempt = 1; attempt <= POLL_ATTEMPTS; attempt++) { try { const count = await recount(orderId); if (count?.instances) return count; } catch { // A failed read is one more attempt, not a verdict on the upload. } if (attempt < POLL_ATTEMPTS) await sleep(pollDelayMs); } return undefined; }; // One check of the job, sent as the upload that asked and in the practice it was asked in. const checkAsked = async (upload: SaveOperation, importJobId: string): Promise => { const check = startOwnedSave(upload); const pinned = practiceId; const owned: OwnedRequest = { admit: () => { check.transport.admit(); if (wpOrganization?.current?.id !== pinned) throw new SaveRetiredError(); }, signal: check.transport.signal, organizationId: pinned, }; try { return await check.step(() => reconcileImport(importJobId, target, owned), true); } catch (failure) { if (isSaveRetired(failure)) throw failure; // A job still running refuses to reconcile; a check that could not be made is said so, never read as an answer. return "unavailable"; } }; // What a check found, for this upload only: a conflict waits for a person, anything else waits for the count. const settle = async (upload: SaveOperation, importJobId: string, answer: ReconcileAnswer | "unavailable") => { if (typeof answer === "object" && "kind" in answer) { setConflict({ upload, jobId: importJobId, message: answer.message }); setPhase("conflict"); return; } // Images already filed on another order will never count here, so waiting would only say "still filing". if (typeof answer === "object" && "alreadyFiled" in answer) { setError(answer.alreadyFiled); setPhase("choose"); return; } setPhase("filing"); const count = await waitForImages(); if (!upload.belongs()) return; setFiled(count); setPhase(count ? "filed" : "processing"); }; const run = async (send: () => Promise<{ importJobId: string }>) => { const upload = uploads.start(); setPhase("uploading"); setError(undefined); setProgress(undefined); let importJobId: string; try { ({ importJobId } = await send()); } catch (failure) { if (!upload.belongs()) return; setError(relayServerFailure(failure, "The images were not uploaded. Nothing was filed against this order.")); setPhase("choose"); return; } if (!upload.belongs()) return; onImported(row); setPhase("filing"); const count = await waitForImages(); if (!upload.belongs()) return; if (count) { setFiled(count); setPhase("filed"); return; } try { const answer = await checkAsked(upload, importJobId); if (upload.belongs()) await settle(upload, importJobId, answer); } catch (failure) { if (!isSaveRetired(failure)) throw failure; } }; // Checks the same job again, as the upload that met the conflict. A check that could not be made settles nothing. const recheck = async () => { if (!conflict || !conflict.upload.belongs()) return; const { upload, jobId } = conflict; setError(undefined); setPhase("filing"); try { const answer = await checkAsked(upload, jobId); if (!upload.belongs()) return; if (answer === "unavailable") { setError("The import could not be checked. Try again."); setPhase("conflict"); return; } await settle(upload, jobId, answer); } catch (failure) { if (!isSaveRetired(failure)) throw failure; } }; const onFolder = (event: React.ChangeEvent) => { const files = Array.from(event.target.files ?? []); event.target.value = ""; if (files.length) void run(() => uploadStudy(files, target, onProgress)); }; const onZip = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ""; if (file) void run(() => uploadStudyFromZip(file, target, onProgress)); }; const uploading = phase === "uploading"; const status = { choose: undefined, uploading: progress ? "Uploading " + progress.done + " of " + progress.total : "Reading the files...", filing: "Uploaded. Waiting for the study to be filed against this order...", filed: filed ? imagesLine(filed) : undefined, processing: "Uploaded. The study is still being filed, and its image count will appear on the order shortly. Do not upload it again.", conflict: conflict?.message, }[phase]; return ( { if (!open && !uploading) onClose(); }}> Upload images {[row.test ?? "This study", row.patientDisplay].filter(Boolean).join(" \u00b7 ")} {error && ( {error} )}
{phase === "choose" ? (

Choose the study folder, or a ZIP of it. Only DICOM files are sent; anything else on the disc is left out.

) : null} {status ?

{status}

: null}
{phase === "choose" || uploading ? ( <> ) : phase === "conflict" ? ( <> ) : ( )}
); }; export default UploadOrderImagesDialog;