import { useCallback, useEffect, useMemo, useState } from "react"; import type { ServiceRequest } from "fhir/r4"; import { UnreadableWriteAnswerError, useHttpService } from "../services/HttpService.tsx"; import { environment } from "../environments/environment.ts"; import { relayServerFailure } from "../utils/fetchFailureMessage"; /** * Requisitions that arrived on paper, and the person who turns them into orders. * * NOTHING HERE HOLDS A PATIENT until somebody names one. What arrives is a page somebody read: the * patient's details as the sender WROTE them, which is what a human compares against the record. The * server will not create an order without an id a person chose, and this does not let the screen * pretend otherwise. * * A match only says who the paper is about; the row stays until somebody accepts the order, and the * list read lags its writes, so both are held here until the read catches up. */ export interface RequisitionCandidate { id: string; name?: string; birthDate?: string; } export interface InboundRequisition { requisitionId?: string; /** The practice it came from. A display string: they are not on the system. */ from?: string; npi?: string; test?: string; /** An imaging study rather than laboratory work; absent is laboratory. */ kind?: "imaging"; /** The patient AS WRITTEN ON THE PAPER, for a person to compare. Never a reference. */ patientName?: string; patientBirthDate?: string; theirOrderCode?: string; /** Our own code for the test, as the sending practice's reviewed pairing with us names it. Akashic orders only. */ ourTestCode?: string; /** The test as the sender coded it, LOINC as a rule. */ ourTestCoding?: { system?: string; code?: string; display?: string }; /** The sending practice's account with us. */ accountNumber?: string; note?: string; receivedAt?: string; /** Who a person matched it to. Matched is not accepted: there is no order until somebody accepts. */ matchedPatient?: { patientId: string; display?: string }; /** Sent by another Akashic practice rather than typed in from paper. */ akashic?: { orderedBy?: string }; } /** A staff attempt to tell a paper sender, with its stated outcome. Not proof anyone was reached. */ export interface RequisitionContact { /** Chosen once per attempt, so a resent save is recognised rather than recorded twice. */ id: string; method: "phone" | "fax"; recipient?: string; spokeWith?: string; outcome: string; occurredAt: string; recordedBy?: string; } export interface RefusedRequisition extends InboundRequisition { /** Set only once the Akashic sender's own order was updated with the refusal. Never for paper. */ senderRecordUpdated?: boolean; refusal?: { reasonCode?: string; message?: string; at?: string }; contacts?: RequisitionContact[]; } /** What the server stored, which a retry answers too. */ export interface RefusalResult { refusal?: { reasonCode?: string; message?: string; at?: string }; contacts?: RequisitionContact[]; senderRecordUpdated?: boolean; } /** A reply without what the server stored: the write may or may not have landed. */ export class UnconfirmedResult extends Error { constructor(what: string) { super(`The ${what} reply did not say what was stored.`); this.name = "UnconfirmedResult"; } } const storedRefusal = (result: RefusalResult | undefined, attemptId?: string): result is RefusalResult => Boolean(result?.refusal?.reasonCode) && Array.isArray(result?.contacts) && (!attemptId || (result?.contacts ?? []).some((each) => each.id === attemptId)); export interface MatchedRequisition { requisitionId?: string; matchedPatient?: { patientId: string; display?: string }; } export interface AcceptedRequisition { requisitionId?: string; orderId?: string; order?: ServiceRequest; } const ORDER_API = environment.fhirBaseApi; // A FHIR resource id. const REQUISITION_ID = /^[A-Za-z0-9\-.]{1,64}$/; // The contact route is served by the clinical stack. const CONTACT_API = environment.fhirClinicalApi; export const useInboundRequisitions = () => { const { get, post, put } = useHttpService(); const [listed, setListed] = useState([]); // The list read lags its writes: a requisition saved here stays listed until the read has it, and // one matched here stays off it. const [saved, setSaved] = useState([]); const [matched, setMatched] = useState>({}); const [accepted, setAccepted] = useState([]); const [listedRefused, setListedRefused] = useState([]); const [refusedHere, setRefusedHere] = useState([]); const [refusedUnreadable, setRefusedUnreadable] = useState(false); const [refusedPartial, setRefusedPartial] = useState(false); const [requisitionsPartial, setRequisitionsPartial] = useState(false); const [refusedWindowDays, setRefusedWindowDays] = useState(); const [loading, setLoading] = useState(true); const [error, setError] = useState(); const load = useCallback(async () => { setLoading(true); setError(undefined); try { const found = await get<{ requisitions?: InboundRequisition[]; requisitionsPartial?: boolean; refused?: RefusedRequisition[]; refusedUnreadable?: boolean; refusedPartial?: boolean; refusedWindowDays?: number; }>( `${ORDER_API}/inbound-requisition`, ); setListed(found?.requisitions ?? []); setRequisitionsPartial(found?.requisitionsPartial === true); setListedRefused(found?.refused ?? []); setRefusedUnreadable(found?.refusedUnreadable === true); setRefusedPartial(found?.refusedPartial === true); setRefusedWindowDays(found?.refusedWindowDays); } catch (failure) { console.error("Error loading inbound requisitions:", failure); setError(relayServerFailure(failure, "Could not load what has arrived. Please try again.")); } finally { setLoading(false); } }, [get]); // The operation names this one entry: sent again after a lost answer, the server finds what it stored. // Only a reply naming a valid requisition id confirms it; any other 2xx may have stored it, so it stays unsettled. const log = useCallback( async (requisition: Omit, operationId: string) => { const created = await post<{ requisitionId?: string; requisition?: InboundRequisition; receivedAt?: string }>( `${ORDER_API}/inbound-requisition`, { ...requisition, operationId }, ); if (typeof created?.requisitionId !== "string" || !REQUISITION_ID.test(created.requisitionId)) { throw new UnreadableWriteAnswerError(); } const requisitionId = created.requisitionId; setSaved((held) => [ ...held.filter((row) => row.requisitionId !== requisitionId), { ...requisition, ...created.requisition, requisitionId, receivedAt: created.receivedAt ?? new Date().toISOString() }, ]); return created; }, [post], ); const match = useCallback( async (requisitionId: string, patientId: string) => { const result = await put(`${ORDER_API}/inbound-requisition/${requisitionId}`, { patientId }); setMatched((held) => ({ ...held, [requisitionId]: result?.matchedPatient ?? { patientId } })); return result; }, [put], ); // Who a pending requisition could be about: a name or a full phone number, answered with only what a // person compares. This is the search a lab can use; the chart search is not. const findCandidates = useCallback( async (requisitionId: string, handle: { name?: string; phone?: string }) => { const query = new URLSearchParams(handle as Record).toString(); const result = await get<{ candidates?: RequisitionCandidate[] }>( `${ORDER_API}/inbound-requisition/${requisitionId}/candidates?${query}`, ); return result?.candidates ?? []; }, [get], ); // Only acceptance creates the order, and the row leaves the list with it. const accept = useCallback( async (requisitionId: string) => { const result = await post(`${ORDER_API}/inbound-requisition/${requisitionId}/accept`, {}); setAccepted((held) => [...held, requisitionId]); return result; }, [post], ); // A refused requisition leaves the waiting list and is held as refused until the list read has it. const refuse = useCallback( async ( requisitionId: string, refusal: { reasonCode: string; message: string; contact?: Omit }, ) => { const result = await post(`${ORDER_API}/inbound-requisition/${requisitionId}/refuse`, refusal); if (!storedRefusal(result, refusal.contact?.id)) throw new UnconfirmedResult("refusal"); const row = [...refusedHere, ...listedRefused, ...listed, ...saved].find((each) => each.requisitionId === requisitionId); setAccepted((held) => [...held, requisitionId]); setRefusedHere((held) => [...held.filter((each) => each.requisitionId !== requisitionId), { ...row, requisitionId, senderRecordUpdated: result.senderRecordUpdated === true, refusal: result.refusal, contacts: result.contacts, }]); return result; }, [post, listed, saved, refusedHere, listedRefused], ); const recordContact = useCallback( async (requisitionId: string, contact: Omit) => { const result = await post<{ contacts?: RequisitionContact[] }>( `${CONTACT_API}/inbound-requisition/${requisitionId}/contact`, contact, ); const stored = result?.contacts; if (!Array.isArray(stored) || !stored.some((each) => each.id === contact.id)) throw new UnconfirmedResult("contact attempt"); const base = [...refusedHere, ...listedRefused].find((each) => each.requisitionId === requisitionId); if (base) { setRefusedHere((held) => [ ...held.filter((each) => each.requisitionId !== requisitionId), { ...base, contacts: stored }, ]); } return result; }, [post, refusedHere, listedRefused], ); const refused = useMemo(() => { const here = new Set(refusedHere.map((row) => row.requisitionId)); return [...refusedHere, ...listedRefused.filter((row) => !here.has(row.requisitionId))] .sort((a, b) => (b.refusal?.at ?? "").localeCompare(a.refusal?.at ?? "")); }, [refusedHere, listedRefused]); const requisitions = useMemo(() => { const ids = new Set(listed.map((row) => row.requisitionId)); return [...listed, ...saved.filter((row) => !ids.has(row.requisitionId))] .filter((row) => !accepted.includes(row.requisitionId as string)) .map((row) => (row.matchedPatient || !matched[row.requisitionId as string] ? row : { ...row, matchedPatient: matched[row.requisitionId as string] })); }, [listed, saved, matched, accepted]); useEffect(() => { void load(); }, [load]); return { requisitions, requisitionsPartial, refused, refusedUnreadable, refusedPartial, refusedWindowDays, loading, error, reload: load, log, match, accept, refuse, recordContact, findCandidates, }; };