import type { ReactNode } from 'react'; import { CalendarDays, Inbox, MessageCircleWarning, MessageSquareWarning } from 'lucide-react'; import { EmptyState } from '../../../components/ui/empty-state'; import { Spinner } from '../../../components/ui/spinner'; import { DismissibleAlert } from '../../../components/ui/dismissible-alert'; import PresenceDot from '../../../components/PresenceDot/PresenceDot'; import { awaitingReplyHeading, newFormsHeading, type RoleFilterOption } from './frontDeskRail'; import type { WorkScheduleItem } from './ReceptionistHomePage'; import type { NewFormSubmissionsStatus } from '../../../services/FormSubmissionNotificationService'; /** * - the rail: what is OWED, and who is here to do it. * * THE MESSAGE SECTIONS LIST THREADS AWAITING A REPLY, NOT UNREAD ONES (Spencer, 2026-08-13). * Membership is operational, weight is personal: a thread is here because the practice owes an answer, * and bold because *you* have not read it. A colleague's already-answered thread leaves your rail even if * you never opened it; a thread you read but did not answer stays. * * UNREAD IS WEIGHT, NEVER A RAIL. It was briefly a blue left rail - but a left rail is the queues' * device for PATIENT STATE (green waiting - red late - blue checkout), so the same mark meant two * unrelated things a few inches apart. One device, one meaning, per screen. * * AND NO REPLY BUTTON. The whole row opens the conversation; a button beside a row that is already * clickable is a second door to the same place, and it adds exactly the noise the right-hand rails were * cleaned up to remove. The affordance is the hover. */ /** Rail sections cap and scroll too - a rail that grows is a page that scrolls. */ const RAIL_CAP = 'max-h-[calc(3*44px+2*3px)]'; /** Rows a summary section draws before the rest are counted as "+N more". */ const RAIL_VISIBLE_ROWS = 3; /** * A rail section - gave every one of them a glyph, a destination and a heading that outweighs * its own rows. * * THE HEADING WAS SMALLER AND LIGHTER THAN ITS CONTENT, which is why the three sections read as one * continuous list. Measured: a 10.5px `--ink-muted` label sitting directly above 12px `--ink-strong` * names. 11.5px, `--ink-strong`, 0.07em. The COUNT stays muted - it is a detail of the heading, not * a second heading. * * COLOUR WAS THE OTHER CANDIDATE AND WAS REJECTED ON EVIDENCE. Red, amber and green already mean * *late / warning / ready* on this same screen, so a hue per section would put colour to a second, * unrelated job on a surface where it is load-bearing for status. Do not reintroduce it. */ const Section = ({ glyph, heading, count, countFirst = false, onOpen, trailing, children }: { glyph: ReactNode; heading: string; /** Rendered muted, INSIDE the pressable area. `undefined` draws nothing. */ count?: ReactNode; /** The count leads the label ("10 on today"), as the deck draws the roster heading. */ countFirst?: boolean; /** Where this heading goes. Omit and the heading is inert text. */ onOpen?: () => void; trailing?: ReactNode; children?: ReactNode; }) => { // THE WHOLE HEADING IS THE TARGET - glyph, label and count - so the thing you read is the thing you // press. The `trailing` slot is deliberately OUTSIDE it: it holds the "+N more" link and the role // filter, and a lens on the list cannot also be the way off the screen. const label = ( <> {glyph} {countFirst && count !== undefined && {count}} {heading} {!countFirst && count !== undefined && {count}} ); return (
{onOpen ? ( ) : ( label )} {trailing}
{children !== undefined && (
*]:flex-none ${RAIL_CAP}`}> {children}
)}
); }; export interface RailMessage { id: string; name: string; reference?: string; message: string; age: string; unread: boolean; /** The message the row shows, for a dismissal that applies to exactly what was seen. */ latestMessageId?: string; } /** * DISMISS IS NOT A SECOND DOOR. It does not open or answer the thread; it takes it off MY owed list * (the same personal mark the thread's own Dismiss writes), so it is the one control besides the row. */ const MessageRow = ({ item, onOpen, onDismiss, dismissing = false }: { item: RailMessage; onOpen: (item: RailMessage) => void; onDismiss?: (item: RailMessage) => void; dismissing?: boolean; }) => (
onOpen(item)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onOpen(item); } }} >
{item.reference && } {item.name} {item.age} {onDismiss && ( )}
{item.message}
); export interface FrontDeskRailColumnProps { patientsAwaitingReply: RailMessage[]; colleaguesAwaitingReply: RailMessage[]; onOpenPatientThread: (item: RailMessage) => void; onOpenColleagueThread: (item: RailMessage) => void; onOpenAllMessages: (tab: 'patients' | 'colleagues') => void; /** Personal dismissal: takes the row off this person's owed list only. Omit and no Dismiss is drawn. */ onDismissPatientThread?: (item: RailMessage) => void; onDismissColleagueThread?: (item: RailMessage) => void; /** Whether that row's dismissal is still saving. */ isDismissing?: (id: string) => boolean; loadingMessages: boolean; messagesError?: string; /** * Public forms that have landed and are not yet linked to a patient - the same set the holding inbox * lists, counted once in FormSubmissionNotificationService rather than defined a second time here. */ newFormsSubmitted: number; /** The newest of those submissions, as rail rows. */ newFormSubmissions?: RailMessage[]; /** The count is drawn only when `ready`; otherwise the section says it does not know. */ newFormsStatus: NewFormSubmissionsStatus; onRetryNewForms: () => void; onOpenFormSubmissions: () => void; onToday: WorkScheduleItem[]; roleOptions: RoleFilterOption[]; roleFilter?: string; onRoleFilterChange: (role: string | undefined) => void; onOpenPerson: (item: WorkScheduleItem) => void; loadingStaff: boolean; staffError?: string; /** * Where the "On today" heading goes - the full schedule, on TODAY. * * CORRECTED WHILE BUILDING, and the correction is worth keeping. The card warned that * *"the Schedules screen holds a date, so a bare navigate can land the receptionist on last week"* and * asked for the date to be carried. **It does not hold one.** `SchedulesPage` keeps its range in local * `useState`, initialised to `'day'` with the title `'Today'`, and there is no URL parameter and no * storage anywhere in it - so it starts on today on every mount, and the rail is on a different route, * so it always mounts fresh. A plain navigate IS "defaulted to today". * If that page ever gains a remembered date, this is the call site that has to start carrying one. */ onOpenScheduleToday: () => void; } export const FrontDeskRailColumn = ({ patientsAwaitingReply, colleaguesAwaitingReply, newFormsSubmitted, newFormSubmissions = [], newFormsStatus, onRetryNewForms, onOpenFormSubmissions, onOpenPatientThread, onOpenColleagueThread, onOpenAllMessages, onDismissPatientThread, onDismissColleagueThread, isDismissing = () => false, loadingMessages, messagesError, onToday, roleOptions, roleFilter, onRoleFilterChange, onOpenPerson, loadingStaff, staffError, onOpenScheduleToday, }: FrontDeskRailColumnProps) => { // A ZERO IS A CLAIM, so it is only drawn once the message read has actually answered. Loading and // FAILED are both "not read yet" here: an unread list is not an empty one, and neither is a list whose // fetch fell over. The heading names the section without a number until it knows. const owedCount = (items: RailMessage[]) => (loadingMessages || messagesError ? undefined : items.length); // Counts what the section is not drawing; nothing is drawn when every row is visible or the total // is not known yet. The heading itself stays a door either way. const moreLink = (total: number | undefined, shown: number, onOpen: () => void, destination: string) => { const hidden = total === undefined ? 0 : total - shown; if (hidden <= 0) return undefined; return ( ); }; // The deck's quiet line, the same faint sentence the board's queues use. Only a read zero says it. const quietLine = (total: number | undefined, line: string) => total === 0 ?

{line}

: null; const visiblePatients = patientsAwaitingReply.slice(0, RAIL_VISIBLE_ROWS); const visibleColleagues = colleaguesAwaitingReply.slice(0, RAIL_VISIBLE_ROWS); const visibleForms = newFormSubmissions.slice(0, RAIL_VISIBLE_ROWS); const formsCount = newFormsStatus === 'ready' ? newFormsSubmitted : undefined; // Unavailable in a demo visit: nothing here opens the forms page the demo refuses. const formsUnavailable = newFormsStatus === 'unavailable'; return ( // A RULE BETWEEN SECTIONS, 13px either side, on the HEAVIER `--line` token deliberately: the rows // inside a section already use `--line-soft`, so a section boundary has to OUTRANK a row boundary // rather than match it. Space alone was tried and was not enough - 15px read as a slightly bigger // row gap, which is exactly the confusion it was meant to remove.
{messagesError && {messagesError}} {loadingMessages && (
Loading messages...
)} {/* Drawn at zero like the message sections; rows name the submitter and the form they sent. */}
); }; export default FrontDeskRailColumn;