import { useCallback, useEffect, useRef, useState } from "react"; import { AlertCircle, Loader2 } from "lucide-react"; import { Alert, AlertDescription } from "../../../components/ui/alert.tsx"; import { Button } from "../../../components/ui/button.tsx"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../../../components/ui/dialog.tsx"; import { newOperationId, recordsConflict, useRecordsService, type RecordsDetailsChange, type RecordsHistoryEntry, type RecordsHistoryPage, type RecordsRequest, type RecordsRequestAction, } from "../../../services/RecordsService.tsx"; import { useListOwner } from "../../../hooks/useListOwner"; import { formatDate, formatDateTime } from "../../../utils/dateUtils"; import { relayServerFailure } from "../../../utils/fetchFailureMessage"; import { actionAttempt, stillOpen, type ActionAttempt, type ReferralRequestScope } from "../../../utils/referralRequestScope"; import { isSaveRetired, startOwnedSave, useSaveScope, type SaveOperation } from "../../../utils/saveOperation"; import RecordsDetailsForm, { type RecordsDraft } from "./RecordsDetailsForm.tsx"; import RecordsIncomingMatch from "./RecordsIncomingMatch.tsx"; import RecordsOriginals from "./RecordsOriginals.tsx"; import RecordsReleasePanel from "./RecordsReleasePanel.tsx"; import { BASIS_LABEL, SCOPE_LABEL, basisAvailability, claimedByOther, stateLabel } from "./recordsWords.ts"; interface Props { requestId: string; organizationId?: string; me?: string; mayWork: boolean; onClose: () => void; onChanged: (request: RecordsRequest) => void; } const FINISHED = new Set(["delivered", "filed", "cancelled"]); // A release in these states went out: how it went is read from its own receipts, never from today's details. const RELEASED_STATES = new Set(["delivering", "delivery-unconfirmed", "delivered"]); const ACTION_LIFETIME_MS = 10 * 60 * 1000; // A browser timeout is a 32-bit delay; a later deadline is reached in bounded steps instead. const LONGEST_TIMEOUT_MS = 2_147_483_647; /** Pages of one history, in version order: a version read twice keeps the entry first read. */ const appendHistory = (current: RecordsHistoryEntry[], page: RecordsHistoryEntry[]): RecordsHistoryEntry[] => { const known = new Map(current.map((entry) => [entry.version, entry])); for (const entry of page) if (!known.has(entry.version)) known.set(entry.version, entry); return [...known.values()].sort((a, b) => a.version - b.version); }; const RecordsRequestDialog = ({ requestId, organizationId, me, mayWork, onClose, onChanged }: Props) => { const { getRequest, getHistory, writeRequest, settleEvidence } = useRecordsService(); const [request, setRequest] = useState(); const [history, setHistory] = useState([]); // The history's continuation and its read-on state belong to one history generation; a fresh read starts another. const [historyCursor, setHistoryCursor] = useState(); const [historyPartial, setHistoryPartial] = useState(false); const [historyMore, setHistoryMore] = useState<{ busy?: boolean; error?: string; stalled?: boolean }>({}); const [error, setError] = useState(); const [notice, setNotice] = useState(); const [busy, setBusy] = useState(); // The version an edit opened, and the edits carried onto a newer one the editor chose to review. const [editing, setEditing] = useState<{ base: RecordsRequest; carried?: Partial }>(); // How a release went, as the release panel read it for one version. const [releasedSeen, setReleasedSeen] = useState<{ version: number; method?: string }>(); // A retried step keeps its operation id, so a lost answer is replayed rather than applied twice. const pending = useRef<{ key: string; operationId: string }>(); // Each read and write is admitted only while it is the latest one this dialog started. const run = useRef(0); // Ending a request asks first, for the version on screen; a newer version, another request or lost authority retires it. const [confirmingCancel, setConfirmingCancel] = useState(); useEffect(() => { setConfirmingCancel(undefined); }, [request?.version, requestId, organizationId, mayWork]); const [now, setNow] = useState(() => Date.now()); const clockOf = request ? [requestId, organizationId ?? "", me ?? "", request.version, request.claim?.by ?? "", request.claim?.until ?? ""].join("\u0000") : undefined; const clockNow = useRef(clockOf); clockNow.current = clockOf; // A deadline lapses on the real clock, not the instant its timeout was expected to fire. useEffect(() => { if (!clockOf) return; const until = request?.claim ? Date.parse(request.claim.until) : Number.NaN; let timer: ReturnType | undefined; const sync = () => { if (clockNow.current !== clockOf) return; if (timer !== undefined) clearTimeout(timer); timer = undefined; const sampled = Date.now(); setNow(sampled); if (!Number.isFinite(until) || sampled >= until) return; timer = setTimeout(sync, Math.min(until - sampled, LONGEST_TIMEOUT_MS)); }; // Background tabs may defer the deadline callback. const visible = () => { if (document.visibilityState === "visible") sync(); }; window.addEventListener("focus", sync); window.addEventListener("pageshow", sync); document.addEventListener("visibilitychange", visible); sync(); return () => { if (timer !== undefined) clearTimeout(timer); window.removeEventListener("focus", sync); window.removeEventListener("pageshow", sync); document.removeEventListener("visibilitychange", visible); }; }, [clockOf, request]); useEffect(() => () => { run.current += 1; }, [requestId]); // Every call is this opening's: admitted by it, sent under its practice, and cancelled when it closes or its request, // practice or viewer changes. A step is also bound to the authority to work records; losing it retires the step. const lives = useSaveScope(organizationId + ":" + requestId); const mayWorkNow = useRef(mayWork); mayWorkNow.current = mayWork; // Losing the authority retires every step under way for good, so getting it back does not revive one. const attempts = useRef(new Set()); // The settlement that set busy: retired, it is cleared here, and its own late answer never clears a newer one. const settleBy = useRef(); useEffect(() => { if (mayWork) return; attempts.current.forEach((attempt) => attempt.retire()); attempts.current.clear(); if (settleBy.current) { settleBy.current = undefined; setBusy(undefined); } }, [mayWork]); const scopeOf = useCallback((life: SaveOperation): ReferralRequestScope => ({ admit: life.admit, signal: life.transport.signal, organizationId }), [organizationId]); // Every history read goes through here, under this opening, viewer and session. Reading from the start begins a new // generation and retires the old one's continuation; only the latest generation may show what it read. const { owner: viewer, live } = useListOwner(getHistory); const viewerNow = useRef(viewer); viewerNow.current = viewer; const historyRun = useRef(0); const historyLife = useRef(); const historyInFlight = useRef(null); const historyCursorNow = useRef(); historyCursorNow.current = historyCursor; // Every continuation this history generation has already read from; one handed back again would only go round. const historyTraversed = useRef<{ run: number; cursors: Set }>({ run: 0, cursors: new Set() }); const ownedRead = (life: SaveOperation, ours: () => boolean): ReferralRequestScope => ({ ...scopeOf(life), admit: () => { if (!ours()) throw new DOMException("This history was replaced.", "AbortError"); life.admit(); }, }); const takeFirstPage = (page: RecordsHistoryPage) => { setHistory(page.history); setHistoryPartial(page.truncated && !page.nextCursor); setHistoryCursor(page.truncated ? page.nextCursor : undefined); }; // A first page answered or failed, with the check that it still belongs: read again at the moment it is shown. type HistoryRead = { ours: () => boolean; page?: RecordsHistoryPage; failure?: unknown }; const readHistory = useCallback(async (): Promise => { const mine = ++historyRun.current; historyLife.current?.retire(); const life = lives.start(); historyLife.current = life; historyTraversed.current = { run: mine, cursors: new Set() }; const of = viewerNow.current; const ours = () => historyRun.current === mine && life.belongs() && live(of); setHistoryCursor(undefined); setHistoryPartial(false); setHistoryMore({}); try { return { ours, page: await getHistory(requestId, ownedRead(life, ours)) }; } catch (failure) { return { ours, failure }; } }, [getHistory, live, lives, requestId, scopeOf]); // eslint-disable-line react-hooks/exhaustive-deps const refreshHistory = useCallback(() => { void readHistory().then(({ ours, page, failure }) => { if (!ours()) return; if (page) { takeFirstPage(page); return; } if (isSaveRetired(failure)) return; console.error("Error reading a records request history:", failure); setHistoryMore({ error: "The history could not be read again. Close and open the request to see it." }); }); }, [readHistory]); const loadMoreHistory = async () => { const mine = historyRun.current; const cursor = historyCursorNow.current; const parent = historyLife.current; if (!cursor || !parent || historyInFlight.current === mine) return; historyInFlight.current = mine; const life = startOwnedSave(parent); const of = viewerNow.current; const ours = () => historyRun.current === mine && parent.belongs() && live(of); setHistoryMore({ busy: true }); try { const page = await getHistory(requestId, ownedRead(life, ours), { cursor }); if (!ours()) return; setHistory((current) => appendHistory(current, page.history)); const next = page.truncated ? page.nextCursor : undefined; setHistoryPartial(page.truncated && !page.nextCursor); // A place read once already this generation would only go round again, however many steps away it was. const walked = historyTraversed.current.run === mine ? historyTraversed.current.cursors : new Set(); walked.add(cursor); historyTraversed.current = { run: mine, cursors: walked }; if (next && walked.has(next)) { setHistoryCursor(undefined); setHistoryMore({ stalled: true }); } else { setHistoryCursor(next); setHistoryMore({}); } } catch (failure) { if (!ours() || isSaveRetired(failure)) return; console.error("Error reading more records request history:", failure); setHistoryMore({ error: relayServerFailure(failure, "More history could not be loaded.") }); } finally { if (historyInFlight.current === mine) historyInFlight.current = null; if (ours()) setHistoryMore((now) => (now.busy ? { ...now, busy: false } : now)); } }; const load = useCallback(async () => { const mine = ++run.current; setError(undefined); try { const scope = scopeOf(lives.start()); const [fresh, past] = await Promise.all([getRequest(requestId, scope), readHistory()]); if (run.current !== mine) return; // A history read since replaced shows nothing, its failure included; the newer read speaks for the history. if (past.ours() && past.failure !== undefined) throw past.failure; setRequest(fresh); if (past.ours() && past.page) takeFirstPage(past.page); onChanged(fresh); } catch (failure) { if (run.current !== mine || isSaveRetired(failure)) return; console.error("Error reading a records request:", failure); setError(relayServerFailure(failure, "This request could not be read.")); } }, [getRequest, lives, onChanged, readHistory, requestId, scopeOf]); // A new viewer or sign-in session reads the request and its history again. useEffect(() => { void load(); }, [load, viewer]); const act = async (action: RecordsRequestAction, change: RecordsDetailsChange = {}, expectedVersion = request?.version) => { if (!request || expectedVersion === undefined) return; const mine = ++run.current; const scope = actionAttempt(scopeOf(lives.start(ACTION_LIFETIME_MS)), () => mayWorkNow.current); attempts.current.add(scope); scope.signal.addEventListener("abort", () => attempts.current.delete(scope), { once: true }); const key = action + JSON.stringify(change) + expectedVersion; if (pending.current?.key !== key) pending.current = { key, operationId: newOperationId() }; setBusy(action); setError(undefined); setNotice(undefined); try { const answer = await writeRequest(request.requestId, { operationId: pending.current.operationId, expectedVersion, action, ...change }, scope); if (run.current !== mine) return; pending.current = undefined; setRequest(answer.request); onChanged(answer.request); setEditing(undefined); refreshHistory(); } catch (failure) { if (run.current !== mine || isSaveRetired(failure)) return; const conflict = recordsConflict(failure); if (conflict?.current) { pending.current = undefined; setRequest(conflict.current); onChanged(conflict.current); setNotice(conflict.message); } else if (conflict) { setNotice(conflict.message); } else { console.error("Error updating a records request:", failure); setError(relayServerFailure(failure, "That step was not confirmed. Try again; it will not be applied twice.")); } } finally { if (run.current === mine) setBusy(undefined); } }; // Finishes recording one owed step by name, whatever state the request is in now; a step not finished stays owed. const settle = async (owed: { step: string; operationId: string }) => { if (!request) return; const mine = ++run.current; const scope = actionAttempt(scopeOf(lives.start(ACTION_LIFETIME_MS)), () => mayWorkNow.current); attempts.current.add(scope); scope.signal.addEventListener("abort", () => attempts.current.delete(scope), { once: true }); settleBy.current = scope; setBusy("settle"); setError(undefined); setNotice(undefined); try { const answer = await settleEvidence(request.requestId, owed, scope); if (run.current !== mine || !stillOpen(scope)) return; setRequest(answer.request); onChanged(answer.request); if (answer.evidence !== "settled") setNotice(answer.message ?? "Not recorded yet. Try again to finish recording it."); } catch (failure) { // A retired settlement says nothing: its debt stays on the server for whoever works the request now. if (run.current !== mine || isSaveRetired(failure) || !stillOpen(scope)) return; const conflict = recordsConflict(failure); if (conflict) setNotice(conflict.message); else { console.error("Error finishing a records request's record:", failure); setError(relayServerFailure(failure, "That record was not confirmed. Try again; it is recorded once.")); } } finally { if (settleBy.current === scope) { settleBy.current = undefined; setBusy(undefined); } } }; const open = request && !FINISHED.has(request.state); const heldByOther = request ? claimedByOther(request, me, now) : false; const mine = Boolean(request?.claim && request.claim.by === me && Date.parse(request.claim.until) > now); const canStep = Boolean(mayWork && open && !heldByOther && !busy); const editLock = !mayWork ? "You can no longer change records requests." : !open ? "This request is finished, so its details are fixed." : heldByOther ? "Someone else is working this request, so its details cannot be saved now." : undefined; const released = Boolean(request && request.direction === "incoming" && RELEASED_STATES.has(request.state)); const releasedMethod = request && releasedSeen?.version === request.version ? releasedSeen.method : undefined; const shownMethod = released ? releasedMethod : request?.recipient?.method; return ( { if (!next && !busy) onClose(); }}> {request ? request.counterpart.name : "Records request"} {request ? (request.direction === "incoming" ? "Asks this practice for records" : "This practice asked for records") : "Reading the request"} {error && ( {error} )} {notice && ( {notice} )} {!request && !error &&

} {request && (
Status
{released && !releasedMethod && request.state === "delivering" ? "Released" : stateLabel(request.state, shownMethod)}
{request.counterpart.organization && (<>
Organization
{request.counterpart.organization}
)} {request.counterpart.fax && (<>
Fax
{request.counterpart.fax}
)} {request.counterpart.email && (<>
Email
{request.counterpart.email}
)} {request.dueAt && (<>
Due
{formatDate(request.dueAt)}
)} {request.direction === "incoming" && (<>
Which records
{SCOPE_LABEL[request.scope]}
)} {request.direction === "incoming" && ( <>
Basis
{request.basis ? BASIS_LABEL[request.basis.kind] + (request.basis.note ? ": " + request.basis.note : "") + basisAvailability(request.basis) : "Not decided yet"}
)} {request.recipient && (<>
{released ? "Request details name" : "Send to"}
{request.recipient.name + (request.recipient.fax ? ", fax " + request.recipient.fax : "") + " (" + request.recipient.method + ")"}
)}
Patient
{request.patient ? "Matched" : "Not matched yet"}
{request.claim && Date.parse(request.claim.until) > now && ( <>
Working
{mine ? "You" : "Someone else"}, until {formatDateTime(request.claim.until)}
)}
{mayWork && open && (
{!mine && !heldByOther && } {mine && } {request.state === "received" && } {request.state === "draft" && } {!editing && } {["received", "in-progress", "awaiting-approval", "approved", "draft", "awaiting-records"].includes(request.state) && confirmingCancel !== request.version && ( )}
)} {mayWork && open && confirmingCancel === request.version && (
Cancel this request? It ends for everyone working it. Its history and any patient match stay.
)} {heldByOther && mayWork && open &&

Someone else is working this request. It frees up when their hold lapses.

} {request.evidenceOwed && request.evidenceOwed.length > 0 && (

This request has not finished recording {request.evidenceOwed.length === 1 ? "one step" : request.evidenceOwed.length + " steps"}.

    {request.evidenceOwed.map((owed) => (
  • {owed.step === "provenance" ? "The record of a filed original" : "The record of a step on this request"} Owed since {formatDateTime(owed.since)} {mayWork && ( )}
  • ))}
)} {editing && ( void act("update-details", change, editing.base.version)} onReconcile={(carried) => setEditing({ base: request, carried })} /> )} {request.direction === "incoming" && ( { setRequest(next); onChanged(next); refreshHistory(); }} /> )} { setRequest(fresh); onChanged(fresh); refreshHistory(); }} /> {request.direction === "outgoing" && ["awaiting-records", "records-received", "filed"].includes(request.state) && ( { setRequest(next); onChanged(next); refreshHistory(); }} /> )}

History

    {history.map((entry) => (
  1. {formatDateTime(entry.at)} {entry.action.replace(/-/g, " ")} {stateLabel(entry.state, shownMethod)}
  2. ))}
{historyCursor && ( )} {historyMore.error &&

{historyMore.error}

} {historyCursor && history.length === 0 && !historyMore.error &&

There is more history to load.

} {historyPartial &&

More history was not loaded. Close and open the request to read it again.

} {historyMore.stalled &&

The history did not move on from here. Close and open the request to read it again.

}
)}
); }; export default RecordsRequestDialog;