import React, { useEffect, useRef, useState } from 'react'; import { RECORD_ACTIONS } from '../../../utils/recordActions'; import { formatFhirHumanName } from '../../../utils/fhirHumanName'; import type { Encounter, Patient, Practitioner } from 'fhir/r4'; import { AddProposedItem } from './AddProposedItem'; import { useFileRecordItem } from './useFileRecordItem'; import { useAuthServiceContext } from '../../../services/AuthService'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; import { HttpError } from '../../../services/HttpService'; import { DiagnosisVisitLinkError, DIAGNOSIS_NOT_LINKED_MESSAGE, useConditionServiceContext } from '../../../services/ConditionService'; import { isSaveRetired, outcomeUnknown, useSaveScope, TIMED_OUT_MAY_HAVE_SAVED } from '../../../utils/saveOperation'; import type { Condition } from 'fhir/r4'; import type { RecordProposal } from '../../../utils/recordProposals'; import type { CataloguedKind } from '../../../utils/recordFieldCatalogue.generated'; import { EarlierSaveNotice } from '../../../components/EarlierSaveNotice/EarlierSaveNotice'; import { operationPartiesOf, useObservationOperation, type EarlierOutcome } from '../../../hooks/useObservationOperation'; import { useObservationServiceContext } from '../../../services/ObservationService'; import { OPERATION_NOT_SAVED_MESSAGE, OPERATION_REUSED_MESSAGE, OPERATION_UNAVAILABLE_MESSAGE, operationNotYetAdmitted, operationReused, operationUnconfirmed } from '../../../utils/observationOperation'; import { RecoveryFull, RecoveryUnavailable } from '../../../utils/messaging/sendRecovery'; /** * The one proposed item currently being added, and the write behind it. * * IT IS MOUNTED ONLY WHILE AN ITEM IS OPEN, and that placement is deliberate rather than tidy. It * reaches nine resource services and the signed-in practitioner; holding those from inside the note * editor would make the editor itself require ten providers to render. The editor's own rule, learned * on the scribe and again on the proposal service: an add-on must never be able to take note writing * down with it. * * THE ITEM IS MARKED ADDED ONLY AFTER THE WRITE SUCCEEDS. A decision is not re-decidable - that is what * stops an item being filed twice - so marking it on the press would leave a FAILED write looking filed * and impossible to retry. The clinician would believe something is in the chart that is not. */ interface Props { proposal: RecordProposal; patient?: Patient; encounter?: Encounter; /** * Called once the item is genuinely on the record, with what was filed. * * The VALUES travel with it because what a clinician adds most is read back out of them - the same * field the search seeds is the field that names the thing, so the two cannot disagree. */ onFiled: (id: string, values: Record) => void; onClose: () => void; } const kindLabelOf = (kind: string) => RECORD_ACTIONS.find((action) => action.kind === kind)?.label ?? 'Item'; export const FileProposedItem: React.FC = ({ proposal, patient, encounter, onFiled, onClose, }) => { const fileRecordItem = useFileRecordItem(); const signedIn = useAuthServiceContext(); const { currentUser } = signedIn; const [saving, setSaving] = useState(false); const [error, setError] = useState(); const kindLabel = kindLabelOf(proposal.kind); // What a clinician is told when nothing better can honestly be said. const FAILED = `${kindLabel} not saved. Your entries are still here. Check them and retry.`; const { linkDiagnosisToItsEncounter } = useConditionServiceContext(); // Filed with no answer: it may be on record, so it is not filed again from here. const [unconfirmed, setUnconfirmed] = useState(false); const filing = useRef(false); // A diagnosis stored without its visit link: only the link is offered again, never the diagnosis. const [unlinked, setUnlinked] = useState(); // This item, for this patient and the person signed in: another retires its filing and what it was showing. const scope = useSaveScope(`${proposal.id}:${patient?.id ?? ''}`, () => { setSaving(false); setError(undefined); setUnconfirmed(false); setUnlinked(undefined); }); // Linking again is safe: a visit that already lists the diagnosis is left as it is. const linkToVisit = async (stored: Condition) => { const operation = scope.start(); setSaving(true); setError(undefined); try { await operation.step(() => linkDiagnosisToItsEncounter(stored, operation.transport), true); onClose(); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; console.error('Diagnosis not linked to its encounter:', err); setError(outcomeUnknown(err) ? 'Whether the diagnosis was linked to this visit could not be confirmed. Linking again is safe.' : 'The diagnosis could not be linked to this visit. Try linking again.'); } finally { if (operation.belongs()) setSaving(false); } }; // A reading is filed under one operation id; an earlier filing of it never confirmed is checked when this opens. const isReading = proposal.kind === 'observation'; const reading = useObservationOperation(`visit-note-item:${proposal.id}`, operationPartiesOf(signedIn, patient?.id), useObservationServiceContext()); useEffect(() => { if (isReading) void reading.opening(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isReading, proposal.id, patient?.id]); const settleEarlier = async (outcome: Promise) => { const result = await outcome; if (result?.saved) { onFiled(proposal.id, {}); onClose(); } else if (result) { setError('The earlier filing was refused, so nothing was saved from it.'); } }; const patientName = patient ? formatFhirHumanName(patient.name) : ''; const save = async (values: Record) => { // Nothing can be filed without a patient to file it against, and this says so rather than // posting a resource whose subject would be undefined. if (!patient) { setError('This item cannot be added without a patient.'); return; } if (unlinked) { await linkToVisit(unlinked); return; } if (unconfirmed || filing.current) return; // One filing at a time: a second press while this one prepares or sends files nothing more. filing.current = true; const operation = scope.start(); setSaving(true); setError(undefined); let operationId: string | undefined; try { if (isReading) { try { operationId = await reading.begin(); } catch (holdError) { if (holdError instanceof RecoveryUnavailable || holdError instanceof RecoveryFull) { if (operation.belongs()) setError(OPERATION_UNAVAILABLE_MESSAGE); return; } throw holdError; } } await fileRecordItem(proposal.kind as CataloguedKind, values, { patient, encounter, practitioner: currentUser?.fhir as Practitioner | undefined, operation, operationId, }); if (operationId) reading.settled(operationId); if (!operation.belongs()) return; onFiled(proposal.id, values); onClose(); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; console.error('Could not add this item to the record', err); // The diagnosis is on record; only its visit link is not. It is filed, and only the link is offered. if (err instanceof DiagnosisVisitLinkError) { onFiled(proposal.id, values); setUnlinked(err.condition); setError(DIAGNOSIS_NOT_LINKED_MESSAGE); return; } if (outcomeUnknown(err) || (isReading && operationUnconfirmed(err))) { setUnconfirmed(true); setError(TIMED_OUT_MAY_HAVE_SAVED); return; } if (isReading && operationReused(err)) { setUnconfirmed(true); setError(OPERATION_REUSED_MESSAGE); return; } // Fenced unsaved by the server: checked, then finished under the same operation, never filed again blind. if (operationId && operationNotYetAdmitted(err)) { setError(OPERATION_NOT_SAVED_MESSAGE); reading.unconfirmed(operationId); return; } // Refused with nothing written: the next filing is a new one. if (operationId) reading.settled(operationId); /* ONLY THE SERVER'S OWN WORDS ARE RELAYED, and that is the whole premise of `relayServerFailure` - it exists because a refusal explains itself better than any sentence we could write. A failure raised on THIS side never came from a server, so relaying it puts an internal message where the explanation should be. WALKED AND FOUND, 2026-09-04: a resource transform threw before any request was made, and a clinician was shown "Cannot read properties of undefined (reading 'map')". */ setError(err instanceof HttpError ? relayServerFailure(err, FAILED) : FAILED); } finally { filing.current = false; if (operation.belongs()) setSaving(false); } }; return ( void settleEarlier(reading.check())} onFinish={() => void settleEarlier(reading.finish())} onStartNew={() => { setError(undefined); reading.startNew(); }} /> ) : undefined} // Titled by kind, as the deck draws it; the item itself is in the body's first band. title={kindLabel} closeLabel={`Close ${kindLabel.toLowerCase()} without saving`} subtitle={[patientName, 'this visit'].filter(Boolean).join(' ยท ')} footer={`Closing this overlay does not add the ${kindLabel.toLowerCase()} or change whether the note is saved.`} /> ); }; export default FileProposedItem;