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;
}) => (
);
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.
)}
}
heading={awaitingReplyHeading(owedCount(patientsAwaitingReply), 'patient')}
onOpen={() => onOpenAllMessages('patients')}
trailing={moreLink(owedCount(patientsAwaitingReply), visiblePatients.length,
() => onOpenAllMessages('patients'), 'Open Messages on the Patients tab')}
>
{quietLine(owedCount(patientsAwaitingReply), 'No patient is waiting on a reply')}
{visiblePatients.map((item) => (
))}
}
heading={awaitingReplyHeading(owedCount(colleaguesAwaitingReply), 'colleague')}
onOpen={() => onOpenAllMessages('colleagues')}
trailing={moreLink(owedCount(colleaguesAwaitingReply), visibleColleagues.length,
() => onOpenAllMessages('colleagues'), 'Open Messages on the Colleagues tab')}
>
{quietLine(owedCount(colleaguesAwaitingReply), 'No colleague is waiting on a reply')}
{visibleColleagues.map((item) => (
))}
{/* Drawn at zero like the message sections; rows name the submitter and the form they sent. */}
}
heading={newFormsHeading(formsCount)}
onOpen={formsUnavailable ? undefined : onOpenFormSubmissions}
trailing={formsUnavailable ? undefined : moreLink(formsCount, visibleForms.length, onOpenFormSubmissions, 'Open Form submissions')}
>
{newFormsStatus === 'loading' && (
Loading new forms...
)}
{newFormsStatus === 'unavailable' && (
Unavailable in this demo
)}
{newFormsStatus === 'failed' && (
Could not load new form submissions.{' '}
)}
{quietLine(formsCount, 'No new form submissions')}
{visibleForms.map((item) => (
))}
}
heading="On today"
count={onToday.length}
countFirst
onOpen={onOpenScheduleToday}
trailing={
<>
{/* The filter rides the section label — it is a LENS on the list, not an action. Its options
come from the rota, so it can never offer one that returns nothing. */}
{/* The roster already lists everyone, so its link is a door to the schedule, not a count. */}
>
}
>
{staffError && {staffError}}
{loadingStaff && (
Loading today's rota...
)}
{!loadingStaff && onToday.length === 0 && (
)}
{onToday.map((item) => (
// CLICKING A PERSON OPENS THE QUICK-MESSAGE COMPOSER - settled 2026-07-14: "an in-overview
// quick-message modal + a 'View full conversation' deep-link; do NOT navigate away to send."
// That doc specified a message ICON; the whole row opens it instead, matching the message
// rows above - one interaction vocabulary for the whole rail.
onOpenPerson(item)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpenPerson(item);
}
}}
>
{/* Presence answers "message them, or walk over?". ⛔ It never GATES messaging, which is
asynchronous either way. */}
{item.practitionerId && (
)}
{item.name}{item.hours}