import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { DismissibleAlert } from '../../../../../components/ui/dismissible-alert'; import { Spacer } from '../../../../../components/ui/spacer'; import { useForm, FormProvider } from 'react-hook-form'; import { Observation } from 'fhir/r4'; import { Check, FileText } from 'lucide-react'; import { useAuthServiceContext, useObservationServiceContext, usePatientServiceContext } from '../../../../../services'; import { AppHeader, AppSpinner, ProfileLayoutContainer, ScrollableTab, HistoryNavigator, FhirObservation } from '../../../../../components'; import { useFhirObservationTargetedBroadcast } from '../../../../../hooks'; import { ARCHIVE_ABANDONED, useArchive } from '../../../../../hooks/useArchive'; import { useEditBaseline } from '../../../../../hooks/useEditBaseline'; import { isSaveRetired, useSaveScope, outcomeUnknown, TIMED_OUT_MAY_HAVE_SAVED } from '../../../../../utils/saveOperation'; import { StaleRecordNotice } from '../../../../../components/StaleRecordNotice/StaleRecordNotice'; import { EarlierSaveNotice } from '../../../../../components/EarlierSaveNotice/EarlierSaveNotice'; import { operationPartiesOf, useObservationOperation, type EarlierOutcome } from '../../../../../hooks/useObservationOperation'; import { OPERATION_NOT_SAVED_MESSAGE, OPERATION_REUSED_MESSAGE, OPERATION_UNAVAILABLE_MESSAGE, operationNotYetAdmitted, operationReused, operationUnconfirmed } from '../../../../../utils/observationOperation'; import { RecoveryFull, RecoveryUnavailable } from '../../../../../utils/messaging/sendRecovery'; import { reloadBaseline, saveOverBaseline } from '../../../../../utils/saveOverBaseline'; import { isRecordChanged } from '../../../../../utils/recordChanged'; import ArchiveConfirmModal from '../../../../../components/ArchiveConfirmModal/ArchiveConfirmModal'; import { describeWriteFailure } from '../../../../../utils/fetchFailureMessage'; import { toLocalDateTimeInput } from '../../../../../utils/dateUtils'; import { patientCacheGuard } from '../../../../../utils/cacheOwner'; import { observationPractice } from '../../../../../utils/observationPractice'; import { GENERIC_ENTRY_HELD_NOTE, editDropsSourceNote, releasableOriginOf } from '../../../../../utils/releaseOrigin'; const EditObservationPage: React.FC = () => { const { observationId } = useParams<{ observationId: string }>(); const navigate = useNavigate(); const { patientState, encounterState } = usePatientServiceContext(); const { cachedForPatientId: observationsOwner, fetchObservation, transformToFhirObservation, transformFromFhirObservation, createObservationOnce, updateObservation, archiveObservation, observationsList: rawObservationsList, // US Core Observation Profiles usCoreAverageBloodPressureProfile, usCoreCareExperiencePreferenceProfile, usCoreObservationAdiDocumentationProfile, usCoreObservationLabProfile, usCoreObservationOccupationProfile, usCoreObservationPregnancyintentProfile, usCoreObservationPregnancystatusProfile, usCoreObservationScreeningAssessmentProfile, usCoreObservationSexualOrientationProfile, usCoreSimpleObservationProfile, usCoreSmokingstatusProfile, usCoreTreatmentInterventionPreferenceProfile, // Vital Signs usCoreVitalSignsProfile, usCoreBloodPressureProfile, usCorePediatricMmiForAgeProfile, pediatricWeightForHeightProfile, usCoreBmiProfile, usCoreBodyHeightProfile, usCoreBodyTemperatureProfile, usCoreBodyWeightProfile, usCoreHeadCircumferenceProfile, usCoreHeartRateProfile, usCorePulseOximetryProfile, usCoreRespiratoryRateProfile, headOccipitalFrontalCircumferencePercentileProfile } = useObservationServiceContext(); // The cache outlives a change of patient: read only the patient on screen. const observationsGuard = patientCacheGuard(observationsOwner, patientState?.id); const observationsList = observationsGuard.list(rawObservationsList); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [reloading, setReloading] = useState(false); // A create whose answer was lost may have filed the reading: it is not offered again from this page. const [createUnconfirmed, setCreateUnconfirmed] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [observation, setObservation] = useState(); const { userRole, USER_ROLES, currentOrganization, wpOrganization } = useAuthServiceContext(); const [observationProfile, setObservationProfile] = useState(); const isNew = observationId === 'new'; const spacerMargin = '24px'; const baseObservation = { identifier: [], basedOn: [], partOf: [], status: '', category: [], code: null, focus: [], encounter: null, // A new observation starts at now: the server refuses a vital sign with no time taken. effectiveType: 'dateTime', effectiveDateTime: toLocalDateTimeInput(new Date()), effectiveTiming: null, effectiveInstant: '', issued: '', performer: [], valueType: '', valueQuantity: null, valueCodeableConcept: null, valueString: '', valueBoolean: false, valueInteger: 0, valueRange: { low: null, high: null }, valueRatio: null, valueSampledData: null, valueTime: '', valueDateTime: '', dataAbsentReason: null, interpretation: [], note: [], bodySite: null, method: null, specimen: null, device: null, referenceRange: [], hasMember: [], derivedFrom: [], component: [] }; const methods = useForm({ defaultValues: {} }); const { handleSubmit, reset } = methods; // What a new record starts from: no profile and the base values, never the last record the form held. The // bare reset also empties the inputs; a reset to {} would restore the last record, as an empty reset does. const startBlank = () => { setObservationProfile(undefined); reset(); reset(baseObservation as never); }; useEffect(() => { if (observationProfile === 'us-core-average-blood-pressure') { // us-core-average-blood-pressure if (isNew) { reset({ ...baseObservation, ...usCoreAverageBloodPressureProfile }); } } else if (observationProfile === 'us-core-care-experience-preference') { // us-core-care-experience-preference if (isNew) { reset({ ...baseObservation, ...usCoreCareExperiencePreferenceProfile }); } } else if (observationProfile === 'us-core-observation-adi-documentation') { // us-core-observation-adi-documentation if (isNew) { reset({ ...baseObservation, ...usCoreObservationAdiDocumentationProfile }); } } else if (observationProfile === 'us-core-observation-lab') { // us-core-observation-lab if (isNew) { reset({ ...baseObservation, ...usCoreObservationLabProfile }); } } else if (observationProfile === 'us-core-observation-occupation') { // us-core-observation-occupation if (isNew) { reset({ ...baseObservation, ...usCoreObservationOccupationProfile }); } } else if (observationProfile === 'us-core-observation-pregnancyintent') { // us-core-observation-pregnancyintent if (isNew) { reset({ ...baseObservation, ...usCoreObservationPregnancyintentProfile }); } } else if (observationProfile === 'us-core-observation-pregnancystatus') { // us-core-observation-pregnancystatus if (isNew) { reset({ ...baseObservation, ...usCoreObservationPregnancystatusProfile }); } } else if (observationProfile === 'us-core-observation-screening-assessment') { // us-core-observation-screening-assessment if (isNew) { reset({ ...baseObservation, ...usCoreObservationScreeningAssessmentProfile }); } } else if (observationProfile === 'us-core-observation-sexual-orientation') { // us-core-observation-sexual-orientation if (isNew) { reset({ ...baseObservation, ...usCoreObservationSexualOrientationProfile }); } } else if (observationProfile === 'us-core-smokingstatus') { // us-core-smokingstatus if (isNew) { reset({ ...baseObservation, ...usCoreSmokingstatusProfile }); } } else if (observationProfile === 'us-core-treatment-intervention-preference') { // us-core-treatment-intervention-preference if (isNew) { reset({ ...baseObservation, ...usCoreTreatmentInterventionPreferenceProfile }); } } else if (observationProfile === 'us-core-vital-signs') { // us-core-vital-signs if (isNew) { reset({ ...baseObservation, ...usCoreVitalSignsProfile }); } } else if (observationProfile === 'pediatric-bmi-for-age') { // pediatric-bmi-for-age if (isNew) { reset({ ...baseObservation, ...usCorePediatricMmiForAgeProfile }); } } else if (observationProfile === 'pediatric-weight-for-height') { // pediatric-weight-for-height if (isNew) { reset({ ...baseObservation, ...pediatricWeightForHeightProfile }); } } else if (observationProfile === 'us-core-blood-pressure') { // us-core-blood-pressure if (isNew) { reset({ ...baseObservation, ...usCoreBloodPressureProfile }); } } else if (observationProfile === 'us-core-bmi') { // us-core-bmi if (isNew) { reset({ ...baseObservation, ...usCoreBmiProfile }); } } else if (observationProfile === 'us-core-body-height') { // us-core-body-height if (isNew) { reset({ ...baseObservation, ...usCoreBodyHeightProfile }); } } else if (observationProfile === 'us-core-body-temperature') { // us-core-body-temperature if (isNew) { reset({ ...baseObservation, ...usCoreBodyTemperatureProfile }); } } else if (observationProfile === 'us-core-body-weight') { // us-core-body-weight if (isNew) { reset({ ...baseObservation, ...usCoreBodyWeightProfile }); } } else if (observationProfile === 'us-core-head-circumference') { // us-core-head-circumference if (isNew) { reset({ ...baseObservation, ...usCoreHeadCircumferenceProfile }); } } else if (observationProfile === 'us-core-heart-rate') { // us-core-heart-rate if (isNew) { reset({ ...baseObservation, ...usCoreHeartRateProfile }); } } else if (observationProfile === 'us-core-pulse-oximetry') { // us-core-pulse-oximetry if (isNew) { reset({ ...baseObservation, ...usCorePulseOximetryProfile }); } } else if (observationProfile === 'us-core-respiratory-rate') { // us-core-respiratory-rate if (isNew) { reset({ ...baseObservation, ...usCoreRespiratoryRateProfile }); } } else if (observationProfile === 'head-occipital-frontal-circumference-percentile') { // head-occipital-frontal-circumference-percentile if (isNew) { reset({ ...baseObservation, ...headOccipitalFrontalCircumferencePercentileProfile }); } } }, [observationProfile]); // The form's values for a record, and the form's values now, as plain data the baseline compares and merges. const formOf = useCallback((record: Observation) => transformFromFhirObservation(record) as Record, [transformFromFhirObservation]); const read = useCallback(() => methods.getValues() as Record, [methods]); const write = useCallback((values: Record) => reset(values as never), [reset]); const onFill = useCallback((record: Observation) => { setObservation(record); setObservationProfile((formOf(record) as { observationProfileSlug?: string }).observationProfileSlug as string); }, [formOf]); // The version the form was opened on: a newer one arriving while the form holds changes is shown beside them, // and Save is made only over the version opened or reviewed (see useEditBaseline). const baseline = useEditBaseline>({ formOf, read, write, onFill }); // The first read opens the record; any later copy of it (a second read, the list's) is an arrival, so it never // fills over a draft. const init = baseline.arrive; // One read at a time per opening, whatever re-runs the loader while it is out. const loadingFor = useRef(); // Moving to another record (or person) closes this one: nothing of it is shown or saved under the next. // A record being created belongs to the patient it was started for: another patient retires its save and // empties the form, so nothing typed for one is sent for the next. const createdFor = useRef(patientState?.id); useEffect(() => { if (createdFor.current === patientState?.id) return; createdFor.current = patientState?.id; if (!isNew) return; saveScope.renew(); startBlank(); setCreateUnconfirmed(false); setSubmitting(false); setError(undefined); }, [patientState?.id]); // A new record belongs to the patient it is filed for: another patient retires its save at once. const saveScope = useSaveScope(isNew ? `new:${patientState?.id ?? ''}` : observationId, () => { baseline.close(); startBlank(); setObservation(undefined); setCreateUnconfirmed(false); setSubmitting(false); loadingFor.current = undefined; }); // A new reading is one save under one operation id; an earlier one never confirmed is checked when the page opens. const createOperation = useObservationOperation('observation-page', operationPartiesOf(useAuthServiceContext(), patientState?.id), useObservationServiceContext()); useEffect(() => { if (isNew) void createOperation.opening(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isNew, patientState?.id]); // A read or answer lands only for the observation the page is on when it arrives. const shownId = useRef(observationId); shownId.current = observationId; useFhirObservationTargetedBroadcast({ observation, observationId, onUpdate: baseline.arrive, onDelete: () => navigate('..', { relative: 'path' }) }); // Archiving writes the record as this page opened it, within an operation of this page. Refused because someone // else changed it, out of date, or unanswered, the opening is done: the dialog closes, the page says so, and only // Reload record moves on. const closeArchive = useRef<() => void>(); const archive = useArchive(async (reason, note, signal) => { const held = baseline.opening; if (!held || baseline.stale || signal.aborted) return ARCHIVE_ABANDONED; const operation = saveScope.start(); // The dialog closing or reopening retires this attempt too, wherever it is. signal.addEventListener('abort', operation.retire); try { await operation.step(() => archiveObservation(held, reason, note, operation.transport), true); } catch (archiveError) { if (isSaveRetired(archiveError)) return ARCHIVE_ABANDONED; const ends = isRecordChanged(archiveError) ? (archiveError.outOfDate ? 'outOfDate' as const : 'changed' as const) : outcomeUnknown(archiveError) ? 'unconfirmed' as const : undefined; if (!ends) throw archiveError; baseline.refuse(ends); closeArchive.current?.(); return ARCHIVE_ABANDONED; } }); closeArchive.current = archive.close; // 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); setSuccess(undefined); try { await reloadBaseline({ baseline, operation, readNow: (transport) => fetchObservation(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); } }; useEffect(() => { const loadObservation = async () => { if (isNew || observation) return; const foundObservation = observationsList?.find(o => o.id === observationId); if (foundObservation) { init(foundObservation); return; } const requested = observationId; if (loadingFor.current === requested) return; loadingFor.current = requested; const operation = saveScope.start(); setLoading(true); try { const fetchedObservation = await operation.step(() => fetchObservation(requested as string, false, operation.transport)); if (shownId.current !== requested) return; init(fetchedObservation); } catch (err) { if (isSaveRetired(err) || shownId.current !== requested) return; console.error('Error loading observation:', err); setError('Could not load observation. Try again.'); } finally { if (loadingFor.current === requested) loadingFor.current = undefined; if (shownId.current === requested) setLoading(false); } }; loadObservation(); }, [observation, observationsList, observationId, fetchObservation, init]); // An earlier new reading, checked or finished: saved leaves the page as a save does; refused leaves it to enter. const settleEarlier = async (outcome: Promise) => { const result = await outcome; if (result?.saved) navigate('..', { relative: 'path' }); else if (result) setError('The earlier save was refused, so nothing was saved from it.'); }; const onSubmit = async (data: any) => { if (!patientState) { setError('Patient information is missing. Please try again.'); return; } if (isNew && createUnconfirmed) return; // This press, for this observation and person only: an answer after the page moved on changes nothing here. const operation = saveScope.start(); setSubmitting(true); setSuccess(undefined); setError(undefined); try { if (isNew) { let operationId: string; try { operationId = await createOperation.begin(); } catch (holdError) { if (holdError instanceof RecoveryUnavailable || holdError instanceof RecoveryFull) { setError(OPERATION_UNAVAILABLE_MESSAGE); return; } throw holdError; } try { // A confirmed answer settles its own operation, even one that arrives after the page moved on. await operation.step(async () => { const created = await createObservationOnce(transformToFhirObservation(data, patientState, encounterState), operationId, operation.transport); createOperation.settled(operationId); return created; }, true); } catch (createError) { // Sent with no answer of ours: it may be on record, so it is not sent again from here. Its operation // stays held, and the next opening checks it. if (!isSaveRetired(createError) && operation.belongs() && (outcomeUnknown(createError) || operationUnconfirmed(createError))) { setCreateUnconfirmed(true); setError(TIMED_OUT_MAY_HAVE_SAVED); return; } if (!isSaveRetired(createError) && operation.belongs() && operationReused(createError)) { setCreateUnconfirmed(true); setError(OPERATION_REUSED_MESSAGE); return; } // Fenced unsaved by the server: checked, then finished under the same operation, never sent again blind. if (!isSaveRetired(createError) && operation.belongs() && operationNotYetAdmitted(createError)) { setError(OPERATION_NOT_SAVED_MESSAGE); createOperation.unconfirmed(operationId); return; } // Refused with nothing written: the next press is a new save. Any other answer leaves it held for the next opening. if (!isSaveRetired(createError) && !outcomeUnknown(createError) && !operationUnconfirmed(createError) && !operationReused(createError) && !operationNotYetAdmitted(createError)) createOperation.settled(operationId); throw createError; } navigate('..', { relative: 'path' }); } else { const outcome = await saveOverBaseline({ baseline, operation, write: (basedOn, transport) => updateObservation({ id: observation?.id, ...transformToFhirObservation(data) }, basedOn, transport), }); if ('retired' in outcome) return; if ('refused' in outcome) { // A stale opening shows the standard notice with Reload record instead. if (!outcome.stale) setError(outcome.refused); return; } } setSuccess(`Observation ${isNew ? 'created' : 'updated'} successfully.`); setTimeout(() => setSuccess(undefined), 3_000); } catch (err: any) { if (isSaveRetired(err) || !operation.belongs()) return; console.error(`Error ${isNew ? 'creating' : 'updating'} observation:`, err); setError(describeWriteFailure(err, `Failed to ${isNew ? 'create' : 'update'} observation`)); } finally { if (operation.belongs()) setSubmitting(false); } }; if (loading) { return ( ); } // A reading another practice recorded is read here and changed only there, as the server enforces. const staffViewer = userRole !== USER_ROLES.PATIENT && userRole !== USER_ROLES.RELATED_PERSON; const practice = observationPractice(isNew ? undefined : observation, staffViewer ? currentOrganization?.id ?? wpOrganization?.current?.id : undefined); const readOnly = practice.fromAnotherPractice; return ( resourceType="Observation" resourceId={observationId || ''}> {(showHistory) => (
{!isNew && ( )} {(isNew && observationProfile || !isNew) && !readOnly && ( )} {!isNew && !readOnly && ( )} {!readOnly && (() => { const origin = isNew ? undefined : releasableOriginOf(observation); const note = isNew ? GENERIC_ENTRY_HELD_NOTE : origin ? editDropsSourceNote(origin) : undefined; return note &&

{note}

; })()} {isNew && createOperation.earlier && ( void settleEarlier(createOperation.check())} onFinish={() => void settleEarlier(createOperation.finish())} onStartNew={() => { setError(undefined); createOperation.startNew(); }} /> )} {success && ( <> setSuccess(undefined)}> {success} )} {error && ( <> setError(undefined)}>
{error}
)} {practice.readOnlyReason && ( <> {practice.readOnlyReason} )} void reload()} reloading={reloading} /> setObservationProfile(observationProfile)} elevated={true} disabled={submitting || readOnly}/> { if (!o) archive.close(); }} resourceLabel="Observation" submitting={archive.submitting} error={archive.error} onConfirm={async (reason, note) => { if (await archive.submit(reason, note)) { navigate('..', { relative: 'path' }); } }} />
)} ); }; export default EditObservationPage;