import React, { useMemo } from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import PresenceDot from '../../../components/PresenceDot/PresenceDot'; import type { ClinicalTier } from '../../../utils/roleTier'; import { statusToBucket, type BucketKey, type QueueItem } from './encounterLifecycle'; import { ModalityBadge } from './queueItemDisplay'; // / auth-consent Slice A. Both imported from the RECEPTIONIST page on purpose: they are // already generic (a status in, a badge out) and a second copy of "is this patient authorized?" is // exactly how the front-desk and clinician views would drift apart. Left in place rather than moved // so this change stays inside its own blast radius; a shared home is a later tidy-up, not this card. import NotAuthorizedBadge from '../ReceptionistHomePage/NotAuthorizedBadge'; import { useOrgAuthorizationStatusByPatient } from '../ReceptionistHomePage/useOrgAuthorizationStatusByPatient'; import { taskCountIndicator, type PatientTaskCount } from '../../../utils/patientTaskCount'; import { formatElapsedSince } from '../../../utils/formatElapsedSince'; import { resolveAppointmentPatientLabel, type PatientNameResolution, } from '../../../utils/appointmentParticipants'; /** * - `empty` IS PER BUCKET, and that is the whole point of this field. * * Found walking the practitioner role: **one hardcoded sentence, "Nobody here right now.", was * rendered for all seven buckets.** Two of them it is simply false for. **"Seen today"** is a question * about what has already HAPPENED, so answering it with a statement about who is present is not a * softer wording - it is the wrong fact. **"Later today"** asks what is still SCHEDULED, and nobody * scheduled for 4pm is "here" at 9am even when the day is full. * * The generic sentence read as fine on the one bucket it was written for ("Ready to see") and was * then reused into six places it was never checked against. * * Do NOT collapse these back to one string. Each answers the question its OWN heading asks. */ export interface QueueBucket { key: BucketKey; title: string; dot: string; collapsedByDefault?: boolean; /** Shown in place of the list. Must answer THIS bucket's heading, never presence in general. */ empty: string; } const PROVIDER_BUCKETS: QueueBucket[] = [ { key: 'in-progress', title: 'In progress', dot: 'bg-amber-500', empty: 'No visit is open right now.' }, { key: 'ready', title: 'Ready to see', dot: 'bg-emerald-500', empty: 'Nobody is waiting to be seen.' }, { key: 'later', title: 'Later today', dot: 'bg-faint', empty: 'Nothing else is scheduled today.' }, { key: 'seen', title: 'Seen today', dot: 'bg-faint', collapsedByDefault: true, empty: 'Nobody has been seen yet today.' }, ]; const SUPPORT_BUCKETS: QueueBucket[] = [ { key: 'to-room', title: 'To room', dot: 'bg-emerald-500', empty: 'Nobody is waiting to be roomed.' }, { key: 'roomed', title: 'Roomed', dot: 'bg-sky-500', empty: 'Nobody is in a room.' }, { key: 'with-provider', title: 'With provider', dot: 'bg-amber-500', empty: 'Nobody is with a provider right now.' }, { key: 'later', title: 'Later today', dot: 'bg-faint', empty: 'Nothing else is scheduled today.' }, ]; // A practice that does not room patients sees the same columns without the word room (no deck frame). const SUPPORT_BUCKETS_WITHOUT_ROOMS: QueueBucket[] = SUPPORT_BUCKETS.map((b) => b.key === 'to-room' ? { ...b, title: 'Waiting', empty: 'Nobody is waiting.' } : b.key === 'roomed' ? { ...b, title: 'Ready', empty: 'Nobody is ready yet.' } : b); export const queueBucketsFor = (tier: ClinicalTier, rooming = true): QueueBucket[] => (tier === 'support' ? (rooming ? SUPPORT_BUCKETS : SUPPORT_BUCKETS_WITHOUT_ROOMS) : PROVIDER_BUCKETS); /** The support mode's name. The deck names only the rooming one. */ export const supportModeLabel = (rooming: boolean): string => (rooming ? 'Rooming patients' : 'Preparing patients'); export const groupQueueByBucket = ( items: QueueItem[], buckets: QueueBucket[], tier: ClinicalTier, ): Record => { const map = buckets.reduce((acc, b) => { acc[b.key] = []; return acc; }, {} as Record); // Rooming staff have no Seen today: a provider's tally of finished work is not theirs (deck). for (const item of items) map[statusToBucket(item.status, tier, Boolean(item.room))]?.push(item); return map; }; export const fmtTime = (iso?: string): string => iso ? new Date(iso).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : ''; /** * Spencer, 2026-08-22: *"405m should show in hours."* A visit that started in the morning read `405m`, * because this rendered raw minutes with no hour rollover at all. * * It now uses the SHARED formatter rather than growing an hours branch here. That function's own note * is the argument: *"three spellings of that is three answers to one question"* - and this badge was the * fourth, sitting in a different folder from the other three. */ export const elapsedLabelFor = (iso?: string): string | undefined => { if (!iso) return undefined; const started = new Date(iso).getTime(); if (Number.isNaN(started)) return undefined; // A start in the FUTURE is not "0m in progress" - it is a record that makes no sense, and the badge // falls back to `active` rather than inventing a duration for it. return started > Date.now() ? undefined : formatElapsedSince(started); }; // A visit's state beside its row: the shared row state words, coloured, with no fill. A scheduled row has // none, because its time already leads the row. const RowState: React.FC<{ item: QueueItem }> = ({ item }) => { if (item.status === 'in-progress') { const elapsed = elapsedLabelFor(item.encounter?.period?.start); return {elapsed ?? 'active'}; } if (item.status === 'onleave') return on leave; if (item.status === 'finished') return done; return null; }; // The deck's task line: faint, the completed count in ink, and only the due-by-this-visit part in // warning amber (Spencer, 2026-09-25). Shared by the queue rows and the rooming day panel. export const QueueTaskLine: React.FC<{ count?: PatientTaskCount }> = ({ count }) => { const tasks = taskCountIndicator(count); if (!tasks) return null; return ( {tasks.completed !== undefined && tasks.total !== undefined ? ( <>{tasks.completed} of {tasks.total} tasks ) : tasks.label} {/* Tasks due by this visit, said apart from the total because they decide whether today's visit can go ahead. */} {tasks.dueHereLabel && ( <> · {tasks.dueHereLabel} )} ); }; interface RowsProps { list: QueueItem[]; empty: string; selectedKey?: string; onSelect: (item: QueueItem) => void; taskCountByRowKey: Record; patientNames?: PatientNameResolution; authorizationByPatientId: ReturnType; virtualLocationId?: string; } // One bucket's rows, shared by the expanded queue and the overview's count-card sheet. export const QueueBucketRows: React.FC = ({ list, empty, selectedKey, onSelect, taskCountByRowKey, patientNames, authorizationByPatientId, virtualLocationId, }) => ( list.length === 0 ?

{empty}

: (
    {list.map((item) => { const selected = item.key === selectedKey; return (
  • {/* The deck's patient row (.pt): time, then who, then what they owe. */}
  • ); })}
) ); interface Props { /** Whether this practice rooms patients; from the page, so the queue reads no service itself. */ rooming?: boolean; items: QueueItem[]; selectedKey?: string; onSelect: (item: QueueItem) => void; collapsed?: boolean; onExpand?: () => void; /** * Collapse the queue back to the rail. Supplied only while an encounter is open - otherwise there is * nothing to make room for. * * Expanding and collapsing this queue are VIEW actions ONLY (Spencer, 2026-08-05: *"expanding it is * just to actually see the queue, not an action to do anything"*). Neither may change an Encounter's * status, park a visit, or unmount the workspace. An earlier version parked the visit on expand, which * wrote two Encounter versions into an evidentiary record every time a provider glanced at their list. */ onCollapse?: () => void; tier?: ClinicalTier; /** * - the patient's task tally, keyed by the ROW. * * By the ROW and not the patient, because one patient can hold two rows on a board - a morning and * an afternoon visit - and "due by this visit" is a different number on each. * * PASSED IN, NOT FETCHED HERE, and that is a coupling decision rather than a style one. Calling the * readiness service inside this component would make every surface that mounts a queue need the auth * and http provider tree - the same defect R5 found when a closed dialog reached for * `useFhirLocations` and quietly gave its whole page a new dependency. The page owns the day, so the * page owns this read. * An absent entry is NOT a zero: the shared vocabulary renders nothing for a patient nobody has * answered for yet. */ taskCountByRowKey?: Record; /** * - WHO the visit is for, resolved from the reference rather than a denormalised display. * * THE DEFECT: this row printed `item.patientName`, which `buildQueue` fills from * `participant.actor.display` ALONE - so a correctly-formed appointment carrying no display read * **"Unknown patient"** while the DETAIL PANE immediately to its right, which resolves the reference, * read *"Jacob Miller - 49 - M"*. The two are on screen together: the surface contradicted itself, * and the row is the half a clinician scans. * Same defect the day board had (/337) and the same fix - the SHARED resolver, handed in * from the page because a pure component cannot call a hook. Never a local participant helper. */ patientNames?: PatientNameResolution; virtualLocationId?: string; } export const EncounterQueue: React.FC = ({ items, selectedKey, onSelect, collapsed, onExpand, onCollapse, tier = 'provider', taskCountByRowKey = {}, patientNames, rooming: roomsInUse = true, virtualLocationId }) => { const buckets = queueBucketsFor(tier, roomsInUse); // / Slice A - the clinician's queue had NO authorization awareness at all, so a patient who // could never be started looked identical to one who could. Called before the `collapsed` early // return below: hooks must run unconditionally, and the status is wanted the moment the queue // expands anyway. The hook dedups per patient, so a patient on two rows costs one read. const orgAuthorizationStatusByPatientId = useOrgAuthorizationStatusByPatient( useMemo(() => items.map((queueItem) => queueItem.patientId), [items]), ); const byBucket = useMemo(() => groupQueueByBucket(items, buckets, tier), [items, buckets, tier]); if (collapsed) { return ( ); } return (
{/* Only while a visit is open: give the workspace its width back. View-only — the visit stays open. */} {onCollapse && ( )} {buckets.map((bucket) => { const list = byBucket[bucket.key]; return (
{bucket.title} {list.length}
); })}
); };