import React, { useState, useEffect, useRef, useCallback } 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 { Appointment, Encounter, List, Organization } from 'fhir/r4'; import { Check, FileText, Files, Pin } from 'lucide-react'; import { useAppointmentServiceContext, useAuthServiceContext, useEncounterServiceContext, useFhirServiceContext, useListServiceContext, usePatientServiceContext } from '../../../../../services'; import { AppHeader, AppSpinner, HistoryNavigator, ProfileLayoutContainer, ScrollableTab, FhirEncounter, FhirEncounterDisplay } from '../../../../../components'; import { PrintableRegion, PrintRecord } from '../../../../../components/ui/print-record'; import { useFhirEncounterTargetedBroadcast } 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 { reloadBaseline, saveOverBaseline } from '../../../../../utils/saveOverBaseline'; import { isRecordChanged } from '../../../../../utils/recordChanged'; import ArchiveConfirmModal from '../../../../../components/ArchiveConfirmModal/ArchiveConfirmModal'; import { describeFetchFailure, describeWriteFailure } from '../../../../../utils/fetchFailureMessage'; import { patientCacheGuard } from '../../../../../utils/cacheOwner'; const EditEncounterPage: React.FC = () => { const { encounterId, appointmentId } = useParams<{ id: string; encounterId: string; appointmentId: string; }>(); const navigate = useNavigate(); const { userRole, USER_ROLES, wpOrganization } = useAuthServiceContext(); const { patientState } = usePatientServiceContext(); const { cachedForPatientId: encountersOwner, fetchEncounter, readEncounter, transformToFhirEncounter, transformFromFhirEncounter, createEncounter, updateEncounter, archiveEncounter, encountersList: rawEncountersList } = useEncounterServiceContext(); // The cache outlives a change of patient: read only the patient on screen. const encountersGuard = patientCacheGuard(encountersOwner, patientState?.id); const encountersList = encountersGuard.list(rawEncountersList); const { fetchAppointment } = useAppointmentServiceContext(); const { fetchCareViewAnchors } = useListServiceContext(); const { unpackBundle } = useFhirServiceContext(); 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 visit: it is not offered again from this page. const [createUnconfirmed, setCreateUnconfirmed] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [encounter, setEncounter] = useState(); const [appointment, setAppointment] = useState(); const [findingAnchor, setFindingAnchor] = useState(false); const [anchorNotice, setAnchorNotice] = useState(); const loincSearchButtonRef = useRef(null); const backButtonRef = useRef(null); const [recordedAudio, setRecordedAudio] = useState(); const [encounterTranscript, setEncounterTranscript] = useState(''); const isPatient = [USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON].includes(userRole as string); const isNew = encounterId === 'new'; const spacerMargin = '24px'; const methods = useForm({ defaultValues: { identifier: [], status: '', statusHistory: [], class: null, classHistory: [], type: [], serviceType: null, priority: null, subject: null, episodeOfCare: [], basedOn: [], participant: [], appointment: [], reasonCode: [], reasonReference: [], diagnosis: [], account: [], hospitalization: null, // hospitalization: { // preAdmissionIdentifier: { // system: '', // value: '' // }, // origin: null, // admitSource: null, // reAdmission: null, // dietPreference: [], // specialCourtesy: [], // specialArrangement: [], // destination: null, // dischargeDisposition: null // }, location: [], serviceProvider: null, partOf: null } }); const { handleSubmit, reset } = methods; // What a new record starts from: the form as first mounted, never the last record it held. The bare reset // also empties the inputs, which a reset to values leaves as typed when they have no default. const creationDefaults = useRef>(); creationDefaults.current ??= structuredClone(methods.getValues() as Record); const startBlank = () => { reset(); reset(creationDefaults.current as never); }; // 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: Encounter) => transformFromFhirEncounter(record) as Record, [transformFromFhirEncounter]); const read = useCallback(() => methods.getValues() as Record, [methods]); const write = useCallback((values: Record) => reset(values as never), [reset]); const onFill = useCallback((record: Encounter) => { setEncounter(record); }, [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 ?? ''}` : encounterId, () => { baseline.close(); startBlank(); setEncounter(undefined); setCreateUnconfirmed(false); setSubmitting(false); loadingFor.current = undefined; }); // A read or answer lands only for the visit the page is on when it arrives. const shownId = useRef(encounterId); shownId.current = encounterId; useFhirEncounterTargetedBroadcast({ encounter, encounterId, 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(() => archiveEncounter(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) => readEncounter(id, 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 loadEncounter = async () => { if (isNew || encounter) return; const foundEncounter = encountersList?.find(e => e.id === encounterId); if (foundEncounter) { init(foundEncounter); return; } const requested = encounterId; if (loadingFor.current === requested) return; loadingFor.current = requested; const operation = saveScope.start(); setLoading(true); try { const fetchedEncounter = await operation.step(() => fetchEncounter(requested as string, false, operation.transport)); if (shownId.current !== requested) return; init(fetchedEncounter); } catch (err) { if (isSaveRetired(err) || shownId.current !== requested) return; console.error('Error loading encounter:', err); setError('Could not load encounter. Try again.'); } finally { if (loadingFor.current === requested) loadingFor.current = undefined; if (shownId.current === requested) setLoading(false); } }; loadEncounter(); }, [encounter, encountersList, encounterId, fetchEncounter, init]); useEffect(() => { const loadAppointment = async () => { if (!appointmentId || appointment) return; setLoading(true); setSuccess(undefined); setError(undefined); try { const fetchedAppointment = await fetchAppointment(appointmentId); setAppointment(fetchedAppointment); } catch (err) { console.error('Error loading appointment:', err); setError('Could not load appointment. Try again.'); } finally { setLoading(false); } }; loadAppointment(); }, [appointmentId, fetchAppointment]); const onSubmit = async (data: any) => { if (!patientState) { setError('Patient information is missing. Please try again.'); return; } if (isNew && createUnconfirmed) return; // This press, for this visit 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 { await operation.step(() => createEncounter(transformToFhirEncounter(data, patientState, wpOrganization.current as Organization, appointment), operation.transport), true); } catch (createError) { // Sent with no answer of ours: it may be on record, so it is not sent again from here. if (!isSaveRetired(createError) && operation.belongs() && outcomeUnknown(createError)) { setCreateUnconfirmed(true); setError(TIMED_OUT_MAY_HAVE_SAVED); return; } throw createError; } navigate(appointment ? '../..' : '..', { relative: 'path' }); } else { const outcome = await saveOverBaseline({ baseline, operation, write: (basedOn, transport) => updateEncounter({ id: encounter?.id, ...transformToFhirEncounter(data, patientState, wpOrganization.current as Organization) }, 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(`Encounter ${isNew ? 'created' : 'updated'} successfully.`); setTimeout(() => setSuccess(undefined), 3_000); } catch (err: any) { if (isSaveRetired(err) || !operation.belongs()) return; console.error(`Error ${isNew ? 'creating' : 'updating'} encounter:`, err); setError(describeWriteFailure(err, `Failed to ${isNew ? 'create' : 'update'} encounter`)); } finally { if (operation.belongs()) setSubmitting(false); } }; const navigateToLoincSearch = () => { if (loincSearchButtonRef.current) { loincSearchButtonRef.current.click(); } }; // Care-view anchor entry point (Capability B2, Task 9). Read-only lookup: fetch the patient's // anchors on demand and match by encounter reference, rather than pre-fetching on mount for // every encounter visit. No anchor found is an expected, non-error outcome. const handleViewCareViewAnchor = async () => { if (!patientState || !encounterId) return; // Defense in depth: the button above is already staff-only, and the patient route tree no // longer carries an anchors route to navigate to. if (isPatient) return; setFindingAnchor(true); setAnchorNotice(undefined); try { const bundle = await fetchCareViewAnchors(patientState); const anchors = unpackBundle(bundle).filter((r): r is List => Boolean(r?.id)); const match = anchors.find( (anchor) => anchor.encounter?.reference === `Encounter/${encounterId}` ); if (match) { // Staff subtree only - the patient-self mount no longer has an anchors route. navigate(`/dashboard/patients/${patientState.id}/anchors/${match.id}`); } else { setAnchorNotice('No care-view anchor for this encounter.'); } } catch (err: any) { setAnchorNotice(describeFetchFailure(err, "Couldn't look up care-view anchors")); } finally { setFindingAnchor(false); } }; // const navigateBack = () => { // if (backButtonRef.current) { // backButtonRef.current.click(); // } // }; // // const handleStartRecording = () => { // console.log('STARTED'); // }; // // const handleAudioChunk = (audioBlob: Blob) => { // console.log('BLOB', audioBlob); // // streamAudioChunk(audioBlob); // }; // // const handleStopRecording = (finalAudioBlob: Blob) => { // console.log('Final audio blob:', finalAudioBlob); // }; if (loading) { return ( ); } return ( resourceType="Encounter" resourceId={encounterId || ''}> {(showHistory) => (
{!isNew && ( )} {/* Staff only. A care-view anchor is evidentiary tooling — it proves what a clinician was looking at when they made a decision — so it belongs to the practitioner view, not to a patient (or related person) reading their own encounter. */} {!isNew && encounter && !isPatient && ( )} {/* Dead placeholder ({false &&}) — converted rather than deleted so the file clears the WordPress list while the placeholder survives. It keeps the author's isDestructive styling; if this is ever enabled, revisit the colour, because navigating to a search is not a destructive action. */} {false && ( )} {!isPatient && ( )} {!isNew && !isPatient && ( )} {/* 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. */} {isPatient && encounter && } {success && ( <> setSuccess(undefined)}> {success} )} {error && ( <> setError(undefined)}>
{error}
)} {anchorNotice && ( <> setAnchorNotice(undefined)}> {anchorNotice} )} void reload()} reloading={reloading} /> {/* THE READER GETS THE RECORD, NOT THE FORM SWITCHED OFF. `FhirEncounterDisplay` 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. */} {isPatient && encounter ? ( undefined}/> ) : ( )} { if (!o) archive.close(); }} resourceLabel="Encounter" submitting={archive.submitting} error={archive.error} onConfirm={async (reason, note) => { if (await archive.submit(reason, note)) { navigate('..', { relative: 'path' }); } }} />
)} // // // {/* Hidden button with ref */} // // Navigate to AI transcript // // // // // {/* Hidden back button with ref */} // // Back // // // // // // // ); }; export default EditEncounterPage;