import { useCallback, useEffect, useRef, useState } from 'react'; import { isRecordChanged } from '../../../utils/recordChanged'; import { ENDED_ENCOUNTER_STATUSES, moveEncounter, OPEN_ENCOUNTER_STATUSES, VISIT_CHANGED_MESSAGE, type EncounterChange } from '../../../utils/encounterChange'; import { useNavigate } from 'react-router-dom'; import type { Encounter, Organization } from 'fhir/r4'; import { SETTLEMENT_PENDING_MESSAGE, useEncounterServiceContext } from '../../../services/EncounterService'; import { toast } from 'sonner'; import { useAuthServiceContext } from '../../../services/AuthService'; import { formatFhirHumanName } from '../../../utils/fhirHumanName'; import { onlyVirtual, roomPhysicalType } from '../../../utils/roomLocation'; import { encounterClassForAppointment, encounterIsVirtual, VIRTUAL_ENCOUNTER_CLASS } from '../../../utils/encounterClass'; import { useFhirLocations } from '../../../hooks/useFhirLocations.tsx'; import { useOrgAuthorizationServiceContext, type OrgAuthorizationStatusValue, } from '../../../services/OrgAuthorizationService.tsx'; import { startEncounterFailureMessage } from './encounterStartFailure'; import type { QueueItem } from './encounterLifecycle'; import { RECORDS_INSTRUMENT } from '../../../config/consentInstruments.ts'; import { useOptionalVideoServiceContext } from '../../../services/VideoService'; import { isSaveRetired, outcomeUnknown, useSaveScope, TIMED_OUT_MAY_HAVE_SAVED, type SaveOperation, type SaveTransport } from '../../../utils/saveOperation'; import { encounterContext } from '../../../services/video/videoContract'; import { HttpError } from '../../../services/HttpService'; import { prepareVisitLine } from '../../../utils/prepareVisit'; import { knownVisitRecords, rememberVisitRecords } from '../../../utils/knownVisitRecords'; import { startInView, type AnchorView } from '../../../hooks/anchorView/anchorView'; import { settleVisitStartOperation, visitStartOperation, VisitStartScopeUnconfirmedError, type VisitStartCustody, type VisitStartScope } from '../../../utils/visitStartOperation'; import { admitFinish, finishBlockedMessage, isFinishRefusal, recordingTarget, unsettledRecording, type FinishAdmission, } from '../../../services/scribe/recordingDisposition'; const US_CORE_ENCOUNTER = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter'; // (the local AMB constant moved to utils/encounterClass.ts as AMBULATORY_ENCOUNTER_CLASS, alongside the // virtual coding it now has to be chosen against - §6) // US Core Encounter requires `type` (1..*). HealthLake rejects a us-core-profiled Encounter without it // (HTTP 400). A generic ambulatory-visit type satisfies the profile for a one-click Start; the provider // refines it during documentation. const DEFAULT_ENCOUNTER_TYPE = [ { coding: [{ system: 'http://snomed.info/sct', code: '308335008', display: 'Patient encounter procedure' }] }, ]; // An encounter in any of these statuses already represents this visit - reuse it, never create a duplicate. // in-progress/onleave are already open; planned/arrived/triaged are pre-visit and get transitioned to in-progress. const REUSABLE_ENCOUNTER_STATUSES = ['planned', 'arrived', 'triaged', 'in-progress', 'onleave']; /** Why a new visit was not created: its start could not be kept safely enough to resend as the same start. */ export const VISIT_START_NOT_KEPT = 'This visit was not started: this browser could not keep track of the start, so trying again could ' + 'create a second visit. Allow this site to store data, then try again.'; export const VISIT_START_UNREADABLE = 'This visit was not started: an earlier start of it in this tab could not be read. ' + 'Refresh the board to find that visit before starting another.'; export const VISIT_START_SCOPE_UNCONFIRMED = 'The original visit start cannot be confirmed here. It is kept for assistance.'; export const UNCONFIRMED_VISIT_RETRY = 'Starting this visit may already have been saved, though it is not showing yet.' + ' Start it again anyway? Choose Cancel to wait a moment and try again.'; interface EncounterActionsOptions { /** * (B47) - receives the encounter that was just WRITTEN, when there is one. The board cannot * learn about it from a refetch: HealthLake searches are ~30s stale after a write, so an immediate * refresh faithfully returns the pre-write world. Pass the resource the API RETURNED. */ onChanged?: (writtenEncounter?: Encounter) => void; onEnter: (encounter: Encounter, item: QueueItem) => void; /** The original view the panel showed before Start, when one opened for this visit. */ view?: AnchorView; } export const useEncounterActions = ({ onChanged, onEnter, view }: EncounterActionsOptions) => { const navigate = useNavigate(); const { createEncounter, prepareVisit, changeEncounter, fetchEncounters, readVisitStartScope } = useEncounterServiceContext(); const { currentUser, wpOrganization } = useAuthServiceContext(); // / Slice A - used only to EXPLAIN a start failure, never to pre-gate the click. The server // is the authority on whether the write is allowed; this is presentation. const { getStatus } = useOrgAuthorizationServiceContext(); // §6 - needed to recognise the org's virtual Location on the appointment. const { locationsList } = useFhirLocations(); const [busyKey, setBusyKey] = useState(); const video = useOptionalVideoServiceContext(); // Rows whose visit was sent and never answered: it may exist and not be found yet, so another is created only // when the person chooses to, never by a plain retry. const unconfirmedStarts = useRef(new Set()); const mayCreateFor = (item: QueueItem) => !unconfirmedStarts.current.has(item.key) || window.confirm(UNCONFIRMED_VISIT_RETRY); // Each command belongs to this board and the person signed in: leaving the board, or another person signing in, // retires it, so nothing it was waiting on is sent, entered or reported afterwards. const scope = useSaveScope((wpOrganization?.current as Organization | null | undefined)?.id ?? 'board', () => { setBusyKey(undefined); setConfirmingFinish(undefined); setEndingCall(false); }); // Enter the in-place workspace (was: setEncounterState + navigate). The page decides what "enter" does. const enterEncounter = useCallback((encounter: Encounter, item: QueueItem) => { onEnter(encounter, item); }, [onEnter]); // Reuse an existing encounter for this appointment if one exists (avoid duplicates). Includes // pre-visit statuses (planned/arrived/triaged) - those get transitioned to in-progress by `start`. const findExistingEncounter = useCallback(async (item: QueueItem, transport: SaveTransport): Promise => { if (item.encounter && REUSABLE_ENCOUNTER_STATUSES.includes(item.encounter.status)) return item.encounter; if (!item.appointment?.id) return undefined; const bundle = await fetchEncounters({ appointment: item.appointment, transport }); return (bundle.entry ?? []) .map((e) => e.resource as Encounter) .find((e) => e && REUSABLE_ENCOUNTER_STATUSES.includes(e.status)); }, [fetchEncounters]); const start = useCallback(async (item: QueueItem) => { const operation = scope.start(); setBusyKey(item.key); // Whatever this run ends up putting on the record, reported to the board in `finally`. let startedEncounter: Encounter | undefined; let creating = false; try { const existing = await operation.step(() => findExistingEncounter(item, operation.transport)); if (existing) unconfirmedStarts.current.delete(item.key); const existingId = existing?.id; if (existing && existingId) { // Already open just enter. Pre-visit (planned/arrived/triaged) transition to in-progress // (this write fires the care-view anchor server-side), then enter. if (existing.status === 'in-progress' || existing.status === 'onleave') { enterEncounter(existing, item); startedEncounter = existing; } else { // Made on the visit as stored, so what it gained since the board read it (a room, a diagnosis) stays. // Inside an original view the start first freezes what was shown, then names it under its own operation. const named = await operation.step(() => startInView(view, existingId, undefined, operation.transport.signal)); const started = await operation.step(() => changeEncounter(existingId, moveEncounter( ['planned', 'arrived', 'triaged', 'onleave'], 'in-progress', (current) => ({ period: { ...current.period, start: current.period?.start ?? new Date().toISOString() } }), ), { transport: operation.transport, ...named }), true); enterEncounter(started, item); startedEncounter = started; } // Another visit found under the row says nothing of whether a held create landed, so that create stays held. return; } const org = wpOrganization.current as Organization | null; const practitionerId = currentUser?.fhir?.id; const practitionerName = currentUser?.fhir?.resourceType === 'Practitioner' ? formatFhirHumanName(currentUser.fhir.name) : undefined; const encounter: Encounter = { resourceType: 'Encounter', meta: { profile: [US_CORE_ENCOUNTER] }, status: 'in-progress', // §6 - the APPOINTMENT'S LOCATION decides this, so the record is right before anyone // joins. still stamps `virtual` server-side when a video session is created (the // backstop for a visit that becomes video without having been booked that way); this makes the // booked case correct from the start. Otherwise a video visit that is documented and finished // without anyone pressing "start video" stays recorded as an in-person visit forever. // Ambulatory remains the default for walk-ins and for every practice not doing telehealth. class: encounterClassForAppointment(item.appointment, onlyVirtual(locationsList)[0]?.id), type: DEFAULT_ENCOUNTER_TYPE, subject: { reference: `Patient/${item.patientId}`, ...(item.patientName && { display: item.patientName }) }, period: { start: new Date().toISOString() }, ...(item.appointment?.id && { appointment: [{ reference: `Appointment/${item.appointment.id}` }] }), ...(org?.id && { serviceProvider: { reference: `Organization/${org.id}`, ...(org.name && { display: org.name }) } }), ...(practitionerId && { participant: [{ individual: { reference: `Practitioner/${practitionerId}`, ...(practitionerName && { display: practitionerName }) } }] }), ...(item.appointment?.reasonCode?.length ? { reasonCode: item.appointment.reasonCode } : {}), }; // A new visit is created only under an operation this tab holds for the signed-in account, so a resend after a lost // answer or a reload is the same visit; it is let go only when that create is confirmed. const account = currentUser?.cognito?.sub; const startScope: VisitStartScope | undefined = account ? { viewerId: account, practiceId: org?.id ?? '', patientId: item.patientId, rowKey: item.key } : undefined; if (!startScope) { if (operation.belongs()) window.alert(VISIT_START_NOT_KEPT); return; } let custody: VisitStartCustody; try { const runtimeScope = await operation.step(() => readVisitStartScope(item.patientId, operation.transport)); operation.admit(); custody = visitStartOperation(startScope, runtimeScope); } catch (error) { if (!isSaveRetired(error) && operation.belongs()) window.alert(VISIT_START_SCOPE_UNCONFIRMED); return; } if (custody.state !== 'held') { if (operation.belongs()) window.alert(custody.reason === 'unreadable' ? VISIT_START_UNREADABLE : custody.reason === 'not-kept' ? VISIT_START_NOT_KEPT : VISIT_START_SCOPE_UNCONFIRMED); return; } if (!mayCreateFor(item)) return; creating = true; const created = await operation.step(() => createEncounter(encounter, operation.transport, custody.operationId, custody.runtimeScope), true); operation.admit(); unconfirmedStarts.current.delete(item.key); settleVisitStartOperation(startScope, custody.operationId, custody.runtimeScope); enterEncounter(created, item); startedEncounter = created; } catch (err) { if (creating && (outcomeUnknown(err) || isSaveRetired(err))) unconfirmedStarts.current.add(item.key); if (isSaveRetired(err) || !operation.belongs()) return; if (err instanceof VisitStartScopeUnconfirmedError) { window.alert(VISIT_START_SCOPE_UNCONFIRMED); return; } console.error('Failed to start encounter', err); if (outcomeUnknown(err)) { window.alert(TIMED_OUT_MAY_HAVE_SAVED); return; } if (isRecordChanged(err)) { window.alert(VISIT_CHANGED_MESSAGE); return; } // / auth-consent Slice A. The overwhelmingly common cause of this failure is that the // patient has not completed the org's HIPAA authorization - `POST /R4/Encounter` carries // `requirePatientAccess`, and a merely-AFFILIATED org may read Encounters but not create one. // // We cannot read that off `err`: the authorizer throws, API Gateway generates a 403 with no // CORS headers, and fetch rejects with an opaque `TypeError: Failed to fetch` // (). So ask the authorization service instead. This runs ONLY // on the failure path - the happy path pays nothing - and a probe failure falls back to the // generic message rather than asserting a consent problem we could not verify. let authorizationStatus: OrgAuthorizationStatusValue | undefined; try { // THREE DOCUMENTS NOW EXIST AND THIS GATE STILL ASKS ABOUT ONE: the instrument named here is the HIPAA Authorization because that is the // one whose BEHAVIOUR the old welded "Authorization to Treat" actually had - an unsigned authorization // meant the practice could not see the record, which answers access, on an object whose name said // treatment. Naming it keeps this surface doing exactly what it did. Rebuilding these surfaces around // all three documents is the consent-flow work, not this change. ({ status: authorizationStatus } = await getStatus(item.patientId, undefined, RECORDS_INSTRUMENT)); } catch (statusErr) { console.error('Could not resolve org-authorization status while explaining the failure', statusErr); } if (operation.belongs()) window.alert(startEncounterFailureMessage(authorizationStatus)); } finally { if (operation.belongs()) { setBusyKey(undefined); onChanged?.(startedEncounter); } } }, [findExistingEncounter, enterEncounter, changeEncounter, wpOrganization, currentUser, createEncounter, onChanged, locationsList, getStatus, scope, view]); const isVideoVisit = useCallback((item: QueueItem): boolean => ( item.encounter ? encounterIsVirtual(item.encounter) : encounterIsVirtual({ class: encounterClassForAppointment(item.appointment, onlyVirtual(locationsList)[0]?.id) }) ), [locationsList]); // A video visit with no appointment has no booking to prove: its record is found or created as before. const prepareUnbooked = useCallback(async (item: QueueItem) => { const operation = scope.start(); setBusyKey(item.key); let prepared: Encounter | undefined; let creating = false; try { const existing = await operation.step(() => findExistingEncounter(item, operation.transport)); if (existing) unconfirmedStarts.current.delete(item.key); if (existing?.id) { enterEncounter(existing, item); prepared = existing; return; } const org = wpOrganization.current as Organization | null; const practitionerId = currentUser?.fhir?.id; const practitionerName = currentUser?.fhir?.resourceType === 'Practitioner' ? formatFhirHumanName(currentUser.fhir.name) : undefined; if (!mayCreateFor(item)) return; creating = true; const created = await operation.step(() => createEncounter({ resourceType: 'Encounter', meta: { profile: [US_CORE_ENCOUNTER] }, status: 'planned', class: VIRTUAL_ENCOUNTER_CLASS, type: DEFAULT_ENCOUNTER_TYPE, subject: { reference: `Patient/${item.patientId}`, ...(item.patientName && { display: item.patientName }) }, ...(item.appointment?.id && { appointment: [{ reference: `Appointment/${item.appointment.id}` }] }), ...(org?.id && { serviceProvider: { reference: `Organization/${org.id}`, ...(org.name && { display: org.name }) } }), ...(practitionerId && { participant: [{ individual: { reference: `Practitioner/${practitionerId}`, ...(practitionerName && { display: practitionerName }) } }] }), ...(item.appointment?.reasonCode?.length ? { reasonCode: item.appointment.reasonCode } : {}), }, operation.transport), true); unconfirmedStarts.current.delete(item.key); enterEncounter(created, item); prepared = created; } catch (err) { if (creating && (outcomeUnknown(err) || isSaveRetired(err))) unconfirmedStarts.current.add(item.key); if (isSaveRetired(err) || !operation.belongs()) return; console.error('Failed to prepare the video visit', err); window.alert(outcomeUnknown(err) ? TIMED_OUT_MAY_HAVE_SAVED : 'Could not open the video visit. Please try again.'); } finally { if (operation.belongs()) { setBusyKey(undefined); onChanged?.(prepared); } } }, [findExistingEncounter, enterEncounter, wpOrganization, currentUser, createEncounter, onChanged, scope]); const prepareAgain = useRef<(item: QueueItem, options?: { adopt?: boolean }) => Promise>(async () => undefined); // Opens a video visit's workspace without starting it: the server starts it once clinician and patient are both // present in the video. The server proves the record belongs to the booking that is open now, and the workspace opens // only with the record it returns: a row's cached record is never entered without that answer. Every other answer is // said and enters nothing; trying again, or using an earlier preparation for this booking, is the person's choice. const prepareVideo = useCallback(async (item: QueueItem, options?: { adopt?: boolean }) => { if (!item.appointment?.id) return prepareUnbooked(item); // The row's own record, and any an earlier answer named, are sent so the server reads them by id: a search that // misses one cannot let the visit be prepared beside it. The row's record is never entered without the answer. const visit = { patientId: item.patientId, appointmentId: item.appointment.id }; rememberVisitRecords(visit, [item.encounter?.id]); const operation = scope.start(); setBusyKey(item.key); let prepared: Encounter | undefined; let line: ReturnType | undefined; try { const org = wpOrganization.current as Organization | null; const practitionerId = currentUser?.fhir?.id; const practitionerName = currentUser?.fhir?.resourceType === 'Practitioner' ? formatFhirHumanName(currentUser.fhir.name) : undefined; const answer = await operation.step(() => prepareVisit({ resourceType: 'Encounter', meta: { profile: [US_CORE_ENCOUNTER] }, status: 'planned', class: VIRTUAL_ENCOUNTER_CLASS, type: DEFAULT_ENCOUNTER_TYPE, subject: { reference: `Patient/${item.patientId}`, ...(item.patientName && { display: item.patientName }) }, appointment: [{ reference: `Appointment/${item.appointment!.id}` }], ...(org?.id && { serviceProvider: { reference: `Organization/${org.id}`, ...(org.name && { display: org.name }) } }), ...(practitionerId && { participant: [{ individual: { reference: `Practitioner/${practitionerId}`, ...(practitionerName && { display: practitionerName }) } }] }), ...(item.appointment?.reasonCode?.length ? { reasonCode: item.appointment.reasonCode } : {}), }, { adoptUnproved: options?.adopt, knownRecords: knownVisitRecords(visit), transport: operation.transport }), true); if (answer.result === 'entered') { // The record the server proved is known now, as surely as one a refusal names: later saves of the visit name it. rememberVisitRecords(visit, [answer.encounter.id]); enterEncounter(answer.encounter, item); prepared = answer.encounter; return; } if (answer.result === 'generation-unproved' && answer.record) rememberVisitRecords(visit, [answer.record]); line = prepareVisitLine(answer); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; console.error('Failed to prepare the video visit', err); // Only a refusal the server gave proves nothing was written; anything else may have been. const refused = err instanceof HttpError && err.status >= 400 && err.status < 500; line = refused ? { message: 'Could not open the video visit. Please try again.' } : prepareVisitLine({ result: 'write-unknown' }); } finally { if (operation.belongs()) { setBusyKey(undefined); onChanged?.(prepared); } } if (!line || !operation.belongs()) return; if (!line.next) { window.alert(line.message); return; } if (window.confirm(line.message)) await prepareAgain.current(item, { adopt: line.next === 'adopt' }); }, [prepareUnbooked, enterEncounter, wpOrganization, currentUser, prepareVisit, onChanged, scope]); useEffect(() => { prepareAgain.current = prepareVideo; }, [prepareVideo]); const resume = useCallback((item: QueueItem) => { if (item.encounter?.id) enterEncounter(item.encounter, item); }, [enterEncounter]); // The caller's dialog collects `reopenReason`, and the server refuses a reopen without one. It rides // the `x-reopen-reason` header rather than the resource because it explains why the record changed; // it lands on the AuditEvent. const reopen = useCallback(async (item: QueueItem, reopenReason?: string) => { const id = item.encounter?.id; if (!id) return; const operation = scope.start(); setBusyKey(item.key); let reopenedEncounter: Encounter | undefined; try { // Reopen a finished encounter: back to in-progress, drop period.end. Fires a fresh care-view // anchor (a resumed visit is a new active period) - intentional. History-safe status update. const reopened = await operation.step(() => changeEncounter(id, moveEncounter( ['finished'], 'in-progress', (current) => ({ period: { start: current.period?.start ?? new Date().toISOString() } }), // new period object → no `end` ), { reopenReason, transport: operation.transport }), true); enterEncounter(reopened, item); reopenedEncounter = 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()) { setBusyKey(undefined); onChanged?.(reopenedEncounter); } } }, [changeEncounter, enterEncounter, onChanged, scope]); // Open the chart: encounter mode if an encounter exists, otherwise the plain patient chart. const openChart = useCallback((item: QueueItem) => { if (item.encounter?.id) { enterEncounter(item.encounter, item); return; } navigate(`/dashboard/patients/${item.patientId}`); }, [enterEncounter, navigate]); // A status-only decision, made on the visit as stored rather than the row's copy (see EncounterChange). // A Finish asks to hear when its appointment is left to the server; only its own operation may say so. const decideAs = useCallback(async (operation: SaveOperation, item: QueueItem, change: EncounterChange, finishing = false) => { const id = item.encounter?.id; if (!id || !operation.belongs()) return; setBusyKey(item.key); let writtenEncounter: Encounter | undefined; let settlementPending = false; try { writtenEncounter = await operation.step(() => changeEncounter(id, change, { transport: operation.transport, ...(finishing ? { onSettlementPending: () => { settlementPending = true; } } : {}), }), true); if (settlementPending && operation.belongs()) toast.info(SETTLEMENT_PENDING_MESSAGE); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; console.error('Failed to update the encounter', err); window.alert(isRecordChanged(err) ? VISIT_CHANGED_MESSAGE : 'Could not update the encounter. Please try again.'); } finally { if (operation.belongs()) { setBusyKey(undefined); onChanged?.(writtenEncounter); } } }, [changeEncounter, onChanged]); const decide = useCallback((item: QueueItem, change: EncounterChange) => decideAs(scope.start(), item, change), [decideAs, scope]); const setOnLeave = useCallback((item: QueueItem) => decide(item, moveEncounter(['in-progress'], 'onleave')), [decide]); /** * Return to a visit parked as `onleave`. This is an ENTER, not a bare status write, and it was the * one transition that skipped the park: implemented as `setStatus(item, 'in-progress')` it flipped the * status server-side and never called `enterEncounter`, so it never reached `handleEnter` `setActive` * `switchActiveEncounter`. Result, measured in a browser: **two encounters `in-progress`, both timers * ticking** - and the workspace never opened, so the control labelled "Bring back in" did not bring you * back in. Its siblings below (`finish`/`room`/`markReady`) really are status-only; this one is not. * * PROMOTE FIRST, THEN ENTER WITH THE RETURNED ENCOUNTER - the ordering is load-bearing. The active * slot stores the object it was entered with, and `planEncounterDemotion` parks only `in-progress`. * Enter with the stale `onleave` copy and the slot holds an un-parkable encounter, so the NEXT switch * away from it silently declines to park it - the same defect, one transition later. (Same stale-copy * hazard as `clearActive`'s, reached from the other direction.) * * `period` is deliberately untouched: a resumed visit is the same period, and an `onleave` encounter * has no `end` to drop - `reopen` rebuilds `period` precisely because a `finished` one does. */ const bringBackIn = useCallback(async (item: QueueItem) => { const id = item.encounter?.id; if (!id) return; const operation = scope.start(); setBusyKey(item.key); let resumedEncounter: Encounter | undefined; try { const resumed = await operation.step(() => changeEncounter(id, moveEncounter(['onleave'], 'in-progress'), { transport: operation.transport }), true); enterEncounter(resumed, item); resumedEncounter = resumed; } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; console.error('Failed to bring the encounter back in', err); // Do NOT enter on failure: entering would park the outgoing encounter while this one stayed // `onleave`, leaving the practitioner in a workspace for a visit the server still calls parked. window.alert(isRecordChanged(err) ? VISIT_CHANGED_MESSAGE : 'Could not update the encounter. Please try again.'); } finally { if (operation.belongs()) { setBusyKey(undefined); onChanged?.(resumedEncounter); } } }, [changeEncounter, enterEncounter, onChanged, scope]); // Finishing from the queue ends this encounter's meeting for everyone first, as Finish in the // workspace does; otherwise a visit left running keeps offering both sides a Rejoin into a closed // encounter. A live call asks first (the workspace's own confirmation), and a failed end leaves the // encounter unfinished. const [confirmingFinish, setConfirmingFinish] = useState(undefined); const [endingCall, setEndingCall] = useState(false); const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); const endThenFinish = useCallback(async (item: QueueItem, admission: FinishAdmission) => { const operation = scope.start(); const context = item.encounter?.id ? encounterContext(item.encounter.id) : undefined; if (video && context) { setEndingCall(true); try { await video.endCallIfActive(context); } catch (err) { if (!operation.belongs()) return; console.error('Failed to end the video visit before finishing', err); window.alert('Could not end the video visit, so the encounter was not finished. Please try again.'); return; } finally { if (operation.belongs()) { setEndingCall(false); setConfirmingFinish(undefined); } } } if (!admission.holds()) return; await decideAs(operation, item, moveEncounter( OPEN_ENCOUNTER_STATUSES, 'finished', (current) => ({ period: { ...current.period, end: new Date().toISOString() } }), ), true); }, [video, decideAs, scope]); // Held from before the call ends to after the visit is written; a recording anywhere for this visit refuses it. const endAndFinish = useCallback(async (item: QueueItem) => { const admission = admitFinish(recordingTarget(item.patientId, item.encounter?.id), () => mountedRef.current); if (isFinishRefusal(admission)) { setConfirmingFinish(undefined); const said = finishBlockedMessage(admission.blocked); if (said) window.alert(said); return; } try { await endThenFinish(item, admission); } finally { admission.release(); } }, [endThenFinish]); const finish = useCallback(async (item: QueueItem) => { // Refused before the live-call question: nothing is confirmed while a recording is undecided. const unsettled = unsettledRecording(recordingTarget(item.patientId, item.encounter?.id)); if (unsettled) { window.alert(finishBlockedMessage(unsettled)); return; } const context = item.encounter?.id ? encounterContext(item.encounter.id) : undefined; if (video && context && video.call.phase !== 'idle' && video.call.context === context) { setConfirmingFinish(item); return; } await endAndFinish(item); }, [video, endAndFinish]); const liveFinish = { open: !!confirmingFinish, finishing: endingCall, onOpenChange: (open: boolean) => { if (!open && !endingCall) setConfirmingFinish(undefined); }, onConfirm: () => { if (confirmingFinish) void endAndFinish(confirmingFinish); }, }; // Records the room and keeps the visit's status: rooming is not readiness, Mark ready is. const roomPatient = useCallback((item: QueueItem, room: { id: string; name?: string }) => decide(item, (current) => (ENDED_ENCOUNTER_STATUSES.includes(current.status) ? 'refused' : { ...current, location: [{ location: { reference: `Location/${room.id}`, ...(room.name && { display: room.name }) }, physicalType: roomPhysicalType(), status: 'active' }], })), [decide]); const markReady = useCallback((item: QueueItem) => decide(item, moveEncounter(['planned', 'arrived'], 'triaged')), [decide]); return { busyKey, start, prepareVideo, isVideoVisit, resume, reopen, openChart, setOnLeave, bringBackIn, finish, liveFinish, roomPatient, markReady }; };