import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import type { Attachment } from 'fhir/r4'; import FrontDeskQueueRow from './FrontDeskQueueRow'; import { allocateQueueRows, type FrontDeskQueues, type FrontDeskRow } from './frontDeskQueues'; /** The four queues in priority order, each capped and scrolling inside itself. */ /** Caps are counted in rows so they survive a change to the pitch. */ const ROW_HEIGHT_PX = 36; const ROW_GAP_PX = 0; // Heading block and inter-section divider heights; both mirror the page CSS. const LABEL_BLOCK_PX = 27; const SECTION_BOUNDARY_PX = 10; // An empty section's sentence, counted so quiet sections cannot grant rows the column lacks. const EMPTY_LINE_PX = 22; // The row height is a CSS variable so the taller stacked phone row gets whole rows, not half of one. // No rows granted leaves the section uncapped; calc would clamp it to 0. const capForRows = (rows: number) => rows < 1 ? undefined : `calc(${rows} * var(--ahr-fd-row-h, ${ROW_HEIGHT_PX}px) + ${(rows - 1) * ROW_GAP_PX}px)`; /** Rows that fit the height the grid gives the wrapper, not this column's content; undefined until measured. */ const useAvailableRows = (overheadPx: number) => { const wrapperRef = useRef(null); const [height, setHeight] = useState(); useLayoutEffect(() => { const node = wrapperRef.current; if (!node) return; setHeight(node.clientHeight); // jsdom and old browsers have no ResizeObserver; the baseline caps still render. if (typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver(([entry]) => { setHeight(entry.contentRect.height); }); observer.observe(node); return () => observer.disconnect(); }, []); useEffect(() => { if (wrapperRef.current) setHeight(wrapperRef.current.clientHeight); }, [overheadPx]); if (height === undefined || height <= 0) return { wrapperRef, rows: undefined }; return { wrapperRef, rows: Math.max(0, Math.floor((height - overheadPx + ROW_GAP_PX) / (ROW_HEIGHT_PX + ROW_GAP_PX))), }; }; interface SectionProps { label: string; /** Always the total, never the visible count - otherwise capping quietly hides work. */ count: ReactNode; capRows: number; rows: FrontDeskRow[]; now: number; onOpen: (row: FrontDeskRow) => void; photoFor?: (patientId: string) => Attachment | undefined; trailing?: ReactNode; /** What this section says when it is genuinely empty. */ emptyLine: string; /** Until the count is read the heading shows no count and no empty sentence. */ countsRead: boolean; /** The heading's colour, one per queue. */ tone: 'attention' | 'scheduled' | 'checked-in' | 'roomed'; } const Section = ({ label, count, capRows, rows, now, onOpen, photoFor, trailing, emptyLine, countsRead, tone, }: SectionProps) => { const empty = rows.length === 0; return (

{label}

{/* No count while the read is in flight: a zero before the data lands states something nobody checked. Rows on screen are already evidence the read happened. */} {(countsRead || !empty) && ( · {count} )} {trailing}
{/* The cap makes the zone scroll; flex-none keeps the rows from being compressed to fit it. */} {empty ? ( countsRead ? (

{emptyLine}

) : null ) : (
{rows.map((row) => ( ))}
)}
); }; export interface FrontDeskQueuesColumnProps { queues: FrontDeskQueues; now: number; onOpen: (row: FrontDeskRow) => void; onOpenFullDay: () => void; /** The patient's photograph for the row mark, when the board has read one. */ photoFor?: (patientId: string) => Attachment | undefined; /** Required, so a caller never states zeroes it did not read. */ countsRead: boolean; /** False for a practice that does not room patients: the Roomed band is not drawn. */ rooming?: boolean; } /** What each section shows before the column has been measured: the 1366x768 baseline. */ const BASELINE_CAPS = { needsYou: 3, waitingRoom: 3, inRooms: 3, stillToCome: 3 }; export const FrontDeskQueuesColumn = ({ queues, now, onOpen, onOpenFullDay, photoFor, countsRead, rooming = true, }: FrontDeskQueuesColumnProps) => { const demand = { needsYou: queues.needsYou.length, waitingRoom: queues.waitingRoom.length, inRooms: rooming ? queues.inRooms.length : 0, stillToCome: queues.stillToCome.length, }; const sections = rooming ? 4 : 3; // Every drawn section is budgeted even while loading, so the column does not reflow when counts land. const emptySections = Object.values(demand).filter((count) => count === 0).length - (rooming ? 0 : 1); const overheadPx = sections * LABEL_BLOCK_PX + (sections - 1) * SECTION_BOUNDARY_PX + emptySections * EMPTY_LINE_PX; const runningBehind = queues.runningBehind ? ( // The longest roomed-and-waiting wait: a fact about the band, not about one patient. {queues.runningBehind.room ? `${queues.runningBehind.room} · ${queues.runningBehind.waitingMinutes}m` : `${queues.runningBehind.waitingMinutes}m waiting to be seen`} ) : undefined; const { wrapperRef, rows } = useAvailableRows(overheadPx); const caps = rows === undefined ? BASELINE_CAPS : allocateQueueRows(demand, rows); return ( // min-h-0 + overflow-hidden make the measurement the height the column HAS, not the height it wants.
Full day → } />
{rooming &&
}
); }; export default FrontDeskQueuesColumn;