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 { ClinicalImpression, Practitioner } from 'fhir/r4'; import { Check, FileText } from 'lucide-react'; import { useAuthServiceContext, useClinicalImpressionServiceContext, usePatientServiceContext } from '../../../../../services'; import { AppHeader, AppSpinner, ProfileLayoutContainer, ScrollableTab, HistoryNavigator } from '../../../../../components'; import FhirClinicalImpression from '../../../../../components/FhirClinicalImpression/FhirClinicalImpression.tsx'; import { FhirClinicalImpressionDisplay } from '../../../../../components'; import { PrintableRegion, PrintRecord } from '../../../../../components/ui/print-record'; import { useFhirClinicalImpressionTargetedBroadcast, useActiveEncounterReference } from '../../../../../hooks'; import { ARCHIVE_ABANDONED, useArchive } from '../../../../../hooks/useArchive'; import ArchiveConfirmModal from '../../../../../components/ArchiveConfirmModal/ArchiveConfirmModal'; import { describeWriteFailure, ownFailureSentence } from '../../../../../utils/fetchFailureMessage'; import { useEditBaseline } from '../../../../../hooks/useEditBaseline'; import { isSaveRetired, isSaveTimedOut, useSaveScope, type SaveOperation } from '../../../../../utils/saveOperation'; import { noteOutcomeUnknown } from '../../../../../utils/clinicalNoteOutcome'; import { isRecordChanged } from '../../../../../utils/recordChanged'; import { reloadBaseline, saveOverBaseline } from '../../../../../utils/saveOverBaseline'; import { StaleRecordNotice } from '../../../../../components/StaleRecordNotice/StaleRecordNotice'; import { mayEditRecord } from '../../../../../utils/recordEditability'; import { useSeatStanding } from '../../../../../utils/seatStanding'; import { patientCacheGuard } from '../../../../../utils/cacheOwner'; const EditClinicalImpressionPage: React.FC = () => { const { clinicalImpressionId } = useParams<{ clinicalImpressionId: string }>(); const navigate = useNavigate(); const { currentUser, userRole, USER_ROLES, practitionerRoles } = useAuthServiceContext(); const seatStanding = useSeatStanding(); const { patientState, encounterState } = usePatientServiceContext(); const { cachedForPatientId: clinicalImpressionsOwner, fetchClinicalImpression, transformToFhirClinicalImpression, transformFromFhirClinicalImpression, createClinicalImpression, updateClinicalImpression, archiveClinicalImpression, clinicalImpressionsList: rawClinicalImpressionsList } = useClinicalImpressionServiceContext(); // The cache outlives a change of patient: read only the patient on screen. const clinicalImpressionsGuard = patientCacheGuard(clinicalImpressionsOwner, patientState?.id); const clinicalImpressionsList = clinicalImpressionsGuard.list(rawClinicalImpressionsList); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [clinicalImpression, setClinicalImpression] = useState(); const [reloading, setReloading] = useState(false); const [loadGeneration, setLoadGeneration] = useState(0); // A new note whose save could not be confirmed may exist already: not offered again, so it is never made twice. const [createUnconfirmed, setCreateUnconfirmed] = useState(false); const isNew = clinicalImpressionId === 'new'; const isPatient = [USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON].includes(userRole as string); /* * MAY THIS VIEWER EDIT THIS RECORD - the question the page below is drawn from. * * It used to ask `isPatient`, which is one case of it. Writing a ClinicalImpression needs a paid clinician seat, * resolved per request by the server's `callerMayWrite`, and an unseated practitioner passed every * check here: the form opened populated, with Save and Archive, and the write came back refused. A * refusal that arrives after the typing has already spent the work. * * THE LIST IS NOT ENOUGH ON ITS OWN. A control removed only from the list is a client gate with a * door beside it - this page is reachable by URL - so the same question is asked again here. */ const seat = mayEditRecord({ seatStanding, resourceType: 'ClinicalImpression', viewerIsStaff: !isPatient, practitionerRoles, }); const mayWrite = !isPatient && seat.mayEdit; const spacerMargin = '24px'; // 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. const activeEncounter = useActiveEncounterReference(); const methods = useForm({ defaultValues: { identifier: [], status: '', statusReason: null, code: null, description: '', encounter: activeEncounter ?? null, effective: '', date: '', assessor: null, previous: null, problem: [], protocol: [], summary: '', finding: [], prognosisCodeableConcept: [], prognosisReference: [], supportingInfo: [], note: [], investigation: [] } }); const { handleSubmit, reset, getValues } = methods; 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>(); 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 the note was opened on, held apart from the form (whose transform drops it), and the only one this // page saves against. A newer one arriving while the form holds changes is never filled over them. See // useEditBaseline. const baseline = useEditBaseline>({ formOf, read, write, onFill: setClinicalImpression }); // WHICH NOTE THIS PAGE IS FOR. On a change of note, or of the person signed in or their practice, everything of the // last one is retired and nothing is saved until the new note is loaded: a late answer lands nowhere (A -> B -> A). const saveScope = useSaveScope(clinicalImpressionId, () => { sending.current = undefined; baseline.close(); setClinicalImpression(undefined); setError(undefined); setSuccess(undefined); setLoading(false); setSubmitting(false); setReloading(false); setCreateUnconfirmed(false); setLoadGeneration((generation) => generation + 1); }); const owned = isNew || (clinicalImpression?.id === clinicalImpressionId && baseline.opening?.id === clinicalImpressionId); // The archive that finished, so the page leaves only while that archive still belongs here. const archived = useRef(); const loadInFlight = useRef(); useFhirClinicalImpressionTargetedBroadcast({ clinicalImpression, clinicalImpressionId, onUpdate: baseline.arrive, onDelete: () => navigate('..', { relative: 'path' }) }); // Archiving writes the note as this page holds it, at the version it was opened on. Refused, 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) => { const held = baseline.opening; if (!held || baseline.stale) return ARCHIVE_ABANDONED; const operation = saveScope.start(); try { await operation.step(() => archiveClinicalImpression(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) : noteOutcomeUnknown(archiveError) ? 'unconfirmed' as const : undefined; if (!ends) throw archiveError; baseline.refuse(ends); closeArchive.current?.(); return ARCHIVE_ABANDONED; } archived.current = operation; }); closeArchive.current = archive.close; useEffect(() => { if (isNew || clinicalImpression) return; const foundClinicalImpression = clinicalImpressionsList?.find(ci => ci.id === clinicalImpressionId); // Arrives rather than opens: if something newer is already open, an older copy changes nothing. if (foundClinicalImpression) { baseline.arrive(foundClinicalImpression); return; } // One read at a time for this note and person; its answer, error and spinner belong to them. if (loadInFlight.current?.belongs()) return; const operation = saveScope.start(); loadInFlight.current = operation; setLoading(true); operation.step(() => fetchClinicalImpression(clinicalImpressionId as string, false, operation.transport)) .then((fetchedClinicalImpression) => baseline.arrive(fetchedClinicalImpression)) .catch((err: unknown) => { if (!operation.belongs()) return; console.error('Error loading clinical impression:', err); setError('Could not load clinical impression. Try again.'); }) .finally(() => { if (loadInFlight.current === operation) loadInFlight.current = undefined; if (operation.belongs()) setLoading(false); }); }, [clinicalImpression, clinicalImpressionsList, clinicalImpressionId, fetchClinicalImpression, baseline.arrive, saveScope, loadGeneration]); // 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) => 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); } }; const onSubmit = async (data: any) => { if (isPatient) return; if (!patientState) { setError('Patient information is missing. Please try again.'); return; } // Nothing is saved until the note this page is for is the one open, nor from an opening that is done. if (!owned || baseline.stale || createUnconfirmed) return; const operation = saveScope.start(); setSubmitting(true); setSuccess(undefined); setError(undefined); try { if (isNew) { await operation.step(() => createClinicalImpression( transformToFhirClinicalImpression(data, patientState, encounterState, currentUser?.fhir as Practitioner), operation.transport, ), true); navigate('..', { relative: 'path' }); return; } 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); return; } setSuccess('Clinical impression updated successfully.'); setTimeout(() => { if (operation.belongs()) setSuccess(undefined); }, 3_000); } 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'); 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); setError('Whether the clinical impression was created could not be confirmed. Go back to the list and check before trying again.'); return; } if (isSaveTimedOut(err)) { setError(ownFailureSentence(err)); return; } console.error(`Error ${isNew ? 'creating' : 'updating'} clinical impression:`, err); setError(describeWriteFailure(err, `Failed to ${isNew ? 'create' : 'update'} clinical impression`)); } finally { if (sending.current === data) sending.current = undefined; if (operation.belongs()) setSubmitting(false); } }; if (loading) { return ( ); } return ( resourceType="ClinicalImpression" resourceId={clinicalImpressionId || ''}> {(showHistory) => (
{!isNew && ( )} {/* SAID, NOT WITHHELD. A Save that simply disappears reads as a broken screen or as a permission the reader has lost, and both send them to an administrator to check permissions that are already correct. A seat is nameable, and a practice can enable one in a minute. */} {!isPatient && !seat.mayEdit && (

{seat.reason}

)} {mayWrite && ( )} {!isNew && mayWrite && ( )} {/* The record has to be able to leave the screen. Without this a patient sending it to somebody not on Akashic photographs a scrolling page or prints the browser window, navigation and all. */} {!mayWrite && clinicalImpression && }
{success && ( <> setSuccess(undefined)}> {success} )} {error && ( <> setError(undefined)}> {error} )} {mayWrite && 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. */} {!mayWrite && clinicalImpression ? ( undefined}/> ) : ( )} {mayWrite && ( { if (!o) archive.close(); }} resourceLabel="Clinical Impression" submitting={archive.submitting} error={archive.error} onConfirm={async (reason, note) => { // The page leaves only while that archive still belongs to the note and person on screen. if (await archive.submit(reason, note) && archived.current?.belongs()) { navigate('..', { relative: 'path' }); } }} /> )}
)} ); }; export default EditClinicalImpressionPage;