import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Flex, FlexItem } from '../components/ui/flex'; import { DismissibleAlert } from '../components/ui/dismissible-alert'; import { Modal } from '../components/ui/wp-modal-shim'; import { useForm, FormProvider } from 'react-hook-form'; import { Encounter, Organization } from 'fhir/r4'; import { useEncounterServiceContext, useAuthServiceContext, usePatientServiceContext } from '../services'; import { FhirEncounter, FhirEncounterDisplay } from '../components'; import { PrintableRegion, PrintRecord } from '../components/ui/print-record'; import { useFhirEncounterTargetedBroadcast } from '../hooks'; import { describeWriteFailure } from '../utils/fetchFailureMessage'; import { invalidSubmitMessage } from '../utils/forms/invalidSubmitMessage'; import { isSaveRetired, outcomeUnknown, useSaveScope, TIMED_OUT_MAY_HAVE_SAVED } from '../utils/saveOperation'; interface EncounterModalProps { isOpen: boolean; onClose: () => void; encounter?: Encounter; onSuccess?: (encounter: Encounter) => void; onError?: (error: string) => void; title?: string; } export const EncounterModal: React.FC = ({ isOpen, onClose, encounter, onSuccess, onError, title }) => { const { userRole, USER_ROLES, wpOrganization } = useAuthServiceContext(); const { patientState } = usePatientServiceContext(); const { transformToFhirEncounter, transformFromFhirEncounter, createEncounter, updateEncounter } = useEncounterServiceContext(); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); // A create sent and never answered may be on record: it is not sent again from this opening. const [createUnconfirmed, setCreateUnconfirmed] = useState(false); const closing = useRef>(); const isNew = !encounter; // A RELATED PERSON IS A PATIENT VIEWER. This read // `userRole === USER_ROLES.PATIENT`, so a parent acting for a child was treated as // staff here while the matching page and FhirEncounter itself both counted them. // It now decides which of the two renders below the reader gets, so a narrow // predicate would put a proxy into the edit form rather than the record. const isPatient = [USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON].includes(userRole as string); const modalTitle = title || `${isNew ? 'Create' : 'Edit'} Encounter`; const methods = useForm({ defaultValues: { identifier: [], status: '', statusHistory: [], class: null, classHistory: [], type: [], serviceType: null, priority: null, subject: null, episodeOfCare: [], basedOn: [], participant: [], appointment: [], reasonCode: [], reasonReference: [], diagnosis: [], account: [], hospitalization: null, location: [], serviceProvider: null, partOf: null } }); const { handleSubmit, reset } = methods; // What a new visit starts from: the form as first mounted. const creationDefaults = useRef>(); creationDefaults.current ??= structuredClone(methods.getValues() as Record); // One opening, for one visit of one patient and the person signed in: closing, another patient or another // person retires its saves, and a new one starts blank. const scope = useSaveScope(isOpen ? `${encounter?.id ?? 'new'}:${patientState?.id ?? ''}` : undefined, () => { clearTimeout(closing.current); setSubmitting(false); setSuccess(undefined); setError(undefined); setCreateUnconfirmed(false); if (!encounter) { // Bare first: it also empties the inputs, which a reset to values leaves as typed. reset(); reset(creationDefaults.current as never); } }); useEffect(() => () => clearTimeout(closing.current), []); // A REFUSED CLICK SAYS SO WHERE IT HAPPENED. The button is in the modal header, above a form that // can be several screens tall, and react-hook-form reports each problem inline beside its field - // so somebody scrolled past the first card saw nothing change at all. This fills the error slot // this modal already has, at the top of the form, next to the button they pressed. const onInvalid = (errors: unknown) => setError(invalidSubmitMessage(errors)); const init = useCallback((encounter: Encounter) => { reset(transformFromFhirEncounter(encounter)); }, [transformFromFhirEncounter, reset]); useFhirEncounterTargetedBroadcast({ encounter, isOpen, onUpdate: init, onDelete: onClose }); useEffect(() => { if (encounter) { init(encounter); } }, [encounter, init]); const onSubmit = async (data: any) => { if (isPatient) return; if (!patientState) { const errorMsg = 'Patient information is missing. Please try again.'; setError(errorMsg); onError?.(errorMsg); return; } if (isNew && createUnconfirmed) return; const operation = scope.start(); setSubmitting(true); setSuccess(undefined); setError(undefined); try { let result: Encounter; if (isNew) { result = await operation.step(() => createEncounter(transformToFhirEncounter(data, patientState, wpOrganization.current as Organization), operation.transport), true); } else { result = await operation.step(() => updateEncounter({ id: encounter?.id, ...transformToFhirEncounter(data, patientState, wpOrganization.current as Organization) // Every edit names the version it was made on. Today's callers open this modal only to create. }, encounter?.meta?.versionId, { transport: operation.transport }), true); } const successMsg = `Encounter ${isNew ? 'created' : 'updated'} successfully.`; setSuccess(successMsg); onSuccess?.(result); closing.current = setTimeout(() => { if (!operation.belongs()) return; setSuccess(undefined); onClose(); }, 1500); } catch (err: any) { if (isSaveRetired(err) || !operation.belongs()) return; console.error(`Error ${isNew ? 'creating' : 'updating'} encounter:`, err); if (isNew && outcomeUnknown(err)) { setCreateUnconfirmed(true); setError(TIMED_OUT_MAY_HAVE_SAVED); return; } const errorMsg = describeWriteFailure(err, `Failed to ${isNew ? 'create' : 'update'} encounter`); setError(errorMsg); onError?.(errorMsg); } finally { if (operation.belongs()) setSubmitting(false); } }; if (!isOpen) return null; return ( {/* A READER GETS PRINT WHERE A WRITER GETS SAVE. This was a disabled Create/Update button, which tells a patient what they may not do - a denial rendered as a dead control, the same failure the record itself had. Print answers the question they actually have: how do I get this to somebody who is not on Akashic. */} {isPatient ? ( ) : ( )} ]} >
{success && ( setSuccess(undefined)} className="mb-4"> {success} )} {error && ( setError(undefined)} className="mb-4"> {error} )}
{/* THE READER GETS THE RECORD, NOT THE FORM SWITCHED OFF. `FhirEncounterDisplay` already renders this resource for the history viewer, the reference preview and the patient snapshot, so a patient was the one audience shown the edit form instead of the view written for reading it. The old shape wrapped the form in `inert`, which is worse than it looks: the fields stay in the accessibility tree, so a screen reader still reads them as inputs, and a text range over the subtree returns an EMPTY STRING - a patient could not select or copy their own record. */} {isPatient && encounter ? ( undefined}/> ) : ( )}
); };