import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { FormProvider, useForm } from "react-hook-form"; import type { Patient } from "fhir/r4"; import { AlertCircle, Loader2 } from "lucide-react"; import { Alert, AlertDescription } from "../../../components/ui/alert.tsx"; import { Button } from "../../../components/ui/button.tsx"; import PatientSearch from "../../../components/PatientSearch/PatientSearch"; import { newOperationId, recordsConflict, useRecordsService, type RecordsRequest } from "../../../services/RecordsService.tsx"; import { useRecordsIntakeService, type RecordsFaxItem, type RecordsIntake, type RecordsWrite } from "../../../services/RecordsIntakeService.tsx"; import { formatDateTime } from "../../../utils/dateUtils"; import { relayServerFailure } from "../../../utils/fetchFailureMessage"; import { actionAttempt, stillOpen, type ActionAttempt, type ReferralRequestScope } from "../../../utils/referralRequestScope"; import { isSaveRetired, useSaveScope, type SaveOperation } from "../../../utils/saveOperation"; import { onViewerChange, viewerEpoch, writeThroughViewer } from "../../../utils/writeThroughHold"; import { useRecordsConnectionsService, type RetrievalChoice } from "../../../services/RecordsConnectionsService.tsx"; import RecordsImportReview from "./RecordsImportReview.tsx"; import RecordsRetrievePanel from "./RecordsRetrievePanel.tsx"; import { notOfferedLine, retrievalLine } from "./retrievalWords.ts"; interface Props { request: RecordsRequest; /** Whether this caller may take steps on the request now. */ canStep: boolean; /** Whether this caller holds the authority to work the request at all; losing it retires every step under way. */ mayAct?: boolean; /** The signed-in practitioner, as Practitioner/: only a retrieval's own starter may check it again. */ me?: string; onRequest: (request: RecordsRequest) => void; } const STATE_WORDS: Record = { "awaiting-upload": "Not received yet: uploading, or still being checked for safety", received: "Received, not filed yet", filed: "Filed to the chart", }; // An upload and its check run as one action, so it gets longer than a single save. const ACTION_LIFETIME_MS = 10 * 60 * 1000; // One panel per request and the practice that holds it: a different one is a new panel, so nothing from the // last one shows here, and every call it makes is sent under that practice. const RecordsOriginals = (props: Props) => ; const OriginalsOf = ({ request, canStep, mayAct = canStep, me, onRequest }: Props) => { const organizationId = request.organizationId; const { listIntakes, originalLink, lookUpPatients } = useRecordsService(); const { uploadOriginal, receiveOriginal, takeFax, matchPatient, fileOriginal, listFaxItems } = useRecordsIntakeService(); const { startRetrieval } = useRecordsConnectionsService(); const [retrievingOpen, setRetrievingOpen] = useState(false); // A retrieval kept whose audit record is still owed: finished by sending exactly the same operation again. const [owed, setOwed] = useState<{ body: RetrievalChoice & RecordsWrite; message: string }>(); const form = useForm(); const [intakes, setIntakes] = useState(); // Whether the list was read whole, at which request version, under which viewer epoch and authority generation. Only // that proves nothing is missing; an epoch is never reused, so a viewer coming back does not bring an old list back. const [listing, setListing] = useState<{ complete: boolean; requestVersion?: number; epoch: number; authority: number }>(); const [faxes, setFaxes] = useState(); const [patient, setPatient] = useState(null); const [matching, setMatching] = useState(!request.patient); const [busy, setBusy] = useState(); const [error, setError] = useState(); const [notice, setNotice] = useState(); const fileInput = useRef(null); // A retried step keeps its operation id, so a lost answer is replayed rather than applied twice. Each step and choice // on each version keeps its own, so trying another retrieval meanwhile does not forget the first one's; a moved request // is a new version, and nothing kept for an older one is sent again. const pending = useRef(new Map()); const viewer = useSyncExternalStore(onViewerChange, writeThroughViewer); const epoch = useSyncExternalStore(onViewerChange, viewerEpoch); // Advanced on each loss of the authority to act, so a list read before it never counts after it. const [authority, setAuthority] = useState(0); const authorityNow = useRef(0); const accepting = request.state === "awaiting-records" || request.state === "records-received"; // Every call belongs to this panel's request, practice and viewer: admitted by it, sent under its practice, // and retired when any of them changes or the panel goes. Nothing a retired call started is sent, shown or opened. const lives = useSaveScope(organizationId + ":" + request.requestId); const scopeOf = useCallback((life: SaveOperation): ReferralRequestScope => ({ admit: life.admit, signal: life.transport.signal, organizationId }), [organizationId]); // The patient search for matching goes through this request, so Records staff without a chart role can use it. // A newer search retires the one it replaces, so an overtaken read is cancelled rather than merely ignored. const searchLife = useRef(); const searchForMatch = useCallback(async (term: string) => { searchLife.current?.retire(); const life = lives.start(); searchLife.current = life; try { return await lookUpPatients(request.requestId, term, scopeOf(life)); } finally { life.retire(); } }, [lives, scopeOf, lookUpPatients, request.requestId]); // A step is also bound to the authority to work the request: losing it retires every step under way for good, // so getting it back does not revive one. const mayActNow = useRef(mayAct); mayActNow.current = mayAct; // The local patient a retrieval reads for. A choice made at the other system is made for them alone, so any change // of patient (matched, rematched or unmatched, back to an earlier one too) starts the retrieval panel over and // retires every retrieval under way for good: what it had not sent is never sent, and its answer is not shown. const subject = request.patient?.id; const retrievals = useRef(new Set()); const retrievalsFor = useRef(subject); const attempts = useRef(new Set()); // The step that set busy, so only it clears busy; a retired step's late finally never clears a newer one. const busyBy = useRef(); const lostAuthority = useRef(false); useEffect(() => { if (mayAct) { // Back from a loss: read where the request stands, since a retired step may have landed before it went. if (lostAuthority.current) { lostAuthority.current = false; void reload(); } return; } lostAuthority.current = true; authorityNow.current += 1; setAuthority(authorityNow.current); attempts.current.forEach((attempt) => attempt.retire()); attempts.current.clear(); busyBy.current = undefined; setBusy(undefined); // eslint-disable-next-line react-hooks/exhaustive-deps -- runs on a change of authority, not on every new reload }, [mayAct]); useEffect(() => { if (retrievalsFor.current === subject) return; retrievalsFor.current = subject; retrievals.current.forEach((attempt) => { attempt.retire(); if (busyBy.current === attempt) { busyBy.current = undefined; setBusy(undefined); } }); retrievals.current.clear(); }, [subject]); const actionScope = (): ActionAttempt => { const attempt = actionAttempt(scopeOf(lives.start(ACTION_LIFETIME_MS)), () => mayActNow.current); attempts.current.add(attempt); attempt.signal.addEventListener("abort", () => attempts.current.delete(attempt), { once: true }); return attempt; }; // Only the latest list answer is shown: an earlier read that lands late never replaces a newer one. const reads = useRef(0); const reload = useCallback(async () => { const life = lives.start(); const scope = scopeOf(life); const run = ++reads.current; const read = { epoch: viewerEpoch(), authority: authorityNow.current }; try { const list = await listIntakes(request.requestId, scope); if (run === reads.current && stillOpen(scope)) { setIntakes(list.intakes as RecordsIntake[]); setListing({ complete: list.complete, requestVersion: list.requestVersion, ...read }); } } catch (failure) { if (run !== reads.current || isSaveRetired(failure) || !stillOpen(scope)) return; console.error("Error reading the originals of a records request:", failure); setError(relayServerFailure(failure, "The originals could not be read.")); } finally { life.retire(); } }, [listIntakes, lives, scopeOf, request.requestId]); // Read again whenever the request moves, so a step taken in another window shows here too. // A new viewer epoch reads the list again for itself. useEffect(() => { void reload(); }, [reload, request.version, epoch]); // A step written from the request version it starts on; a step that follows another in the same action // passes that answer, so it does not send the version the first one already moved past. const step = async ( key: string, run: (write: RecordsWrite, scope: ReferralRequestScope) => Promise, from: RecordsRequest = request, // A step chained to another in one action shares its attempt; the action owns and retires it. shared?: ActionAttempt, ): Promise => { const scope = shared ?? actionScope(); const full = key + "@" + from.version; const operationId = pending.current.get(full) ?? newOperationId(); pending.current.set(full, operationId); busyBy.current = scope; setBusy(key); setError(undefined); setNotice(undefined); try { const answer = await run({ operationId, expectedVersion: from.version }, scope); if (!stillOpen(scope)) return undefined; onRequest(answer.request); return answer; } catch (failure) { if (isSaveRetired(failure) || !stillOpen(scope)) return undefined; const conflict = recordsConflict(failure); if (conflict?.current) onRequest(conflict.current); if (conflict) setNotice(conflict.code === "scan" ? "The file is still being checked for safety. Check again in a minute." : conflict.message); else { console.error("Error on a records original:", failure); setError(relayServerFailure(failure, "That step was not confirmed. Try again; it will not be applied twice.")); } return undefined; } finally { if (busyBy.current === scope) { busyBy.current = undefined; setBusy(undefined); } if (stillOpen(scope)) void reload(); if (!shared) scope.retire(); } }; const upload = async (file: File | undefined) => { if (fileInput.current) fileInput.current.value = ""; if (!file) return; if (file.type !== "application/pdf") { setNotice("Originals are filed as PDF."); return; } const attempt = actionScope(); try { const opened = await step("upload", (write, scope) => uploadOriginal(request.requestId, write, file, scope), request, attempt); if (opened && stillOpen(attempt)) await check(opened.intake.intakeId, opened.request, attempt); } finally { attempt.retire(); } }; // The version the list was read whole at, while it is still the request's version, in this viewer epoch and authority // generation. Anything newer retires it, and nothing new is offered until the list is read whole again. const certifiedAt = listing?.complete && listing.requestVersion === request.version && listing.epoch === epoch && listing.authority === authority ? listing.requestVersion : undefined; // A retrieval promised and not yet received may still be kept; a further one is only ever a separate retrieval. const unresolved = (intakes ?? []).filter((intake) => intake.channel === "connection" && intake.state === "awaiting-upload" && intake.original !== "observed"); const check = (intakeId: string, from?: RecordsRequest, shared?: ActionAttempt) => step("receive-" + intakeId, (write, scope) => receiveOriginal(request.requestId, intakeId, write, scope), from, shared); // One read for the retrieval panel, under this panel's request, practice and viewer; retired, it answers nothing. const readOnce = useCallback(async (work: (scope: ReferralRequestScope) => Promise): Promise => { const life = lives.start(); const scope = scopeOf(life); try { const value = await work(scope); return stillOpen(scope) ? value : undefined; } catch (failure) { if (isSaveRetired(failure) || !stillOpen(scope)) return undefined; throw failure; } finally { life.retire(); } }, [lives, scopeOf]); // Reads the patient's records through the connection into this request, then checks what was kept like any upload. // The same choice on the same version is one operation, so a lost answer is replayed and never read twice; a changed // choice is a new one. It belongs to the patient the request is matched to when it starts, and ends with them. const retrieve = async (choice: RetrievalChoice) => { const at = certifiedAt; if (at === undefined) { setNotice("The originals are being read again before another retrieval. Try once the list is complete."); return; } const attempt = actionScope(); retrievals.current.add(attempt); attempt.signal.addEventListener("abort", () => retrievals.current.delete(attempt), { once: true }); let sent: (RetrievalChoice & RecordsWrite) | undefined; try { const kept = await step("retrieve:" + JSON.stringify(choice), (write, scope) => { sent = { ...write, ...choice }; return startRetrieval(request.requestId, sent, scope); }, { ...request, version: at }, attempt); if (!kept || !stillOpen(attempt)) return; setRetrievingOpen(false); setOwed(kept.evidence === "owed" && sent ? { body: sent, message: kept.message ?? "Kept, but its record is not finished. Send it again to finish it." } : undefined); await check(kept.intake.intakeId, kept.request, attempt); } finally { attempt.retire(); } }; // Its own starter checks a promised retrieval again as the same operation: the same choice on the version it began // at, so the server answers what it kept without reading the other system again. const replay = async (intake: RecordsIntake) => { if (!intake.operationId || !intake.choice || intake.baseline === undefined) return; const body = { operationId: intake.operationId, expectedVersion: intake.baseline, ...intake.choice } as RetrievalChoice & RecordsWrite; // Owned like any retrieval: a change of patient retires it for good before its answer can be shown or followed. const attempt = actionScope(); retrievals.current.add(attempt); attempt.signal.addEventListener("abort", () => retrievals.current.delete(attempt), { once: true }); try { const kept = await step("replay-" + intake.intakeId, (_write, scope) => startRetrieval(request.requestId, body, scope), request, attempt); if (!kept || !stillOpen(attempt)) return; setOwed(kept.evidence === "owed" ? { body, message: kept.message ?? "Kept, but its record is not finished. Send it again to finish it." } : undefined); await check(kept.intake.intakeId, kept.request, attempt); } finally { attempt.retire(); } }; const finishOwed = async () => { if (!owed) return; const kept = await step("retrieve-finish", (_write, scope) => startRetrieval(request.requestId, owed.body, scope)); if (kept && kept.evidence !== "owed") setOwed(undefined); }; const showFaxes = async () => { const life = lives.start(); const scope = scopeOf(life); setError(undefined); try { const items = await listFaxItems(scope); if (stillOpen(scope)) setFaxes(items); } catch (failure) { if (isSaveRetired(failure) || !stillOpen(scope)) return; console.error("Error reading the Records fax queue:", failure); setError(relayServerFailure(failure, "The received faxes could not be read.")); } finally { life.retire(); } }; const open = async (intakeId: string) => { const life = lives.start(); const scope = scopeOf(life); setError(undefined); try { const { url } = await originalLink(request.requestId, intakeId, scope); if (!stillOpen(scope)) return; window.open(url, "_blank", "noopener"); } catch (failure) { if (isSaveRetired(failure) || !stillOpen(scope)) return; const conflict = recordsConflict(failure); if (conflict) setNotice(conflict.code === "scan" ? "The file is still being checked for safety." : conflict.message); else setError(relayServerFailure(failure, "The original could not be opened.")); } finally { life.retire(); } }; const filedCount = intakes?.filter((intake) => intake.state === "filed").length ?? 0; const filedAny = filedCount > 0; // The matched chart is where originals go, not a claim that they are there: an original still uploading, being // checked or waiting to be filed is not in it. Say how many are filed once the list is read. const patientLine = !request.patient ? "Match the patient before filing. Nothing is filed until then." : "Patient matched: originals go to that chart when filed." + (!intakes || intakes.length === 0 ? "" : filedAny ? ` ${filedCount} of ${intakes.length} filed.` : " None is filed yet."); // Leaving a change of patient without matching: the choice and its search go with it, nothing is sent, and the // request keeps the patient it already has. The next change starts from no choice. const cancelMatching = () => { searchLife.current?.retire(); form.reset(); setPatient(null); setMatching(false); }; return (

Originals received

{error && ( {error} )} {notice && ( {notice} )}

{patientLine}

{canStep && request.patient && !filedAny && !matching && ( )} {canStep && matching && !filedAny && (
{/* Only back to a patient already matched: before the first match there is nothing to return to. */} {request.patient && ( )}
)}
{!intakes && !error &&

} {intakes && intakes.length === 0 &&

Nothing received yet.

} {listing && !listing.complete && (

The originals could not all be listed, so no new retrieval is offered until they are read whole.

)} {intakes && intakes.length > 0 && (
    {intakes.map((intake) => (
  • {intake.channel === "fax" ? "Received fax" : intake.channel === "connection" ? "Retrieved through a connection" : "Uploaded original"} {STATE_WORDS[intake.state]} Added {formatDateTime(intake.createdAt)} {intake.retrieval && {retrievalLine(intake.retrieval)}} {intake.retrieval && notOfferedLine(intake.retrieval) && {notOfferedLine(intake.retrieval)}}
    {intake.state === "awaiting-upload" && canStep && intake.channel !== "connection" && ( )} {intake.state === "awaiting-upload" && canStep && intake.channel === "connection" && ( !intake.operationId ? This retrieval was kept before its operation was recorded, so it cannot be checked again here. : me && intake.createdBy === me ? : Only the person who started this retrieval can check it again. )} {intake.state !== "awaiting-upload" && ( )} {intake.state === "received" && canStep && ( )}
    {intake.state !== "awaiting-upload" && (
    )}
  • ))}
)} {canStep && accepting && (
void upload(event.target.files?.[0])} /> {certifiedAt !== undefined && ( )}
)} {canStep && accepting && certifiedAt !== undefined && unresolved.length > 0 && (

An earlier retrieval may still finish keeping its original. Retrieving again is a separate retrieval.

)} {owed && canStep && (

{owed.message}

)} {retrievingOpen && canStep && accepting && ( void retrieve(choice)} /> )} {faxes && canStep && accepting && (
{faxes.length === 0 &&

No received faxes are waiting for Records.

} {faxes.length > 0 && (
    {faxes.map((fax) => (
  • {fax.label ?? "Received fax"} {fax.kind === "records" ? "In Records" : "Not sorted yet"} {[fax.receivedAt ? "Received " + formatDateTime(fax.receivedAt) : undefined, fax.pages ? fax.pages + " pages" : undefined].filter(Boolean).join(", ")}
  • ))}
)}
)}
); }; export default RecordsOriginals;