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 { MedicationStatement } from 'fhir/r4'; import { Check, FileText } from 'lucide-react'; import { useMedicationStatementServiceContext, usePatientServiceContext } from '../../../../../services'; import { AppHeader, AppSpinner, ProfileLayoutContainer, ScrollableTab, HistoryNavigator, FhirMedicationStatement } from '../../../../../components'; import { useFhirMedicationStatementTargetedBroadcast } 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, outcomeUnknown, useSaveScope, type SaveOperation } from '../../../../../utils/saveOperation'; import { isRecordChanged } from '../../../../../utils/recordChanged'; import { reloadBaseline, saveOverBaseline } from '../../../../../utils/saveOverBaseline'; import { StaleRecordNotice } from '../../../../../components/StaleRecordNotice/StaleRecordNotice'; const EditMedicationStatementPage: React.FC = () => { const { medicationStatementId } = useParams<{ medicationStatementId: string }>(); const navigate = useNavigate(); const { patientState, encounterState } = usePatientServiceContext(); const { fetchMedicationStatement, transformToFhirMedicationStatement, transformFromFhirMedicationStatement, createMedicationStatement, updateMedicationStatement, archiveMedicationStatement, medicationStatementsList, } = useMedicationStatementServiceContext(); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [medicationStatement, setMedicationStatement] = useState(); const [reloading, setReloading] = useState(false); const [loadGeneration, setLoadGeneration] = useState(0); // A new statement 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 = medicationStatementId === 'new'; const spacerMargin = '24px'; const methods = useForm({ defaultValues: { status: '', statusReason: null, category: null, medicationCodeableConcept: null, effectiveType: '', effectiveDateTime: '', dateAsserted: '', informationSource: null, reasonCode: [], reasonReference: [], context: [], partOf: [], basedOn: [], note: [], dosage: [] } }); const { handleSubmit, reset, getValues } = methods; const formOf = useCallback((record: MedicationStatement) => transformFromFhirMedicationStatement(record) as Record, [transformFromFhirMedicationStatement]); // The form's values, as plain data the baseline can compare. const read = useCallback(() => getValues() as Record, [getValues]); const write = useCallback((values: Record) => reset(values as never), [reset]); // The version the form was opened on, and the only one it saves against. A newer one arriving while the form holds // changes (another practitioner's dose correction, say) is never filled over them: the opening is marked changed, // and Reload record is the way on - so a stale note edit cannot bring the old dose back. See useEditBaseline. const baseline = useEditBaseline>({ formOf, read, write, onFill: setMedicationStatement }); // WHICH RECORD THIS PAGE IS FOR: on a change of record (or of the person signed in, or their practice) everything // belonging to the last one - record, form, baseline, loads, saves and busy flags - is retired, and nothing can be // saved until the new record is loaded and opened. const saveScope = useSaveScope(medicationStatementId, () => { baseline.close(); setMedicationStatement(undefined); setError(undefined); setSuccess(undefined); setLoading(false); setSubmitting(false); setReloading(false); setCreateUnconfirmed(false); // The record is loaded again for whoever is signed in now. setLoadGeneration((generation) => generation + 1); }); const owned = isNew || (medicationStatement?.id === medicationStatementId && baseline.opening?.id === medicationStatementId); // The archive that finished, so the page leaves only while that archive still belongs here. const archived = useRef(); const loadInFlight = useRef(); useFhirMedicationStatementTargetedBroadcast({ medicationStatement, medicationStatementId, onUpdate: baseline.arrive, onDelete: () => navigate('..', { relative: 'path' }) }); // Archiving writes the record as this page holds it, so it is the version the form was opened on, within an // operation of this page: retired with the record or the person signed in, so nothing of it lands elsewhere. // Refused because someone else changed it, out of date, or unanswered: the archive is a write like any other, so // the opening it was made from 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(() => archiveMedicationStatement(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; } archived.current = operation; }); closeArchive.current = archive.close; useEffect(() => { if (isNew || medicationStatement) return; const foundMedicationStatement = medicationStatementsList?.find(ms => ms.id === medicationStatementId); // Arrives rather than opens: if something newer is already open, an older copy changes nothing. if (foundMedicationStatement) { baseline.arrive(foundMedicationStatement); return; } // One read at a time for this record and person: a read still under way is not started again. if (loadInFlight.current?.belongs()) return; // The read, its answer, its error and the spinner all belong to the record and the person signed in as it // starts; the record or the person changing (A -> B -> A included) retires it, and a late answer lands nowhere. const operation = saveScope.start(); loadInFlight.current = operation; setLoading(true); operation.step(() => fetchMedicationStatement(medicationStatementId as string, false, operation.transport)) .then((fetchedMedicationStatement) => baseline.arrive(fetchedMedicationStatement)) .catch((err: unknown) => { if (!operation.belongs()) return; console.error('Error loading medication statement:', err); setError('Could not load medication statement. Try again.'); }) .finally(() => { if (loadInFlight.current === operation) loadInFlight.current = undefined; if (operation.belongs()) setLoading(false); }); }, [medicationStatement, medicationStatementsList, medicationStatementId, fetchMedicationStatement, 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) => fetchMedicationStatement(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 (!patientState) { setError('Patient information is missing. Please try again.'); return; } // Nothing is saved until the record this page is for is the one open, nor from an opening that is done. if (!owned || baseline.stale || createUnconfirmed) return; // ONE OPERATION FOR THE SAVE, create or update: the record or the person signed in changing retires it, so // nothing more is sent, published, said or navigated for it - and its busy flag is its own. const operation = saveScope.start(); setSubmitting(true); setSuccess(undefined); setError(undefined); try { if (isNew) { await operation.step(() => createMedicationStatement( transformToFhirMedicationStatement(data, patientState, encounterState), operation.transport, ), true); navigate('..', { relative: 'path' }); return; } const outcome = await saveOverBaseline({ baseline, operation, write: (basedOn, transport) => updateMedicationStatement({ id: medicationStatement?.id, ...transformToFhirMedicationStatement(data, patientState) }, 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('Medication statement updated successfully.'); setTimeout(() => setSuccess(undefined), 3_000); } catch (err: any) { if (isSaveRetired(err)) return; // An answer that never came - out of time, or the connection failed after the request may have left - may // mean it was created: not offered again until what is saved is looked at. if (isNew && outcomeUnknown(err)) { setCreateUnconfirmed(true); setError('Whether the medication statement 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'} medication statement:`, err); setError(describeWriteFailure(err, `Failed to ${isNew ? 'create' : 'update'} medication statement`)); } finally { if (operation.belongs()) setSubmitting(false); } }; if (loading) { return ( ); } return ( resourceType="MedicationStatement" resourceId={medicationStatementId || ''}> {(showHistory) => (
{!isNew && ( )} {!isNew && ( )} {success && ( <> setSuccess(undefined)}> {success} )} {error && ( <> setError(undefined)}>
{error}
)} void reload()} reloading={reloading} /> { if (!o) archive.close(); }} resourceLabel="Medication Statement" submitting={archive.submitting} error={archive.error} onConfirm={async (reason, note) => { // The service took it off the list when the archive landed; the page leaves only while // that archive still belongs to the record and person on screen. if (await archive.submit(reason, note) && archived.current?.belongs()) { navigate('..', { relative: 'path' }); } }} />
)} ); }; export default EditMedicationStatementPage;