import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useForm, FormProvider } from 'react-hook-form'; import { Condition } from 'fhir/r4'; import { Check, FileClock } from 'lucide-react'; import { DismissibleAlert } from '../../../../../components/ui/dismissible-alert'; import { AppHeader, AppSpinner, ProfileLayoutContainer, ScrollableTab, HistoryNavigator, FhirCondition, SimpleConditionForm } from '../../../../../components'; import { useAuthServiceContext, useConditionServiceContext, usePatientServiceContext, useWebSocketServiceContext } from '../../../../../services'; import { useFhirConditionTargetedBroadcast, useActiveEncounterReference } 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 { DiagnosisVisitLinkError, DIAGNOSIS_NOT_LINKED_MESSAGE } from '../../../../../services/ConditionService'; import { onViewerChange, viewerEpoch } from '../../../../../utils/writeThroughHold'; import { StaleRecordNotice } from '../../../../../components/StaleRecordNotice/StaleRecordNotice'; import { reloadBaseline, saveOverBaseline } from '../../../../../utils/saveOverBaseline'; import { isRecordChanged } from '../../../../../utils/recordChanged'; import ArchiveConfirmModal from '../../../../../components/ArchiveConfirmModal/ArchiveConfirmModal'; import { describeWriteFailure } from '../../../../../utils/fetchFailureMessage'; import { useProxyCannotAddToRecord } from '../../../../../hooks/useProxyCannotAddToRecord'; import { describeProxyCannotAddToRecord } from '../../../../../utils/proxyFormAuthority'; import { patientCacheGuard } from '../../../../../utils/cacheOwner'; import { isOwnNativeEntry, type ConditionSource } from '../../../../../utils/conditionContentClass'; import { ConditionContentClassSection, ConditionSourceQuestion, hasPendingConditionClassification, PatientConditionSourceQuestion } from './ConditionAuthorship'; import type { SelfReportSource } from '../../../../../utils/selfReport'; const EditConditionPage: React.FC = () => { const { conditionId } = useParams<{ conditionId: string }>(); const proxyCannotAdd = useProxyCannotAddToRecord(); const navigate = useNavigate(); const { currentUser, userRole, USER_ROLES, currentOrganization, actingAs } = useAuthServiceContext(); // Presentation only - the server (Condition handlers) authorizes what a patient may write. const isSimple = userRole === USER_ROLES.PATIENT || userRole === USER_ROLES.RELATED_PERSON; const isClinician = userRole === USER_ROLES.PRACTITIONER && currentUser?.fhir?.resourceType === 'Practitioner'; const { subscribe, unsubscribe } = useWebSocketServiceContext(); const { patientState, encounterState } = usePatientServiceContext(); // The patient themself, on their own record: never someone acting for them. const patientReportsForSelf = userRole === USER_ROLES.PATIENT && !actingAs && currentUser?.fhir?.resourceType === 'Patient' && Boolean(patientState?.id) && currentUser?.fhir?.id === patientState?.id; const editorOwner = JSON.stringify([conditionId, patientState?.id, currentUser?.fhir?.resourceType, currentUser?.fhir?.id, currentOrganization?.id, userRole]); const classificationViewerKey = `${currentUser?.fhir?.id ?? ''}|${currentOrganization?.id ?? ''}|${patientState?.id ?? ''}`; const sourceOpening = useRef({ owner: editorOwner, generation: 0 }); if (sourceOpening.current.owner !== editorOwner) { sourceOpening.current = { owner: editorOwner, generation: sourceOpening.current.generation + 1 }; } const sourceOwner = `${editorOwner}|${sourceOpening.current.generation}|${viewerEpoch()}`; const [sourceAnswer, setSourceAnswer] = useState<{ owner: string; value: ConditionSource }>(); const source = sourceAnswer?.owner === sourceOwner ? sourceAnswer.value : undefined; // The patient answer, held for this opening only, as the clinician answer is. const [reportAnswer, setReportAnswer] = useState<{ owner: string; value: SelfReportSource }>(); const reportSource = reportAnswer?.owner === sourceOwner ? reportAnswer.value : undefined; const setReportSource = useCallback((value: SelfReportSource) => setReportAnswer({ owner: sourceOwner, value }), [sourceOwner]); const setSource = useCallback((value: ConditionSource | undefined) => { setSourceAnswer(value === undefined ? undefined : { owner: sourceOwner, value }); if (value === undefined) setReportAnswer(undefined); }, [sourceOwner]); const { cachedForPatientId: conditionsOwner, fetchCondition, transformToFhirCondition, transformFromFhirCondition, createCondition, linkDiagnosisToItsEncounter, updateCondition, archiveCondition, createOwnCondition, recordOwnConditionReport, editOwnCondition, classifyCondition, conditionsList: rawConditionsList, // US Core Condition profiles usCoreConditionEncounterDiagnosisProfile, usCoreConditionProblemsHealthConcernsProfile } = useConditionServiceContext(); // The cache outlives a change of patient: read only the patient on screen. const conditionsGuard = patientCacheGuard(conditionsOwner, patientState?.id); const conditionsList = conditionsGuard.list(rawConditionsList); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [classifying, setClassifying] = useState(false); const classificationBusy = useRef(false); const setClassificationBusy = useCallback((busy: boolean) => { classificationBusy.current = busy; setClassifying(busy); }, []); const [reloading, setReloading] = useState(false); // A create whose answer was lost may have filed the diagnosis: it is not offered again from this page. const [createUnconfirmed, setCreateUnconfirmed] = useState(false); // A diagnosis stored without its visit link, by id: only the link is offered again, never the diagnosis. const [unlinked, setUnlinked] = useState(); const [linking, setLinking] = useState(false); useEffect(() => onViewerChange(() => setUnlinked(undefined)), []); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [condition, setCondition] = useState(undefined); const [conditionProfile, setConditionProfile] = useState(); const isNew = conditionId === 'new'; // THE FIELD AGREES WITH THE SAVE NOW. The service already applied the active encounter whether or // not this field was touched, so the record filed against the visit correctly while the control // read "Select...". A clinician reading that is told their record is unattached when it is not, and // the natural correction is to open a list of every past visit and pick one - which is how a wrong // visit gets attached to a clinical fact. // // The same hook the eight clinical modals use, so there is one rule rather than a second copy. It // returns undefined outside a visit, which is what keeps the plain chart route opening empty. // Declared above baseCondition because that object is what the reset payload spreads. const activeEncounter = useActiveEncounterReference(); const baseCondition = { clinicalStatus: null, verificationStatus: null, category: [], severity: null, code: null, bodySite: [], // In the RESET PAYLOAD rather than in defaultValues, the same place the diagnosis modal puts it: // this form's `defaultValues` is `{}` and every reset below spreads this object, so seeding it // here is what reaches the field. 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. encounter: activeEncounter, 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; // A report carries only the concern, its status and when it started: a severity or description makes it ordinary. const watched = methods.watch() as unknown as { note?: Array<{ text?: string }>; severity?: unknown }; const reportable = !(Array.isArray(watched.note) ? watched.note : []).some((n) => n?.text?.trim()) && !watched.severity; // 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 = () => { setConditionProfile(undefined); reset(); reset(baseCondition as never); }; 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]); // Force the health-concern profile for a patient/proxy creating a new concern, so the // Save button shows (see `(isNew && conditionProfile || !isNew)` below) and // transformToFhirCondition sets meta.profile. Feeds the [conditionProfile] effect above. useEffect(() => { if (isSimple && isNew && !conditionProfile) { setConditionProfile('us-core-condition-problems-health-concerns'); } }, [isSimple, isNew, conditionProfile]); // 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: Condition) => transformFromFhirCondition(record) as Record, [transformFromFhirCondition]); const read = useCallback(() => methods.getValues() as Record, [methods]); const write = useCallback((values: Record) => reset(values as never), [reset]); const onFill = useCallback((record: Condition) => { setSource(undefined); setCondition(record); setConditionProfile((formOf(record) as { conditionProfileSlug?: string }).conditionProfileSlug as string); }, [formOf, setSource]); // 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 signed-in clinician's own native entry at this practice, by the server's evidence on the version shown. const ownEntry = isClinician && Boolean(patientState?.id) && baseline.opening?.subject?.reference === `Patient/${patientState?.id}` && isOwnNativeEntry(baseline.opening, currentUser?.fhir?.id, currentOrganization?.id); // 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(); const arrive = baseline.arrive; // 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(); setSource(undefined); 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(editorOwner, () => { setSource(undefined); setClassificationBusy(false); baseline.close(); startBlank(); setCondition(undefined); setCreateUnconfirmed(false); setSubmitting(false); setLinking(false); // Kept only across the move from the new form to the diagnosis it stored. setUnlinked((held) => (held === conditionId ? held : undefined)); loadingFor.current = undefined; }); // A read or answer lands only for the condition the page is on when it arrives. const shownId = useRef(conditionId); shownId.current = conditionId; useFhirConditionTargetedBroadcast({ condition, conditionId, onUpdate: 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(() => archiveCondition(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). // Linking again is safe: a visit that already lists the diagnosis is left as it is. const linkToVisit = async () => { const held = baseline.opening; if (!held?.id || held.id !== unlinked) return; const operation = saveScope.start(); setLinking(true); setError(undefined); setSuccess(undefined); try { await operation.step(() => linkDiagnosisToItsEncounter(held, operation.transport), true); setUnlinked(undefined); setSuccess('The diagnosis is now linked to this visit.'); } 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()) setLinking(false); } }; 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) => fetchCondition(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(() => { if (proxyCannotAdd) archive.close(); }, [proxyCannotAdd, archive.close]); useEffect(() => { const loadCondition = async () => { if (isNew || condition) return; const foundCondition = conditionsList?.find(c => c.id === conditionId); if (foundCondition) { init(foundCondition); return; } const requested = conditionId; if (loadingFor.current === requested) return; loadingFor.current = requested; const operation = saveScope.start(); setLoading(true); try { const fetchedCondition = await operation.step(() => fetchCondition(requested as string, false, operation.transport)); if (shownId.current !== requested) return; init(fetchedCondition); } catch (err) { if (isSaveRetired(err) || shownId.current !== requested) return; console.error('Error loading condition:', err); setError('Could not load condition. Try again.'); } finally { if (loadingFor.current === requested) loadingFor.current = undefined; if (shownId.current === requested) setLoading(false); } }; loadCondition(); }, [condition, conditionsList, conditionId, fetchCondition, init]); useEffect(() => { if (!patientState || !condition) { return; } const handleConditionEvent = (cloudEvent: any) => { const conditionPayload: Condition = cloudEvent.data; const eventType = cloudEvent.type; if (conditionPayload.id === conditionId) { switch (eventType) { case 'com.akashichealthrecords.fhir.condition.updated': arrive(conditionPayload); break; case 'com.akashichealthrecords.fhir.condition.deleted': navigate('..', { relative: 'path' }); break; } } }; subscribe('com.akashichealthrecords.fhir.condition.updated', handleConditionEvent); subscribe('com.akashichealthrecords.fhir.condition.deleted', handleConditionEvent); return () => { unsubscribe('com.akashichealthrecords.fhir.condition.updated', handleConditionEvent); unsubscribe('com.akashichealthrecords.fhir.condition.deleted', handleConditionEvent); }; }, [patientState, condition, arrive, subscribe, unsubscribe]); const onSubmit = async (data: any) => { if (proxyCannotAdd || classificationBusy.current || (baseline.opening && hasPendingConditionClassification( baseline.opening, classificationViewerKey))) return; if (!patientState) { setError('Patient information is missing. Please try again.'); return; } if (isNew && createUnconfirmed) return; // This press, for this condition 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) { try { const entry = transformToFhirCondition(data, patientState, encounterState, currentUser?.fhir); const chosen = entry.code?.coding?.[0]; const report = patientReportsForSelf && reportable ? reportSource : undefined; if (report && !(chosen?.system && chosen.code)) { setError('Choose your health concern from the list.'); return; } await operation.step(() => (report && chosen?.system && chosen.code ? recordOwnConditionReport(patientState.id as string, { type: 'condition', source: report, clinicalStatus: 'active', code: { system: chosen.system, code: chosen.code, display: chosen.display ?? chosen.code }, ...(entry.onsetDateTime ? { onsetDateTime: entry.onsetDateTime } : {}), }, operation.transport) : source && isClinician ? createOwnCondition(patientState.id as string, entry, source, operation.transport) : createCondition(entry, operation.transport)), true); } catch (createError) { if (isSaveRetired(createError) || !operation.belongs()) return; // The diagnosis is stored; only its visit link failed. The page becomes that diagnosis, so the // next Save edits it rather than recording it a second time. if (createError instanceof DiagnosisVisitLinkError && createError.condition.id) { console.error('Condition created but not linked to its encounter:', createError); setUnlinked(createError.condition.id); navigate(`../${createError.condition.id}`, { relative: 'path', replace: true }); return; } // Sent with no answer of ours: it may be on record, so it is not sent again from here. if (outcomeUnknown(createError)) { setCreateUnconfirmed(true); setError(TIMED_OUT_MAY_HAVE_SAVED); return; } throw createError; } navigate('..', { relative: 'path' }); } else { const outcome = await saveOverBaseline({ baseline, operation, write: (basedOn, transport) => (ownEntry && source && condition?.id && basedOn ? editOwnCondition(condition.id, basedOn, { id: condition.id, ...transformToFhirCondition(data) }, source, transport) : updateCondition({ id: condition?.id, ...transformToFhirCondition(data) }, basedOn, transport)), }); if (!('retired' in outcome) && !('refused' in outcome)) setSource(undefined); 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(`Condition ${isNew ? 'created' : 'updated'} successfully.`); setTimeout(() => setSuccess(undefined), 3_000); } catch (err: any) { if (isSaveRetired(err) || !operation.belongs()) return; console.error(`Error ${isNew ? 'creating' : 'updating'} condition:`, err); setError(describeWriteFailure(err, `Failed to ${isNew ? 'create' : 'update'} condition`)); } finally { if (operation.belongs()) setSubmitting(false); } }; if (loading) { return ; } return ( resourceType="Condition" resourceId={conditionId || ''}> {(showHistory) => (
{!isNew && ( )} {!proxyCannotAdd && (isNew && conditionProfile || !isNew) && ( // `isBusy` carried WP's spinner affordance; a label swap preserves the // in-progress signal without pulling in a WP component for it. )} {!isNew && !proxyCannotAdd && ( )} {proxyCannotAdd &&

{describeProxyCannotAddToRecord(undefined)}

} {success && ( setSuccess(undefined)}> {success} )} {/* The `whiteSpace: pre-line` wrapper WP needed is gone on purpose — DismissibleAlert applies it to its own body, which is why it exists. This error is built as `Failed to …:\n${err.message}`, so losing it would silently collapse every multi-line server error onto one line. */} {error && ( setError(undefined)}> {error} )} {unlinked !== undefined && unlinked === conditionId && (

{DIAGNOSIS_NOT_LINKED_MESSAGE}

)} void reload()} reloading={reloading} /> {patientReportsForSelf && isNew && !proxyCannotAdd && (reportable ? ( ) : (

A concern with a severity or description is saved as an ordinary record, without where it came from.

))} {isClinician && (isNew || ownEntry) && ( )} {!isSimple && !isNew && baseline.opening && ( fetchCondition(id, true, transport)} onBusyChange={setClassificationBusy} onRecorded={(recorded) => baseline.arrive(recorded)} onReload={() => void reload()} /> )} {/* Presentation only — the server authorizes which Condition fields a patient may write. */} {isSimple ? ( ) : ( setConditionProfile(conditionProfile)} elevated={true} disabled={submitting}/> )} { if (!o) archive.close(); }} resourceLabel="Condition" submitting={archive.submitting} error={archive.error} onConfirm={async (reason, note) => { if (!proxyCannotAdd && await archive.submit(reason, note)) { navigate('..', { relative: 'path' }); } }} />
)} ); }; export default EditConditionPage;