import { useEffect, useRef, useState, type ReactNode } from 'react'; import type { Patient } from 'fhir/r4'; import { ArrowLeft, Loader2, TriangleAlert } from 'lucide-react'; import { AttachSubmissionResult, FormSubmission, useFormSubmissionsService, } from '../../../services/FormSubmissionsService.tsx'; import { usePatientServiceContext, useFhirServiceContext } from '../../../services'; import { formatFhirHumanName } from '../../../utils/fhirHumanName'; import { formatDate } from '../../../utils/dateUtils'; import { formatPhoneNumber } from '../../../utils/phoneNumber'; import { attachedPatientLabel } from './attachedPatientLabel'; import { operationStatusToast } from '../../../components/OperationStatusToast/OperationStatusToast.tsx'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; import { SUBMISSION_SCAN_HOLD_WORDS, scanHoldOf } from '../../../utils/uploadScanHold'; /** * THE STEP A PUBLIC FORM IS COLLECTED FOR -. * * The action row used to read *"More actions coming soon: Convert to patient [disabled]"*, so a * receptionist could read a submission and file it but never do the thing the form exists for. The list * even offered a **Converted** filter that nothing could produce. * * THE RECEPTIONIST CONFIRMS THE MATCH; THE PRODUCT MUST NOT ASSERT ONE. An anonymous submission carries * only what a visitor typed into a public web page - a name and an email, neither verified. Auto-attaching * on a lookalike writes one person's message into another person's chart, and a chart is not somewhere a * guess can be withdrawn from. * The search is **seeded** from what the visitor typed, because starting from a blank box for a name * already on screen is busywork - but nothing is ever selected for them, not even when exactly one * candidate comes back. * * AND ATTACH ONLY, NOT CREATE. Spencer, 2026-08-17: *"a receptionist can opt to attach a public form * submission to a patient **if it matches an existing patient**."* Minting a patient identity from * unverified anonymous input is a wider decision and is deliberately not offered here. */ interface AttachSubmissionToPatientProps { submission: FormSubmission; onBack: () => void; /** Who sent it and what they sent, drawn above the search. */ facts?: ReactNode; onBusyChange?: (busy: boolean) => void; /** The server's result, and the name the receptionist confirmed on the button. */ onAttached: (result: AttachSubmissionResult, patientName: string) => void; } const patientLabel = (patient: Patient): string => formatFhirHumanName(patient.name, { emptyText: '' }) || 'Unnamed patient'; /** Enough to tell two people with the same name apart, and nothing more than the list already shows. */ const patientDetail = (patient: Patient): string => [formatDate(patient.birthDate), formatPhoneNumber(patient.telecom?.find((t) => t.system === 'phone')?.value ?? '')] .filter(Boolean) .join(' ยท '); const SEARCH_PAUSE_MS = 350; const AttachSubmissionToPatient = ({ submission, onBack, facts, onBusyChange, onAttached }: AttachSubmissionToPatientProps) => { const { attachSubmissionToPatient } = useFormSubmissionsService(); const { searchPatients } = usePatientServiceContext(); const { unpackBundle } = useFhirServiceContext(); const [term, setTerm] = useState(submission.submitterContact?.name ?? ''); const [results, setResults] = useState(null); const [hiddenInactive, setHiddenInactive] = useState(0); const [searching, setSearching] = useState(false); const [searchError, setSearchError] = useState(); const [chosen, setChosen] = useState(null); const [attaching, setAttaching] = useState(false); const [error, setError] = useState(); const alive = useRef(true); // Only the newest search may land; a slower earlier one never replaces what is on screen. const searchSeq = useRef(0); const pause = useRef>(); const runSearch = async (searchTerm: string) => { const trimmed = searchTerm.trim(); const seq = ++searchSeq.current; if (trimmed.length < 2) { setResults(null); setSearching(false); return; } setSearching(true); setSearchError(undefined); try { const bundle = await searchPatients(trimmed); if (!alive.current || seq !== searchSeq.current) return; const found = unpackBundle(bundle) as Patient[]; setResults(found); // The search hides inactive records. Reporting "no matching patients" when it DID match and // we chose not to show them is a false sentence staff have been sent after before. setHiddenInactive(bundle.excludedInactiveCount ?? 0); setChosen((current) => (current && found.some((p) => p.id === current.id) ? current : null)); } catch (err) { if (!alive.current || seq !== searchSeq.current) return; console.error('Failed to search patients for a form submission:', err); setSearchError('We could not search your patients just now. Try again in a moment.'); setResults(null); setChosen(null); } finally { if (alive.current && seq === searchSeq.current) setSearching(false); } }; useEffect(() => { void runSearch(term); return () => { alive.current = false; clearTimeout(pause.current); }; // Seeded once from what the visitor typed; later searches follow the box. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const changeTerm = (value: string) => { setTerm(value); clearTimeout(pause.current); pause.current = setTimeout(() => void runSearch(value), SEARCH_PAUSE_MS); }; const attach = async () => { if (!chosen?.id || attaching) return; const name = patientLabel(chosen); setAttaching(true); onBusyChange?.(true); setError(undefined); const toastId = operationStatusToast.saving(); try { const result = await attachSubmissionToPatient(submission.submissionId, chosen.id); onAttached(result, attachedPatientLabel(name, chosen.birthDate)); } catch (err) { console.error('Failed to attach a form submission to a patient:', err); if (alive.current) { const hold = scanHoldOf(err); setError(hold ? SUBMISSION_SCAN_HOLD_WORDS[hold] : relayServerFailure(err, 'We could not attach this submission. Nothing was written to the record.')); } } finally { operationStatusToast.dismiss(toastId); onBusyChange?.(false); if (alive.current) setAttaching(false); } }; return ( <>
{facts}

Check the person before adding these answers to their record.

changeTerm(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); clearTimeout(pause.current); void runSearch(term); } }} disabled={attaching} autoComplete="off" />
{searchError && (
)} {searching && results === null && (

Searching…

)} {results !== null && results.length === 0 && !searching && (

{hiddenInactive > 0 ? 'The only matches are inactive records, which are not shown here. Reactivate the patient first if this is them.' : 'No patients matched. This submission may be from someone who is not a patient yet.'}

)} {results !== null && results.length > 0 && (
{results.map((patient) => ( ))}
)}

Confirm the name, date of birth and phone number before attaching. No match? Return to the submission.

{error && (
)}
{/* Named in the button: the last thing read before writing into a chart is whose chart it is. */}
); }; export default AttachSubmissionToPatient;