import { useCallback, useEffect, useRef, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import type { Patient } from "fhir/r4"; import { AlertCircle } 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 } from "../../../services/RecordsIntakeService.tsx"; import { relayServerFailure } from "../../../utils/fetchFailureMessage"; import { getDisplayName } from "../../../utils/fhirHumanName.tsx"; import { actionAttempt, stillOpen, type ActionAttempt, type ReferralRequestScope } from "../../../utils/referralRequestScope"; import { isSaveRetired, useSaveScope, type SaveOperation } from "../../../utils/saveOperation"; interface Props { request: RecordsRequest; canStep: boolean; mayAct?: boolean; onRequest: (request: RecordsRequest) => void; } const MATCHABLE: readonly RecordsRequest["state"][] = ["received", "in-progress", "awaiting-approval", "approved"]; const ACTION_LIFETIME_MS = 10 * 60 * 1000; const RecordsIncomingMatch = (props: Props) => ; // Who an incoming request is about. Matching (or changing) the patient is what lets a packet be built; once // approved, a different patient withdraws that approval, so the panel says so before it is sent. const MatchOf = ({ request, canStep, mayAct = canStep, onRequest }: Props) => { const organizationId = request.organizationId; const { readMatchedPatient, lookUpPatients } = useRecordsService(); const { matchPatient } = useRecordsIntakeService(); const form = useForm(); const [chosen, setChosen] = useState(null); const [matching, setMatching] = useState(false); const [busy, setBusy] = useState(false); const [name, setName] = useState(); const [error, setError] = useState(); const [notice, setNotice] = useState(); // A retried match keeps its operation id, so a lost answer is replayed rather than applied twice. const pending = useRef<{ key: string; operationId: string }>(); const matchable = MATCHABLE.includes(request.state); const matchedId = request.patient?.id; // Every call belongs to this request, practice and viewer, and is retired when any of them changes or the panel goes. const lives = useSaveScope(organizationId + ":" + request.requestId); const scopeOf = useCallback((life: SaveOperation): ReferralRequestScope => ({ admit: life.admit, signal: life.transport.signal, organizationId }), [organizationId]); // Losing the authority to work the request retires a match under way for good; getting it back does not revive it. const mayActNow = useRef(mayAct); mayActNow.current = mayAct; const attempt = useRef(); useEffect(() => { if (mayAct) return; attempt.current?.retire(); attempt.current = undefined; setBusy(false); }, [mayAct]); // The matched patient's name; only the read for the patient now matched is shown. useEffect(() => { setName(undefined); if (!matchedId) return; const life = lives.start(); const scope = scopeOf(life); readMatchedPatient(request.requestId, scope) .then((patient) => { if (stillOpen(scope)) setName(getDisplayName(patient?.name) || undefined); }) .catch((failure) => { if (!isSaveRetired(failure) && stillOpen(scope)) console.error("Error reading the matched patient:", failure); }) .finally(() => life.retire()); return () => life.retire(); }, [matchedId, lives, scopeOf, readMatchedPatient, request.requestId]); // 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]); const match = async () => { if (!chosen?.id) return; const patientId = chosen.id; const scope = actionAttempt(scopeOf(lives.start(ACTION_LIFETIME_MS)), () => mayActNow.current); attempt.current = scope; const key = patientId + "@" + request.version; if (pending.current?.key !== key) pending.current = { key, operationId: newOperationId() }; setBusy(true); setError(undefined); setNotice(undefined); try { const answer = await matchPatient(request.requestId, patientId, { operationId: pending.current.operationId, expectedVersion: request.version }, scope); if (!stillOpen(scope)) return; pending.current = undefined; setMatching(false); setChosen(null); onRequest(answer.request); } catch (failure) { if (isSaveRetired(failure) || !stillOpen(scope)) return; const conflict = recordsConflict(failure); if (conflict?.current) { pending.current = undefined; onRequest(conflict.current); } if (conflict) setNotice(conflict.message); else setError(relayServerFailure(failure, "The match was not confirmed. Try again; it will not be applied twice.")); } finally { if (attempt.current === scope) { attempt.current = undefined; setBusy(false); } scope.retire(); } }; return (

Patient

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

{matchedId ? "This request is about " + (name ?? "the matched patient") + "." : "Match the patient this request is about before building the packet."}

{canStep && matchable && !matching && ( )} {canStep && matchable && matching && (
{matchedId && request.state === "approved" && (

Changing the patient withdraws the approval. The packet must be built and approved again.

)}
)}
); }; export default RecordsIncomingMatch;