import { 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 { useAuthServiceContext } from "../../../services"; import { RETRIEVABLE_TYPES, grantedTypes, useRecordsConnectionsService, type ExternalPatientMatch, type RecordsConnection, type RetrievableType, type RetrievalChoice, } from "../../../services/RecordsConnectionsService.tsx"; import { relayServerFailure } from "../../../utils/fetchFailureMessage"; import type { ReferralRequestScope } from "../../../utils/referralRequestScope"; import { connectionStateLabel, replacedInUse, systemName } from "./connectionWords.ts"; import { RETRIEVABLE_WORDS, retrievableWords } from "./retrievalWords.ts"; interface Props { /** The local patient this request is matched to; only their own connection may read into it. */ patientId?: string; /** Runs one read for this panel's request, practice and viewer; undefined when it was retired before it answered. */ read: (work: (scope: ReferralRequestScope) => Promise) => Promise; /** A retrieval is running: the panel says how long it may take, and starts nothing else. */ retrieving: boolean; busy: boolean; onRetrieve: (choice: RetrievalChoice) => void; } const describe = (patient: ExternalPatientMatch): string => [patient.name ?? "No name given", patient.birthDate ? "born " + patient.birthDate : undefined, patient.gender].filter(Boolean).join(", "); // Retrieving records for an outgoing request through the staff member's own connection to another system. Each step // opens only once the one before it is answered, and changing an answer clears every step after it. The patient found // there is that system's, pinned for this retrieval only; the request's own patient match is never made from it. // Once the request is matched, the patient's own connection is offered too: its grant is already pinned to them, so // there is nobody to look up, and the server decides whether this staff member may read it. const RecordsRetrievePanel = ({ patientId, read, retrieving, busy, onRetrieve }: Props) => { const { currentUser } = useAuthServiceContext(); const { listConnections, searchExternalPatients } = useRecordsConnectionsService(); const me = currentUser?.fhir?.id ? "Practitioner/" + currentUser.fhir.id : undefined; const [connections, setConnections] = useState(); const [loadError, setLoadError] = useState(); const [connectionId, setConnectionId] = useState(); const [name, setName] = useState(""); const [birthDate, setBirthDate] = useState(""); const [searching, setSearching] = useState(false); const [found, setFound] = useState<{ patients: ExternalPatientMatch[]; more: boolean; withBirthDate: boolean }>(); const [searchError, setSearchError] = useState(); const [externalId, setExternalId] = useState(); const [types, setTypes] = useState>(new Set()); // Only the latest search answers: an earlier one that lands late never replaces it. const searches = useRef(0); // A changed name, date or connection retires the search under way, settles it, and withdraws what it offered; a new // viewer, request, practice or local patient is a new panel altogether. const retireSearch = () => { searches.current += 1; setSearching(false); setSearchError(undefined); setFound(undefined); setExternalId(undefined); }; useEffect(() => { let live = true; read((scope) => listConnections(scope)) .then((listed) => { if (live && listed) setConnections(listed); }) .catch((failure) => { if (live) setLoadError(relayServerFailure(failure, "Your connections could not be read.")); }); return () => { live = false; }; }, [listConnections, read]); // One replaced by a connection in use is not another choice: the new one is offered instead. const current = (connections ?? []).filter((c) => c.status !== "disconnected" && !replacedInUse(c, connections ?? [])); // A staff member's own connections only: one made with someone else's login at the other system is theirs alone. const own = current.filter((c) => c.audience === "staff-delegated" && c.owner === me); // The request's own patient's connections only, never another patient's. const theirs = patientId ? current.filter((c) => c.audience === "patient" && c.localPatient === patientId) : []; const usable = (c: RecordsConnection) => connectionStateLabel(c) === "Connected"; const chosen = [...own, ...theirs].find((c) => c.connectionId === connectionId && usable(c)); const pinned = chosen?.audience === "patient"; const granted = chosen ? grantedTypes(chosen.grantedScopes) : []; // The connection's own lookup rule, as the server registered it; one stored before the rule was told is optional. const dateRequired = chosen?.lookup?.birthDate === "required"; const notGranted = RETRIEVABLE_TYPES.filter((type) => !granted.includes(type)); const choose = (id: string) => { setConnectionId(id); retireSearch(); // A patient's own grant has no lookup step: what it reads is chosen at once. const patients = theirs.find((c) => c.connectionId === id); if (patients) setTypes(new Set(grantedTypes(patients.grantedScopes))); }; const search = async () => { if (!chosen) return; // The same rule the button holds to, so no other way in sends a name alone where a birth date is needed. if (dateRequired && !birthDate) { setSearchError("Enter the patient's birth date to search " + systemName(chosen) + "."); return; } const mine = ++searches.current; setSearching(true); setSearchError(undefined); setFound(undefined); setExternalId(undefined); try { const answer = await read((scope) => searchExternalPatients(chosen.connectionId, name.trim(), birthDate || undefined, scope)); if (mine === searches.current && answer) setFound({ ...answer, withBirthDate: Boolean(birthDate) }); } catch (failure) { if (mine === searches.current) setSearchError(relayServerFailure(failure, "The other system could not be searched.")); } finally { if (mine === searches.current) setSearching(false); } }; const pick = (id: string) => { setExternalId(id); setTypes(new Set(granted)); }; const toggle = (type: RetrievableType) => setTypes((now) => { const next = new Set(now); if (next.has(type)) next.delete(type); else next.add(type); return next; }); const patient = found?.patients.find((p) => p.id === externalId); // Ready to retrieve: a patient found through a staff login, or the patient's own grant, which names its patient. const ready = !!chosen && (pinned || !!patient); const asked = RETRIEVABLE_TYPES.filter((type) => types.has(type) && granted.includes(type)); return (

Retrieve from a connection

{loadError && ( {loadError} )} {!connections && !loadError &&

} {connections && own.length === 0 && theirs.length === 0 && ( <>

You have no connection of your own to another system.

)} {own.length > 0 && (
Your connection {own.map((c) => ( ))}
)} {theirs.length > 0 && (
The patient's own connection {theirs.map((c) => ( ))}
)} {chosen && !pinned && (
{dateRequired &&

Enter the patient's name and birth date to search {systemName(chosen)}.

}
)} {chosen && searchError && ( {searchError} )} {chosen && found && found.patients.length === 0 && (

{systemName(chosen)} found no one with that name{found.withBirthDate ? " and birth date" : ""}.

)} {chosen && found && found.patients.length > 0 && (
At {systemName(chosen)} {found.patients.map((p) => ( ))} {found.more &&

There are more matches at {systemName(chosen)}; add the birth date or more of the name.

}
)} {chosen && ready && (
What to retrieve {granted.map((type) => ( ))} {notGranted.length > 0 && (

Not granted on this connection: {notGranted.map((type) => RETRIEVABLE_WORDS[type]).join(", ")}

)}
)} {chosen && ready && (

Retrieve {asked.map((type) => RETRIEVABLE_WORDS[type].toLowerCase()).join(", ") || "nothing"}{" "} {patient ? "for " + describe(patient) : "for this request's patient"} from {systemName(chosen)} into this request {pinned ? ", through the connection the patient made" : ""}. Nothing is added to the chart until each item is checked.

If not everything can be read, nothing is kept: untick a kind of record and retrieve again.

{retrieving && (

)}
)}
); }; export default RecordsRetrievePanel;