import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { Encounter, Patient } from 'fhir/r4'; import { Activity, Pause, CheckCircle2, Clock, Plus, Video } from 'lucide-react'; import './EncounterWorkspace.css'; import PresenceDot from '../../../components/PresenceDot/PresenceDot'; import { Card } from '../../../components/ui/card'; import { usePatientSnapshot } from '../../../hooks/usePatientSnapshot'; import { EncounterChartPanel } from './EncounterChartPanel'; import { deriveSinceLastVisit } from './sinceLastVisit'; import { SinceLastVisit } from './SinceLastVisitCard'; import { EncounterDocumentation } from './EncounterDocumentation'; import { ConditionModal } from '../../../modals/ConditionModal'; import { LiveFinishConfirmDialog } from './LiveFinishConfirmDialog'; import { EncounterVideoPane } from '../../../components/video/EncounterVideoPane'; import { useVideoServiceContext } from '../../../services/VideoService'; import { useEncounterServiceContext } from '../../../services/EncounterService'; import { HttpError } from '../../../services/HttpService'; import { moveEncounter } from '../../../utils/encounterChange'; import { isSaveRetired, useSaveScope } from '../../../utils/saveOperation'; import { encounterContext } from '../../../services/video/videoContract'; import { registerEncounterFinisher } from '../../../components/video/encounterFinish'; import { admitFinish, finishBlockedMessage, isFinishRefusal, recordingTarget, unsettledRecording, useViewerEpoch, type FinishAdmission, type FinishRefusal, } from '../../../services/scribe/recordingDisposition'; import { encounterIsVirtual } from '../../../utils/encounterClass'; import { isVideoPrevisit } from '../../../utils/videoPrevisit'; import { useWebSocketServiceContext } from '../../../services/WebSocketService'; import { useActiveEncounterContext } from './ActiveEncounterContext'; import type { QueueItem } from './encounterLifecycle'; import { useReopenReason } from './askReopenReason'; const ageSex = (patient?: Patient): string => { const sex = patient?.gender ? patient.gender[0].toUpperCase() : ''; if (!patient?.birthDate) return sex; const age = Math.floor((Date.now() - new Date(patient.birthDate).getTime()) / (365.25 * 864e5)); return [String(age), sex].filter(Boolean).join(' · '); }; interface Props { item: QueueItem; patient?: Patient; encounter: Encounter; /** Awaited: the visit stays held for this Finish until it resolves. */ onFinish: (admission?: FinishAdmission) => void | Promise; onSetOnLeave: () => void; /** - the reason is optional to the CALLER; the prompt below is what collects it. */ onReopen: (reopenReason?: string) => void; busy?: boolean; } const ENCOUNTER_UPDATED = 'com.akashichealthrecords.fhir.encounter.updated'; const WITHHELD_START: Record = { 'agreements-outstanding': "The visit has not started: this patient's agreements are not all in place.", 'agreements-unreadable': "The visit has not started: the patient's agreements could not be checked.", 'agreements-changed': "The visit has not started: the patient's agreements changed as it was starting.", 'basis-not-retained': 'The visit has not started on its own.', }; const SESSION_REREAD_MS = 15_000; const refusalOf = (error: unknown): string => { const said = error instanceof HttpError ? (error.body as { message?: unknown } | undefined)?.message : undefined; return typeof said === 'string' && said ? said : 'The visit could not be started. Try again in a moment.'; }; // The deliberate recheck for a held start: the clinician's own start of this visit, which the server admits afresh on // the agreements in force now. Nothing here starts the visit on a timer or on a read. Each press belongs to this // opening of this practice's visit and this viewer: once it closes or moves on, its answer or refusal is not shown. const HeldStartRecheck: React.FC<{ item: QueueItem; encounterId: string; practiceRef?: string; onRefused: () => void }> = ({ item, encounterId, practiceRef, onRefused, }) => { const { replaceActive } = useActiveEncounterContext(); const { changeEncounter } = useEncounterServiceContext(); const [busy, setBusy] = useState(false); const [refusal, setRefusal] = useState(); const scope = useSaveScope(`${practiceRef ?? ''}|${encounterId}`, () => { setBusy(false); setRefusal(undefined); }); const start = async () => { const operation = scope.start(); setBusy(true); setRefusal(undefined); try { const started = await operation.step(() => changeEncounter(encounterId, moveEncounter(['planned', 'arrived', 'triaged'], 'in-progress', (current) => ({ period: { ...current.period, start: current.period?.start ?? new Date().toISOString() } })), { transport: operation.transport }), true); if (!operation.belongs()) return; replaceActive({ item: { ...item, status: 'in-progress', encounter: started }, encounter: started }); } catch (error) { if (isSaveRetired(error) || !operation.belongs()) return; setRefusal(refusalOf(error)); onRefused(); } finally { if (operation.belongs()) setBusy(false); } }; return (
{refusal && {refusal}}
); }; // The server starts a video visit once both people are in the video; this unlocks the workspace in place. // Mounted only for a previsit, so an ordinary workspace needs no socket. A start the server held back is said // here, read from the participants' session record; reading it never starts the visit. const PrevisitStartWatch: React.FC<{ item: QueueItem; encounterId: string; practiceRef?: string }> = ({ item, encounterId, practiceRef }) => { const { replaceActive } = useActiveEncounterContext(); const { subscribe, unsubscribe } = useWebSocketServiceContext(); const { call: videoCall, startWithheld, refreshSession } = useVideoServiceContext(); const inThisCall = videoCall.phase !== 'idle' && videoCall.context === encounterContext(encounterId); useEffect(() => { if (!inThisCall) return; refreshSession(); const timer = window.setInterval(refreshSession, SESSION_REREAD_MS); return () => window.clearInterval(timer); }, [inThisCall, refreshSession]); useEffect(() => { const onUpdated = (event: { data?: Encounter }) => { const next = event?.data; if (next?.id !== encounterId || next.status !== 'in-progress') return; replaceActive({ item: { ...item, status: 'in-progress', encounter: next }, encounter: next }); }; subscribe(ENCOUNTER_UPDATED, onUpdated); return () => unsubscribe(ENCOUNTER_UPDATED, onUpdated); }, [encounterId, item, replaceActive, subscribe, unsubscribe]); if (!inThisCall || !startWithheld) return null; return (

{WITHHELD_START[startWithheld.code] ?? 'The visit has not started yet.'}

); }; export const EncounterWorkspace: React.FC = ({ item, patient, encounter, onFinish, onSetOnLeave, onReopen, busy }) => { const { notesLocation, setNotesLocation } = useActiveEncounterContext(); const previsit = isVideoPrevisit(encounter); // Read the record again whenever the visit itself changes (finish, reopen, on-leave), or the snapshot // goes on showing the visit as it stood when the panel opened. const asOf = useMemo(() => new Date().toISOString(), [item.patientId, item.status, item.encounter?.meta?.versionId]); // The visit as this page last wrote it, which the record's history can trail by a minute or more. const knownVisit = useMemo(() => (item.encounter ? [item.encounter] : undefined), [item.encounter]); // `running` WAS DROPPED HERE and the sibling `EncounterFocusPanel` takes it - so on the workspace // the card never showed "Checking what's changed..." at all and painted a settled answer for the whole // load. Two mounts of one card, one of them silently in the wrong state. const { results, running } = usePatientSnapshot(patient, patient ? asOf : undefined, knownVisit); const since = useMemo(() => deriveSinceLastVisit(results), [results]); const [diagnosisOpen, setDiagnosisOpen] = useState(false); // The dialog lives in `askReopenReason` and is shared with the queue's focus panel, which carries a // button with the identical label and used to reopen without asking anything at all. Dismissing it // never calls `onReopen`, so a cancel writes nothing. const { ask: promptThenReopen, dialog: reopenReasonDialog } = useReopenReason(onReopen); // SPEC §4.1 - THE VIDEO CALL ENDS BEFORE THE ENCOUNTER IS FINISHED. The server // trips the CARE-VIEW ANCHOR on the status flip, so finishing first would anchor an encounter whose // video is still running. That anchor IS the evidentiary record, which is why the ORDER is the // requirement and not a tidiness preference. const { endCallIfActive, call } = useVideoServiceContext(); // Anchored on `idle` rather than a list of live phases, mirroring `EncounterVideoPane`: a phase added // later then fails SAFE - it keeps the in-call affordances rather than silently hiding them. const callIsThisEncounter = call.phase !== 'idle' && call.context === encounterContext(encounter.id ?? ''); const [finishing, setFinishing] = useState(false); const [finishRefused, setFinishRefused] = useState(null); // Each press belongs to its opening: another visit, viewer or practice, or leaving, ends it, and its pending // Finish no longer greys out the button for the opening that follows. const viewerEpoch = useViewerEpoch(); const openingRef = useRef(0); useLayoutEffect(() => { openingRef.current += 1; setFinishing(false); return () => { openingRef.current += 1; }; }, [item.patientId, encounter.id, viewerEpoch]); /* - FINISHING WITH A CALL LIVE ASKS FIRST, AND ONLY THEN. Spencer, 2026-08-14: *"What we can't do is have a practitioner's browser crash or accidentally end the meeting for some reason and then everyone gets kicked and the meeting is over prematurely mid-visit."* The 10-minute grace window covers the CRASH half - a dropped room stays rejoinable. It cannot cover this half: `/end` is an explicit destroy, so an accidental click is instant and final for everybody in the visit. "Finish encounter" is the ONLY route to `/end` in the product (`CallControls`'s red button is Leave, which is `/leave`), so this is the one place the confirmation belongs. IT ASKS ONLY WHEN A CALL IS ACTUALLY RUNNING. An in-person encounter finishes on one click, exactly as before - adding a confirmation to the overwhelmingly common case is how a practice learns to click through confirmations, which would cost more than the misclick it prevents. */ const [confirmFinishOpen, setConfirmFinishOpen] = useState(false); const requestFinish = () => { // Refused before the live-call question too: there is nothing to confirm while the recording is undecided. const unsettled = unsettledRecording(recordingTarget(item.patientId, encounter.id)); if (unsettled) { setFinishRefused(finishBlockedMessage(unsettled)); return; } if (callIsThisEncounter) { setConfirmFinishOpen(true); return; } void handleFinish(); }; const handleFinish = async (): Promise => { setConfirmFinishOpen(false); // Checked and held before anything ends: the call, the note and the visit stay as they are on a refusal. const opening = openingRef.current; const admission = admitFinish(recordingTarget(item.patientId, encounter.id), () => openingRef.current === opening); if (isFinishRefusal(admission)) { const said = finishBlockedMessage(admission.blocked); if (said) setFinishRefused(said); return admission; } setFinishRefused(null); try { return await endThenFinish(admission); } finally { admission.release(); } }; const endThenFinish = async (admission: FinishAdmission): Promise => { setFinishing(true); try { // A no-op when no call is running, which is the overwhelmingly common case - an in-person // encounter must never be blocked by a video system it never used. // The CONTEXT matters: a practitioner who left the visit before finishing is idle, and // without it the meeting they left stays live with the patient in it. await endCallIfActive(encounterContext(encounter.id ?? '')); } catch { // FAIL CLOSED. A rejected teardown means the meeting may STILL BE LIVE, so the encounter must // not flip to `finished` - that flip is the anchor. The practitioner is not left guessing: the // failure lands on `call.error`, which outlives every video surface, and `CallOverlay` reports // it. Finish re-enables below, so this is a retry, not a dead end. return undefined; } finally { // A press whose opening ended leaves the button to the opening now on screen. if (admission.holds()) setFinishing(false); } // Ended for another visit, viewer or practice while the call was ending: nothing more is started for this one. if (!admission.holds()) return undefined; await onFinish(admission); return undefined; }; // Derive from the authoritative encounter (item.status is a stale queue snapshot - e.g. after a // queue-reopen or a scheduled->in-progress start it lags the real Encounter.status). const onLeave = encounter.status === 'onleave'; const finished = encounter.status === 'finished'; // Board 15: on a phone the call covers this page, so its host finishes from inside the call - through // this same finish, so the call still ends before the encounter flips. const finishRef = useRef(handleFinish); finishRef.current = handleFinish; // A waiting room has nothing to finish, so the call offers no End visit until the visit starts. useEffect(() => { if (!callIsThisEncounter || finished || previsit) return undefined; return registerEncounterFinisher(encounterContext(encounter.id ?? ''), () => finishRef.current()); }, [callIsThisEncounter, finished, previsit, encounter.id]); // A visit opened from the record or the open-visits list arrives with no reason on its item; the Encounter has it. const reason = item.reason ?? encounter.reasonCode?.[0]?.text; return ( {previsit && encounter.id && } {/* header + lifecycle */}
{previsit ? Waiting room : finished ? Finished : {onLeave ? <>On leave : <>In progress} } {/* A video visit says so before the call, as the deck's "Before the call" draws it. */} {encounterIsVirtual(encounter) && ( )}

{item.patientName ?? patient?.name?.[0]?.text ?? 'Patient'}

{ageSex(patient)}
{reason &&

Reason{reason}

}
{previsit ? null : finished ? : <> {/* Awaiting the teardown opens a window the old synchronous handler did not have — a second click would fire a second `/end` at the same meeting and finish twice. */} }
{finishRefused && (
{finishRefused} {notesLocation === 'meeting' && ' The recording is in the video meeting window.'} {notesLocation === 'floating' && ' The recording is in the floating notes window.'}
)}
{/* left: documentation */}
{previsit && (
This visit starts when you and the patient are both in the video. Notes, diagnoses and Finish open then.
)} {!previsit && notesLocation !== 'docked' && (
{/* 🔴 NAME THE RIGHT WINDOW. There are TWO places the note can be, and until card 128 sub-task 5a there was only one — so this line said "a floating window" unconditionally and became FACTUALLY WRONG the day the meeting window could hold the note. A placeholder that points at the wrong window is worse than a vague one: the practitioner goes looking for a pane that is not open. Written as a switch on the location rather than generic copy, because "it's somewhere else" does not help someone find it. */}

{notesLocation === 'meeting' ? 'Notes are open in the video meeting window.' : 'Notes are open in a floating window.'}

)} {/* 🚨 CARD 263 E6 — "Pop out notes" is a WINDOW UTILITY, not an action, and it used to be a full solid-blue button in its own row: 4,559px² of the same fill as "Finish encounter", sitting 147px above and 283px right of the card it controls (card 261, B64). It is now an icon in that card's own header, where what it acts on is unambiguous. ⭐ That also deletes one of B63's seven identical blues for free. */} {!previsit && notesLocation === 'docked' && ( setNotesLocation('floating')} /> )} {/* Tonal, not solid: E6 demotes everything that is not "Finish encounter". Same role, lower priority — ⛔ NOT ghost, which reads as optional. */} {!previsit && ( )} {/* THERE IS DELIBERATELY NO "ADD TO THE RECORD" CONTROL HERE, AND IT IS NOT AN OVERSIGHT. A hand-built menu of three things - diet, glasses, risk - stood here, each opening a bespoke form of one or two invented fields. It was built ahead of the design and removed once the design existed. ⇒ WHAT REPLACES IT IS NOT A BUTTON. Adding to the record is NOTE-LED: saving the note proposes the items it described, each opening the real form condensed - what the note gave you, then what is still required, then the rest - and an Epic-style search is the fallback for anything the note did not cover. It fires on save rather than from a control somebody has to know to press, which is the whole reason a menu was wrong. ⛔ So do not re-add a button here. If the encounter needs an on-demand door before the note is saved, it belongs to that flow and shares its overlay, not a second one. */}
{/* right: patient picture */}
{/* 🚨 E5 (card 263) — VIDEO IS NOT PART OF AN IN-PERSON VISIT'S SCREEN. 📏 Measured on the live encounter: `class: AMB`, no location, no mention of telehealth anywhere in it — and "Start video" was still the largest control on the page (404px, full-width, 2.4× the painted area of anything else). For a clinician seeing patients in the room all day, the most prominent button on every encounter was the one they will never press. 🔑 Telehealth is a LOCATION ⇒ [[project-booking-config-chain]], and the Encounter's own class already carries that answer — stamped at creation from the appointment's virtual Location, and again server-side by card 078 when a video session starts. ⚠️ OR A LIVE CALL, and that half is load-bearing: this pane also carries "this visit is running in the meeting window" and the care-team INVITE, which needs a patientId that exists nowhere else. Hiding it on a call that became video by the server-side path would strand a participant inside a meeting with no invite control — the same rule the ship gate already states in this file: gate the ENTRY POINT, never an in-call affordance. */} {(encounterIsVirtual(encounter) || callIsThisEncounter) && ( )} {/* The deck's patient reference: one heading over what changed and the patient's problems. */}

Patient reference

{/* 📐 CARD 772 — what replaced `Chart snapshot`, and why the heading changed with it. 📏 Measured on a real visit: the six FHIR groups rendered as one line — "Nothing recorded currently in: Clinical · Diagnostics · Medications · Device · Care · Records." Honest, and the whole problem: it answers *which resource types hold something*, which is not a question anyone in a room is asking. ⇒ The problems the patient actually has, and touching one opens everything the record holds about it, across every practice — the SAME About lens the patient's own record renders, never a second clinician-side summary. */}
{diagnosisOpen && ( setDiagnosisOpen(false)} title="Add diagnosis" onSuccess={() => setDiagnosisOpen(false)} /> )} {/* 🚨 CARD 307 — the confirmation that stands between a misclick and ending a live visit for everyone in it. Spencer's "accidentally end the meeting" case, which the grace window cannot reach because `/end` is an explicit destroy rather than a room emptying. */} {reopenReasonDialog} void handleFinish()} />
); };