import { useEffect, useId, useRef, useState } from "react"; import type { KeyboardEvent } from "react"; import { Patient } from "fhir/r4"; import { LoaderCircle, Search } from "lucide-react"; import { usePatientServiceContext, useFhirServiceContext } from "../../../services"; import { Dialog, DialogContent, DialogDescription, DialogTitle, } from "../../../components/ui/dialog.tsx"; import { formatFhirHumanName } from "../../../utils/fhirHumanName.tsx"; import { isRefusedRead } from "../../../utils/fetchFailureMessage"; import { PatientMark } from "./PatientMark.tsx"; import { patientSearchResultLine } from "./patientSearchResult"; import "./ReceptionistPatientSearchDialog.css"; interface ReceptionistPatientSearchDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onSelectPatient: (patientId: string) => void; } const MIN_QUERY = 2; // Every answer carries the query it answers, so a slow reply for "ad" can never stand under "ada". type SearchAnswer = | { query: string; status: "ready"; patients: Patient[]; hiddenInactive: number; hiddenMerged: number } | { query: string; status: "failed"; refused: boolean }; const hiddenRecordsNote = (inactive: number, merged: number): string | undefined => { if (merged > 0) { return merged === 1 ? "A matching record was found, but it was merged into another account. Search for that account instead." : `${merged} matching records were found, but they were merged into other accounts. Search for those accounts instead.`; } if (inactive > 0) { return inactive === 1 ? "A matching record was found, but it is inactive. Ask an administrator to reactivate it." : `${inactive} matching records were found, but they are inactive. Ask an administrator to reactivate them.`; } return undefined; }; // The parent remounts it by key to start over; choosing a patient keeps the query for Back. export default function ReceptionistPatientSearchDialog({ open, onOpenChange, onSelectPatient, }: ReceptionistPatientSearchDialogProps) { const { searchPatients } = usePatientServiceContext(); const { unpackBundle } = useFhirServiceContext(); const listId = useId(); const [term, setTerm] = useState(""); const [attempt, setAttempt] = useState(0); const [answer, setAnswer] = useState(); const [active, setActive] = useState(0); // Escape collapses the list first (the dialog leaves Escape to an expanded combobox); typing opens it again. const [collapsed, setCollapsed] = useState(false); const latest = useRef(0); const searchInput = useRef(null); const query = term.trim(); useEffect(() => { if (query.length < MIN_QUERY) return; const ticket = ++latest.current; const handle = setTimeout(async () => { try { const bundle = await searchPatients(query); if (ticket !== latest.current) return; setAnswer({ query, status: "ready", patients: unpackBundle(bundle), hiddenInactive: bundle.excludedInactiveCount ?? 0, hiddenMerged: bundle.excludedMergedCount ?? 0, }); } catch (error) { if (ticket !== latest.current) return; console.error("Patient search failed:", error); setAnswer({ query, status: "failed", refused: isRefusedRead(error) }); } }, 300); return () => { clearTimeout(handle); // A reply to a query nobody is looking at any more is dropped when it lands. if (ticket === latest.current) latest.current++; }; }, [query, attempt, searchPatients, unpackBundle]); // Only the answer to what is in the box right now is ever shown. const current = query.length >= MIN_QUERY && answer?.query === query ? answer : undefined; const patients = current?.status === "ready" ? current.patients : []; useEffect(() => setActive(0), [current]); const choose = (patient: Patient | undefined) => { if (patient?.id) onSelectPatient(patient.id); }; const expanded = patients.length > 0 && !collapsed; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && expanded) { setCollapsed(true); return; } if (!expanded) return; if (event.key === "ArrowDown") { event.preventDefault(); setActive((index) => Math.min(index + 1, patients.length - 1)); } else if (event.key === "ArrowUp") { event.preventDefault(); setActive((index) => Math.max(index - 1, 0)); } else if (event.key === "Enter") { event.preventDefault(); choose(patients[active]); } }; const hiddenNote = current?.status === "ready" && patients.length === 0 ? hiddenRecordsNote(current.hiddenInactive, current.hiddenMerged) : undefined; return ( { event.preventDefault(); searchInput.current?.focus(); }} > Search patients {/* The title says it all; the description is for screen readers. */} Search for a patient by name, full phone number or date of birth.
{query.length < MIN_QUERY ? (

Type at least 2 characters to search.

) : !current ? (

) : current.status === "failed" ? ( current.refused ? (

You do not have access to search this practice's patients.

) : (
The search did not finish, so we cannot say who matches.
) ) : patients.length === 0 ? (

{hiddenNote ?? `No patients match “${query}”.`}

) : collapsed ? null : (
    {patients.map((patient, index) => { const name = formatFhirHumanName(patient.name, { emptyText: "Unnamed patient" }); return (
  • setActive(index)} onClick={() => choose(patient)} > {name} {patientSearchResultLine(patient)}
  • ); })}
)}
); }