import { useState } from "react"; import type { Patient } from "fhir/r4"; import { AlertCircle, Loader2, Search } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../../../components/ui/dialog"; import { Alert, AlertDescription } from "../../../components/ui/alert"; import { Button } from "../../../components/ui/button"; import { Input } from "../../../components/ui/input"; import { useReferralWorkbenchService, type ReferralConfirmedFaxDetails, type ReferralPatientCandidate } from "../../../services/ReferralWorkbenchService"; import type { ReferralFax } from "../../../hooks/useReferralQueue"; import ReferralFaxReading from "./ReferralFaxReading"; import ReferralFaxOriginal from "./ReferralFaxOriginal"; import { useCapabilityServiceContext } from "../../../services/CapabilityService"; import { stillOpen, type ReferralRequestScope } from "../../../utils/referralRequestScope"; import { formatFhirHumanName } from "../../../utils/fhirHumanName"; import { relayServerFailure } from "../../../utils/fetchFailureMessage"; import ReceptionistRegisterPatientDialog from '../ReceptionistHomePage/ReceptionistRegisterPatientDialog'; import "./referralDialog.css"; /** * - NAMING THE PATIENT A FAXED REFERRAL IS ABOUT. * * Spencer, 2026-08-23: *"at the point of actual contact with the new patient... they would either try to * find the patient in our system or if they don't exist yet register the new patient."* * * THIS IS WHERE A PIECE OF PAPER BECOMES A PATIENT'S REFERRAL, and it is the last step in the chain: * typed in sits in the queue **matched here** advanced, which retires the item (675). * * THE COORDINATOR CONFIRMS THE MATCH; THE PRODUCT MUST NOT ASSERT ONE. The search is **seeded** from * the sender, because starting from a blank box wastes a keystroke - but **nothing is ever selected for * them, not even when exactly one candidate comes back.** A name off a fax is not an identity, so an * auto-selected lookalike puts the referral on the wrong chart, and a chart is not somewhere a guess * can be withdrawn from. The search is the referral-scoped lookup of the practice patients. * Same rule and the same reasoning as `AttachSubmissionToPatient`, which faced this first. * * REGISTERING A NEW PATIENT IS NOT DONE HERE, deliberately. Spencer's ruling allows it - a referral * comes from a named practice on letterhead and the coordinator has the person on the phone, which is * more than an anonymous web form ever gives us. But registration is where matching, the registration * link and every identity rule already live, and **which registration surface is the product is an open * ruling of its own**. This points at that flow rather than growing a second one beside it. */ export type MatchReferralToPatientDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; /** * The queue item being matched. * * `label` IS THE ROW TITLE, already reading *"Referral from X"* - not a bare sender. Seen on screen: * prefixing it produced *"Sent by Referral from ZZTEST Northgate Cardiology"*. The bare sender is not * on the shell, so the copy uses the label as the sentence rather than inventing a stem for it. */ referral: { referralId: string; label: string } | null; /** Set when the referral arrived by fax; a held fax can be read into suggestions. */ fax?: ReferralFax; onMatched: () => void; /** A received fax that is outside records rather than a referral moves to Records; offered only to Records staff. */ onMoveToRecords?: () => Promise; /** The opening this dialog belongs to: its requests are admitted by it, and nothing is shown once it retires. */ scope?: ReferralRequestScope; }; const candidateFromPatient = (patient: Patient): ReferralPatientCandidate => ({ id: patient.id ?? "", name: formatFhirHumanName(patient.name, { emptyText: "" }), birthDate: patient.birthDate, }); export const MatchReferralToPatientDialog = ({ open, onOpenChange, referral, fax, onMatched, onMoveToRecords, scope, }: MatchReferralToPatientDialogProps) => { const { findReferralPatientCandidates, linkReferralToPatient } = useReferralWorkbenchService(); // Read as the referrals page reads its controls: another practice's capabilities say nothing about this one. const capability = useCapabilityServiceContext(); const capabilityHere = !capability.organizationId || !scope?.organizationId || capability.organizationId === scope.organizationId; const mayOpenOriginal = (capabilityHere ? capability.mayCall("POST", "/referral/*/fax-document") : undefined) !== false; const [term, setTerm] = useState(""); const [results, setResults] = useState(null); const [searching, setSearching] = useState(false); const [chosen, setChosen] = useState(null); const [saving, setSaving] = useState(false); const [error, setError] = useState(undefined); const [registering, setRegistering] = useState(false); const [confirmed, setConfirmed] = useState(undefined); const reset = () => { setTerm(""); setResults(null); setChosen(null); setError(undefined); setConfirmed(undefined); }; const close = (next: boolean) => { if (!next) reset(); onOpenChange(next); }; const runSearch = async (asked: string = term) => { const trimmed = asked.trim(); if (!referral || trimmed.length < 2) { setResults(null); return; } setSearching(true); setError(undefined); try { const found = await findReferralPatientCandidates(referral.referralId, trimmed, scope); if (!stillOpen(scope)) return; setResults(found); } catch (failure: unknown) { if (!stillOpen(scope)) return; setError("We could not search your patients just now. Try again in a moment."); setResults(null); } finally { setSearching(false); } }; const confirm = async () => { if (!referral || !chosen?.id || saving) return; setSaving(true); setError(undefined); try { if (confirmed) await linkReferralToPatient(referral.referralId, chosen.id, scope, confirmed); else await linkReferralToPatient(referral.referralId, chosen.id, scope); if (!stillOpen(scope)) return; // Reported before closing: closing retires this opening, and its completion belongs to it. onMatched(); reset(); onOpenChange(false); } catch (failure: unknown) { // Never closes on failure: a coordinator who saw it vanish would believe the referral is on the // patient's record. if (!stillOpen(scope)) return; setError( relayServerFailure(failure, "We could not match this referral. Nothing was written to the record."), ); } finally { setSaving(false); } }; return ( <> Who is this referral for? {referral ? `${referral.label}. Find them in your patients, and the referral goes onto their record.` : "Find the patient this referral is about."}
{referral && fax?.document === "held" && ( // Keyed apart from the reading beside it: two siblings sharing a key leave one mounted when the item changes. )} {referral && fax?.document === "held" && ( { setTerm(value); void runSearch(value); }} onConfirmedChange={setConfirmed} /> )}
setTerm(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") void runSearch(); }} placeholder="Name, date of birth or phone" /> {/* `variant="outline"` drew NO border at all here - measured `0px`, a transparent fill in body ink, which is a label sitting next to the field rather than a control attached to it. `.btn-blue-ghost` is the house treatment for exactly that intent (transparent fill, primary border, primary text) and is what the closure chips in the sibling dialog in this folder already use. The glyph survives the swap: `.btn` is an inline-flex with a 0.5rem gap and `.btn svg` is pinned `flex-shrink: 0`. */}
{results && results.length === 0 && (

No matching patients.

)} {results && results.length > 0 && ( /* A LIST, NEVER A PRESELECTION - not even with one result. The coordinator is the one who just spoke to this person; the product has a name off a fax and no business asserting a match on it. */
    {results.map((patient) => (
  • ))}
)} {/* Registration is the desk's own dialog, reused; the new patient comes back chosen and the coordinator still confirms the match. */}

Not in your patients yet?

{onMoveToRecords && (

Records answering a request this practice sent?

)} {error && ( {error} )}
{/* The control that COMMITS the match drew character-for-character like the `Cancel` beside it - same transparent fill, same body ink, same radius, same height - while `Match to a patient`, the control that merely OPENS this dialog, is filled blue on the page behind. The assertive step was the one that opened a dialog and the quiet one was the step that writes. `.btn-blue` is the same remedy this folder already applies elsewhere. `Cancel` stays a ghost on purpose: it draws no box either way. */}
{ // A patient registered for an opening that has since retired is not chosen for whatever replaced it. if (!stillOpen(scope)) return; setRegistering(false); const candidate = candidateFromPatient(patient); setResults([candidate]); setChosen(candidate); }} /> ); }; export default MatchReferralToPatientDialog;