import { useCallback, useLayoutEffect, useRef, useState } from 'react'; import { isRecordChanged } from '../../../utils/recordChanged'; import { moveEncounter, OPEN_ENCOUNTER_STATUSES, VISIT_CHANGED_MESSAGE } from '../../../utils/encounterChange'; import type { Encounter } from 'fhir/r4'; import { SETTLEMENT_PENDING_MESSAGE, useEncounterServiceContext } from '../../../services/EncounterService'; import { toast } from 'sonner'; import { useEncounterNotesContext, type NoteNeedsReviewError } from './EncounterNotesContext'; import { useActiveEncounterContext } from './ActiveEncounterContext'; import { isSaveRetired, useSaveScope } from '../../../utils/saveOperation'; import { noteOutcomeUnknown } from '../../../utils/clinicalNoteOutcome'; import { admitFinish, finishBlockedMessage, isFinishRefusal, recordingTarget, useViewerEpoch, type FinishAdmission, } from '../../../services/scribe/recordingDisposition'; // Why Finish stopped at the note: refused because it changed, unconfirmed, or a plain failure to retry. Known by name, // as the other save errors are. const noteNotFinishedMessage = (error: unknown): string => { const review = (error as { name?: unknown } | null)?.name === 'NoteNeedsReviewError' ? (error as NoteNeedsReviewError).reason : undefined; if (review === 'conflict' || isRecordChanged(error)) { return 'The clinical note changed elsewhere, so it was not saved and the encounter was not finished. Review the saved note, then finish again.'; } if (review === 'unconfirmed' || noteOutcomeUnknown(error)) { return 'Whether the clinical note was saved could not be confirmed, so the encounter was not finished. Review the saved note before finishing.'; } return 'Could not save the clinical note — the encounter was not finished. Please retry.'; }; /** * The write paths belonging to the encounter that is OPEN in the workspace - Finish, Set on-leave and * Reopen, as pressed from inside the documentation surface. * * - WHY THIS IS A HOOK AND NOT THREE CALLBACKS ON THE PAGE * * There are TWO families of encounter write on this board and they were NOT symmetric: * * - the queue's focus panel writes through `useEncounterActions`, which reports the encounter it * WROTE to the board (`onChanged(written)`), and * - the workspace wrote inline on `ClinicianOverviewPage` and reported NOTHING - it called a bare * `refresh()`. * * That asymmetry IS the defect claude-4 measured twice on 2026-08-16: pressing **Finish encounter** * from inside the documentation surface closed the surface and left the patient reading **In progress**, * with IN PROGRESS still 1 and a fresh *Resume documentation* row - and pressing the SAME BUTTON from * the overview immediately afterwards finished it (IN PROGRESS 0 - SEEN TODAY 1). The write was landing * both times. What differed was whether the board was told. * * The mechanism is (B47): `refresh()` re-runs an Appointment search plus a per-appointment * Encounter search, and **HealthLake is ~30 s stale after a write**, * whose rule is literally *"verify writes by read-by-id, never by search"*. So the board faithfully * redrew the PRE-WRITE world. `useClinicianDay`'s overlay exists to close exactly this gap - and the * only way in is `onChanged(written)`. * * **The failure direction is the dangerous one.** It fails toward *"the visit is still open"*, which * is invisible to the clinician who caused it (their surface closed, so it looked like success) and * visible only to the front desk hours later as a visit that has been running for a day and a quarter. * * So the fix is not three added lines - it is giving this family the same shape as its sibling, where * *"every write path reports what it wrote"* is a property of a hook that can be rendered and asserted, * rather than a habit three inline callbacks each had to remember. * (a proven rule that one call site does not use). */ interface OpenEncounterActionsOptions { /** * (B47) - receives the encounter that was just WRITTEN, when there is one, and refreshes. * Never call it with a locally-assembled guess: the board displays nothing the API did not confirm. * It is called on the FAILURE path too, with `undefined` - that is a plain refresh, which is the * behaviour a failed write has always had, and it keeps the exit paths from having two shapes. */ onChanged: (writtenEncounter?: Encounter) => void; /** * Leave the workspace for the switchboard. It must NOT refresh - `onChanged` above already has, and * a second `refresh()` in the same turn costs a whole extra day-fetch (the board's tick is a counter, * not a flag, so two bumps run the effect twice). */ onExit: (closedKey: string) => void; } export const useOpenEncounterActions = ({ onChanged, onExit }: OpenEncounterActionsOptions) => { const { changeEncounter } = useEncounterServiceContext(); const notes = useEncounterNotesContext(); const { active: open, replaceActive } = useActiveEncounterContext(); const [busy, setBusy] = useState(false); // Each action belongs to the visit open when it was pressed (and the person signed in): once another visit is // open, nothing more is sent for it, and its answer neither alerts, exits nor replaces the visit now open. const scope = useSaveScope(open?.encounter.id, () => setBusy(false)); // The visit open now, read by a finish that may have been handed on from an earlier render. const openRef = useRef(open); openRef.current = open; // Moves on every opening and on leaving, so an ended opening never matches again when the same visit returns. const viewerEpoch = useViewerEpoch(); const openingRef = useRef(0); useLayoutEffect(() => { openingRef.current += 1; return () => { openingRef.current += 1; }; }, [open?.item.patientId, open?.encounter.id, viewerEpoch]); const finishHeld = useCallback(async () => { if (!open) return; const operation = scope.start(); setBusy(true); // Commit the clinical-note draft FIRST - one FHIR write. If it fails, do NOT finish: the draft stays // in memory (nothing lost) so the provider can retry rather than finishing a visit without its note. try { await operation.step(() => notes.commit(open.item.patientId, open.encounter.id as string, operation.transport), true); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; console.error('Failed to save clinical note on finish', err); window.alert(noteNotFinishedMessage(err)); setBusy(false); return; } // - hold what the API RETURNED. Discarding it is what made this button look like it did // nothing: the status was written and the board had no way to learn it before the search caught up. // - write the status onto what the SERVER holds, never the copy held since the visit opened. Walked // 2026-09-30: a diagnosis filed mid-visit linked itself to the encounter (v3), and Finish PUT the v1 // copy and erased the link. A failed read writes nothing; the held copy would be the defect again. // Written over the version read, so a change landing after the read is not erased either. let written: Encounter | undefined; let settlementPending = false; try { written = await operation.step(() => changeEncounter(open.encounter.id as string, moveEncounter( OPEN_ENCOUNTER_STATUSES, 'finished', (stored) => ({ period: { ...stored.period, end: new Date().toISOString() } }), ), { transport: operation.transport, onSettlementPending: () => { settlementPending = true; } }), true); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; console.error('Failed to finish encounter', err); window.alert(isRecordChanged(err) ? VISIT_CHANGED_MESSAGE : 'Could not finish the encounter. Please try again.'); } // Leaves the visit only once it is finished; refused or failed, the workspace stays for another try. if (written && settlementPending && operation.belongs()) toast.info(SETTLEMENT_PENDING_MESSAGE); setBusy(false); onChanged(written); if (written) onExit(open.item.key); }, [open, changeEncounter, notes, onChanged, onExit, scope]); const finish = useCallback(async (admission?: FinishAdmission) => { if (!open) return; // The workspace's admission when it holds this very visit; otherwise this finish takes its own. const target = recordingTarget(open.item.patientId, open.encounter.id); const opening = openingRef.current; const current = () => openingRef.current === opening && openRef.current?.encounter.id === open.encounter.id && openRef.current?.item.patientId === open.item.patientId; // Handed on, it is this press's or nothing: a stale or mismatched admission is never traded for a fresh one. if (admission) { if (admission.target !== target || !admission.holds() || !current()) return; await finishHeld(); return; } const own = admitFinish(target, current); if (isFinishRefusal(own)) { const said = finishBlockedMessage(own.blocked); if (said) window.alert(said); return; } try { await finishHeld(); } finally { own.release(); } }, [open, finishHeld]); const setOnLeave = useCallback(async () => { if (!open) return; const operation = scope.start(); setBusy(true); let written: Encounter | undefined; try { written = await operation.step(() => changeEncounter(open.encounter.id as string, moveEncounter(['in-progress'], 'onleave'), { transport: operation.transport }), true); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; console.error('Failed to set on-leave', err); window.alert(isRecordChanged(err) ? VISIT_CHANGED_MESSAGE : 'Could not update the encounter. Please try again.'); } setBusy(false); onChanged(written); if (written) onExit(open.item.key); }, [open, changeEncounter, onChanged, onExit, scope]); // Reopen the currently-open (finished) encounter IN PLACE - stays in the workspace, which then // re-renders as in-progress. History-safe status update; fires a fresh care-view anchor server-side. /** * The reopen carries the reason the dialog collected (Spencer, 2026-08-25: reopening is logged and * auditable and includes a reason). The dialog requires a non-blank reason, and dismissing it writes * nothing; the server also refuses a reopen without one (reviews/2026-09-28-visit-reopen-reason-accept.md). */ const reopen = useCallback(async (reopenReason?: string) => { if (!open) return; const operation = scope.start(); setBusy(true); try { // On the visit as stored, not the copy held since it opened. const reopened = await operation.step(() => changeEncounter(open.encounter.id as string, moveEncounter( ['finished'], 'in-progress', (stored) => ({ period: { start: stored.period?.start ?? new Date().toISOString() } }), ), { reopenReason, transport: operation.transport }), true); // replaceActive, not setActive: the SAME encounter is being reopened in place, so this is not a // switch and nothing may be parked. (setActive's same-id guard would also refuse, but saying // "not a switch" here is the honest expression of intent rather than relying on a guard alignment.) replaceActive({ item: { ...open.item, status: 'in-progress', encounter: reopened }, encounter: reopened }); // The active slot is NOT the board. `replaceActive` fixes the workspace the practitioner is // looking at; the queue row beside it is still drawn from the day fetch, so without this the row // would keep reading "Finished" next to a workspace that says In progress. onChanged(reopened); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; console.error('Failed to reopen encounter', err); window.alert(isRecordChanged(err) ? VISIT_CHANGED_MESSAGE : 'Could not reopen the encounter. Please try again.'); } finally { if (operation.belongs()) setBusy(false); } }, [open, changeEncounter, replaceActive, onChanged, scope]); return { busy, finish, setOnLeave, reopen }; };