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 { ClinicalImpression, Practitioner } from 'fhir/r4'; import { useAuthServiceContext, useClinicalImpressionServiceContext, usePatientServiceContext } from '../services'; import { FhirClinicalImpression, FhirClinicalImpressionDisplay } from '../components'; import { PrintableRegion, PrintRecord } from '../components/ui/print-record'; import { useFhirClinicalImpressionTargetedBroadcast } from '../hooks'; import { describeWriteFailure, ownFailureSentence } from '../utils/fetchFailureMessage'; import { invalidSubmitMessage } from '../utils/forms/invalidSubmitMessage'; import { useEditBaseline } from '../hooks/useEditBaseline'; import { isSaveRetired, isSaveTimedOut, TIMED_OUT_MAY_HAVE_SAVED, useSaveScope } from '../utils/saveOperation'; import { noteOutcomeUnknown } from '../utils/clinicalNoteOutcome'; import { reloadBaseline, saveOverBaseline } from '../utils/saveOverBaseline'; import { StaleRecordNotice } from '../components/StaleRecordNotice/StaleRecordNotice'; interface ClinicalImpressionModalProps { isOpen: boolean; onClose: () => void; clinicalImpression?: ClinicalImpression; onSuccess?: (clinicalImpression: ClinicalImpression) => void; onError?: (error: string) => void; title?: string; } export const ClinicalImpressionModal: React.FC = ({ isOpen, onClose, clinicalImpression, onSuccess, onError, title }) => { const { currentUser, userRole, USER_ROLES } = useAuthServiceContext(); // THIS MODAL HAD NO PATIENT GUARD AT ALL. The form component guards itself, so a patient // never got writable fields - but nothing switched off this modal's own Create/Update // button, which is a control offering what the server will refuse. A related person counts // as a patient viewer here, as they do everywhere else that gets this right. const isPatient = [USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON].includes(userRole as string); const { patientState, encounterState } = usePatientServiceContext(); const { transformToFhirClinicalImpression, transformFromFhirClinicalImpression, createClinicalImpression, updateClinicalImpression, fetchClinicalImpression } = useClinicalImpressionServiceContext(); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [reloading, setReloading] = useState(false); // A create whose save could not be confirmed may exist already: not offered again, so it is never made twice. const [createUnconfirmed, setCreateUnconfirmed] = useState(false); // A create that was answered is done for this opening: nothing is created again while it closes. const [created, setCreated] = useState(false); const isNew = !clinicalImpression; const modalTitle = title || `${isNew ? 'Create' : 'Edit'} Clinical Impression`; const methods = useForm({ defaultValues: { identifier: [], status: '', statusReason: null, code: null, description: '', encounter: null, effective: '', date: '', assessor: null, previous: null, problem: [], protocol: [], summary: '', finding: [], prognosisCodeableConcept: [], prognosisReference: [], supportingInfo: [], note: [], investigation: [] } }); const { handleSubmit, reset, getValues } = methods; // 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 formOf = useCallback((record: ClinicalImpression) => transformFromFhirClinicalImpression(record) as Record, [transformFromFhirClinicalImpression]); // The form's values, as plain data the baseline can compare. const read = useCallback(() => getValues() as Record, [getValues]); // What a save under way sent. The form stays editable meanwhile, so when that save confirms its record becomes the // basis, but anything typed since stays in the form, unsaved, rather than being reset to what was sent. const sending = useRef>(); // Counts the saves of this modal, so a close timer knows whether a later save has begun. const saves = useRef(0); const write = useCallback((values: Record) => { const sent = sending.current; if (sent && JSON.stringify(getValues()) !== JSON.stringify(sent)) return; reset(values as never); }, [reset, getValues]); // The version this opening was filled from, held apart from the form (whose transform drops it), and the only one // it saves against. A newer one arriving while the form holds changes is never filled over them. See // useEditBaseline. const baseline = useEditBaseline>({ formOf, read, write }); const { open: openBaseline, arrive, close: closeBaseline } = baseline; // Each save belongs to this opening of the modal: closing it (or another person signing in) retires it, and // everything it would still say, close or hand on is dropped with its busy flag. const saveScope = useSaveScope(isOpen ? clinicalImpression?.id ?? 'new' : undefined, () => { sending.current = undefined; closeBaseline(); setSubmitting(false); setReloading(false); setSuccess(undefined); setError(undefined); setCreateUnconfirmed(false); setCreated(false); }); useFhirClinicalImpressionTargetedBroadcast({ clinicalImpression, isOpen, onUpdate: arrive, onDelete: onClose }); // Each opening starts from the note handed in; a newer one handed in while open arrives like a broadcast. const filledThisOpening = useRef(false); useEffect(() => { if (!isOpen) { filledThisOpening.current = false; return; } if (!clinicalImpression) return; if (!filledThisOpening.current) { filledThisOpening.current = true; openBaseline(clinicalImpression); return; } arrive(clinicalImpression); }, [isOpen, clinicalImpression, openBaseline, arrive]); const onSubmit = async (data: any) => { if (!patientState) { const errorMsg = 'Patient information is missing. Please try again.'; setError(errorMsg); onError?.(errorMsg); return; } if (baseline.stale || createUnconfirmed || created) return; const operation = saveScope.start(); const thisSave = ++saves.current; setSubmitting(true); setSuccess(undefined); setError(undefined); try { let result: ClinicalImpression; if (isNew) { result = await operation.step(() => createClinicalImpression( transformToFhirClinicalImpression(data, patientState, encounterState, currentUser?.fhir as Practitioner), operation.transport, ), true); } else { sending.current = data; const outcome = await saveOverBaseline({ baseline, operation, write: (basedOn, transport) => updateClinicalImpression({ id: clinicalImpression?.id, ...transformToFhirClinicalImpression(data, patientState, encounterState, currentUser?.fhir as Practitioner) }, basedOn, transport), }); if ('retired' in outcome) return; if ('refused' in outcome) { // A finished opening says so in its own notice; only a save that sent nothing says it here. if (!outcome.stale) setError(outcome.refused); onError?.(outcome.refused); return; } result = outcome.saved; } if (isNew) setCreated(true); const successMsg = `Clinical impression ${isNew ? 'created' : 'updated'} successfully.`; setSuccess(successMsg); onSuccess?.(result); // Typed while it saved, or since, or saved again: that newer work is not this save's to close over, so the modal // stays open with it. The timer closes this opening only, with nothing typed or saved after this save. const nothingSince = () => saves.current === thisSave && (isNew || JSON.stringify(getValues()) === JSON.stringify(data)); setTimeout(() => { if (!operation.belongs() || !nothingSince()) return; setSuccess(undefined); onClose(); }, 1500); } catch (err: any) { if (isSaveRetired(err)) return; // Failed after it may have been written (a 5xx the note handler answered after its write): the opening is // done, as for an answer that never came, and only Reload record moves on. if (!isNew && noteOutcomeUnknown(err)) { baseline.refuse('unconfirmed'); onError?.(TIMED_OUT_MAY_HAVE_SAVED); return; } // A new note whose answer never came may exist: not offered again until the list is looked at. if (isNew && noteOutcomeUnknown(err)) { setCreateUnconfirmed(true); const unconfirmed = 'Whether it was created could not be confirmed. Close this and check the list before trying again.'; setError(unconfirmed); onError?.(unconfirmed); return; } if (isSaveTimedOut(err)) { setError(ownFailureSentence(err)); onError?.(ownFailureSentence(err)); return; } console.error(`Error ${isNew ? 'creating' : 'updating'} clinical impression:`, err); const errorMsg = describeWriteFailure(err, `Failed to ${isNew ? 'create' : 'update'} clinical impression`); setError(errorMsg); onError?.(errorMsg); } finally { if (sending.current === data) sending.current = undefined; if (operation.belongs()) setSubmitting(false); } }; // Reload record: what is saved now, read fresh, becomes the opening; the draft is discarded (the notice says so). const reload = async () => { const id = baseline.opening?.id; if (!id) return; const operation = saveScope.start(); setReloading(true); setError(undefined); try { await reloadBaseline({ baseline, operation, readNow: (transport) => fetchClinicalImpression(id, true, transport) }); } catch (reloadError) { if (isSaveRetired(reloadError)) return; setError('The record could not be read. Try Reload record again.'); } finally { if (operation.belongs()) setReloading(false); } }; if (!isOpen) return null; return ( {/* A READER GETS PRINT WHERE A WRITER GETS SAVE. A patient reading a record has no use for a Create/Update button - live it lies, disabled it tells them what they may not do, and neither answers the question they 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} )} {!isPatient && void reload()} reloading={reloading} />}
{/* THE READER GETS THE RECORD, NOT THE FORM SWITCHED OFF. `FhirClinicalImpressionDisplay` 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 && clinicalImpression ? ( undefined}/> ) : ( )}
); };