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 { Condition, Encounter } from 'fhir/r4'; import { useConditionServiceContext, useAuthServiceContext, useEncounterServiceContext, useFhirServiceContext, usePatientServiceContext } from '../services'; import { FhirCondition } from '../components'; import { useFhirConditionTargetedBroadcast } from '../hooks'; import { describeWriteFailure } from '../utils/fetchFailureMessage'; import { DiagnosisVisitLinkError, DIAGNOSIS_NOT_LINKED_MESSAGE } from '../services/ConditionService'; import { invalidSubmitMessage } from '../utils/forms/invalidSubmitMessage'; import { activeEncounterDefault } from '../utils/activeEncounterDefault'; import { isSaveRetired, outcomeUnknown, useSaveScope, TIMED_OUT_MAY_HAVE_SAVED } from '../utils/saveOperation'; interface ConditionModalProps { isOpen: boolean; onClose: () => void; condition?: Condition; onSuccess?: (condition: Condition) => void; onError?: (error: string) => void; title?: string; } export const ConditionModal: React.FC = ({ isOpen, onClose, condition, onSuccess, onError, title }) => { const { currentUser } = useAuthServiceContext(); const { patientState, encounterState } = usePatientServiceContext(); const { createReferenceObject } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const { transformToFhirCondition, transformFromFhirCondition, createCondition, linkDiagnosisToItsEncounter, updateCondition, usCoreConditionEncounterDiagnosisProfile, usCoreConditionProblemsHealthConcernsProfile } = useConditionServiceContext(); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [conditionProfile, setConditionProfile] = useState(); // A create sent and never answered may be on record: it is not sent again from this opening. const [createUnconfirmed, setCreateUnconfirmed] = useState(false); // A diagnosis stored without its visit link: only the link is offered again, never the diagnosis. const [unlinked, setUnlinked] = useState(); const closing = useRef>(); const isNew = !condition; const modalTitle = title || `${isNew ? 'Create' : 'Edit'} Condition`; /** * THE FIELD NOW SHOWS THE VISIT THE RECORD IS ALREADY GOING TO BE FILED AGAINST. * * It opened EMPTY, with every past visit listed, for a clinician standing inside one - so the only * piece of context the screen unambiguously had was the one the form did not use, and the rows in * that list look alike. * * The dangerous half is not that the link was missing. `transformToFhirCondition` already falls back * to `encounterState`, so leaving the field alone files the diagnosis against the open visit and * always did. The defect is that the SCREEN DISAGREED WITH THE WRITE: it showed nothing chosen, so a * clinician being helpful would pick a visit by hand and OVERRIDE a correct default with a guess - * and a diagnosis on the wrong encounter is not visibly wrong on any screen afterwards. * * Undefined outside a visit, deliberately: recording from the chart has no encounter to infer, and a * default there would be a guess wearing the clothes of a fact. And it is a DEFAULT, not a lock - a * clinician recording a diagnosis that genuinely came out of an earlier visit must still be able to * say so, which is the case the picker exists for. * * Built in the reset payload rather than applied by a later effect. The value has to be there in the * first committed frame; a field that renders empty and then fills in is the same defect with a * shorter duration. */ const activeEncounterReference = activeEncounterDefault( encounterState, (id) => createReferenceObject('Encounter', id, getEncounterDisplay(encounterState as Encounter)), ); const baseCondition = { clinicalStatus: null, verificationStatus: null, category: [], severity: null, code: null, bodySite: [], encounter: activeEncounterReference, onsetType: '', onsetDateTime: '', onsetAge: null, onsetString: '', onsetRange: { low: null, high: null }, abatementType: '', abatementDateTime: '', abatementAge: null, abatementString: '', abatementBoolean: false, abatementRange: { low: null, high: null }, recordedDate: '', note: [] }; const methods = useForm({ defaultValues: {} }); const { handleSubmit, reset } = methods; // One opening, for one record 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 ? `${condition?.id ?? 'new'}:${patientState?.id ?? ''}` : undefined, () => { clearTimeout(closing.current); setSubmitting(false); setSuccess(undefined); setError(undefined); setCreateUnconfirmed(false); setUnlinked(undefined); if (!condition) { setConditionProfile(undefined); reset(); reset(baseCondition 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)); useEffect(() => { if (conditionProfile === 'us-core-condition-encounter-diagnosis') { if (isNew) { reset({ ...baseCondition, ...usCoreConditionEncounterDiagnosisProfile }); } } else if (conditionProfile === 'us-core-condition-problems-health-concerns') { if (isNew) { reset({ ...baseCondition, ...usCoreConditionProblemsHealthConcernsProfile }); } } }, [conditionProfile, reset]); const init = useCallback((condition: Condition) => { const formData = transformFromFhirCondition(condition); reset(formData); setConditionProfile(formData.conditionProfileSlug); }, [transformFromFhirCondition, reset]); useFhirConditionTargetedBroadcast({ condition, isOpen, onUpdate: init, onDelete: onClose }); useEffect(() => { if (condition) { init(condition); } }, [condition, init]); const onSubmit = async (data: any) => { if (!patientState) { const errorMsg = 'Patient information is missing. Please try again.'; setError(errorMsg); onError?.(errorMsg); return; } if (isNew && (createUnconfirmed || unlinked)) return; const operation = scope.start(); setSubmitting(true); setSuccess(undefined); setError(undefined); try { let result: Condition; if (isNew) { result = await operation.step(() => createCondition(transformToFhirCondition(data, patientState, encounterState, currentUser?.fhir), operation.transport), true); } else { result = await operation.step(() => updateCondition({ id: condition?.id, ...transformToFhirCondition(data) // Every edit names the version it was made on. Today's callers open this modal only to create. }, condition?.meta?.versionId, operation.transport), true); } const successMsg = `Condition ${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; // THE DIAGNOSIS IS SAVED AND ONLY ITS LINK TO THIS VISIT IS NOT, which is a different thing // to say and a different thing to do about it. Reporting it as a failed create would send a // clinician to record the same diagnosis a second time, and the second one would be as // unlinked as the first. if (err instanceof DiagnosisVisitLinkError) { console.error('Condition created but not linked to its encounter:', err); // A constant we author rather than `err.message`: reading it off `.message` is the shape the // raw-message guard exists to refuse, and a reader cannot tell a literal from a relayed IAM // policy string by looking at the sink. setUnlinked(err.condition); onSuccess?.(err.condition); return; } if (isNew && outcomeUnknown(err)) { setCreateUnconfirmed(true); setError(TIMED_OUT_MAY_HAVE_SAVED); return; } console.error(`Error ${isNew ? 'creating' : 'updating'} condition:`, err); const errorMsg = describeWriteFailure(err, `Failed to ${isNew ? 'create' : 'update'} condition`); setError(errorMsg); onError?.(errorMsg); } finally { if (operation.belongs()) setSubmitting(false); } }; // Linking again is safe: a visit that already lists the diagnosis is left as it is. const linkToVisit = async () => { const stored = unlinked; if (!stored) return; const operation = scope.start(); setSubmitting(true); setError(undefined); try { await operation.step(() => linkDiagnosisToItsEncounter(stored, operation.transport), true); setUnlinked(undefined); setSuccess('The diagnosis is now linked to this visit.'); closing.current = setTimeout(() => { if (operation.belongs()) onClose(); }, 1500); } catch (linkError) { if (isSaveRetired(linkError) || !operation.belongs()) return; console.error('Diagnosis not linked to its encounter:', linkError); setError(outcomeUnknown(linkError) ? 'Whether the diagnosis was linked to this visit could not be confirmed. Linking again is safe.' : describeWriteFailure(linkError, 'The diagnosis could not be linked to this visit')); } finally { if (operation.belongs()) setSubmitting(false); } }; if (!isOpen) return null; return ( {/* NOTHING VALIDATES THIS FORM, so `handleSubmit` submits whatever is there - including nothing at all. Measured on staging: with no profile chosen, Create wrote a Condition carrying only subject, recorder and encounter - no `code`, no `clinicalStatus`, no `verificationStatus` - the dialog closed, and a diagnosis with no diagnosis in it landed on the patient's clinical record. The screen then read "No problems recorded yet", so nothing on it disagreed. The profile is what DECIDES the fields: until one is picked the form renders none, so there is no field for a validation rule to hang off and nothing for `shouldFocusError` to send the eye to. That is why this is checked here rather than as a rule - it is a precondition of the form existing, not a missing value inside it. Reported, not disabled. A greyed-out Create says a clinician did something wrong without saying what, and the fix on this card is a report on failure rather than another silent gate. */} {unlinked ? ( ) : ( )} ]} >
{success && ( setSuccess(undefined)} className="mb-4"> {success} )} {error && ( setError(undefined)} className="mb-4"> {error} )} {unlinked &&

{DIAGNOSIS_NOT_LINKED_MESSAGE}

}
setConditionProfile(profile)} elevated={false} disabled={submitting} enableInlineActions={false}/>
); };