import { DismissibleAlert } from '../../../components/ui/dismissible-alert'; import { Spinner } from "../../../components/ui/spinner"; import { isSearchTruncated } from '../../../utils/fetchAllPagesAsBundle'; import { noteCheckIn } from '../../../utils/recentCheckIns'; import { calendarDateInZone, fhirCalendarDate, viewerTimeZone } from '../../../utils/practiceTimezone'; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Appointment as FhirAppointment, Communication, CommunicationRequest, Patient, Practitioner, PractitionerRole, Schedule } from "fhir/r4"; import { useLocation, useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogDescription, DialogTitle, } from "../../../components/ui/dialog"; import ReceptionistBookAppointment from "./ReceptionistBookAppointment.tsx"; import { getScheduleAvailability, getScheduleLocationReference } from "../../../services/ScheduleService"; import ReceptionistAppointmentDetailsDialog from "./ReceptionistAppointmentDetailsDialog.tsx"; import ReceptionistPatientWorkspaceDialog, { mayOpenPatientInvoices } from "./ReceptionistPatientWorkspaceDialog.tsx"; import DeskPatientQuickView from "./DeskPatientQuickView.tsx"; import ReceptionistRegisterPatientDialog from "./ReceptionistRegisterPatientDialog.tsx"; import type { RegisterPatientPrefill } from "./ReceptionistRegisterPatientDialog.tsx"; import { getAppointmentPatientId, useReceptionistAppointmentSupport, useReceptionistPatientAppointments, } from "./useReceptionistAppointmentSupport.ts"; import { useConsentStatusesByPatient } from "./useConsentStatusesByPatient.ts"; import { owedAgreementsFlag } from "./owedAgreementsFlag.ts"; import { applyReceptionistAppointmentStatus, getReceptionistAppointmentStatus, checkStatusChange, STATUS_CHANGE_UNCONFIRMED, STATUS_CHANGE_UNREADABLE, STATUS_CHANGE_SUPERSEDED, noShowUnavailableReason, settleStatusChange, type StatusChangeOutcome, } from "./receptionistAppointmentStatus.ts"; import { HttpError } from "../../../services/HttpService.tsx"; import { treatmentAgreementRefusal } from "../../../utils/treatmentAgreementRefusal.ts"; import type { ReceptionistAppointmentStatus } from "./receptionistAppointmentStatus.ts"; import { LATE_LADDER_MINUTES, buildFrontDeskQueues, frontDeskBoardIsLoading, frontDeskBoardKey, frontDeskCountsAreRead, type FrontDeskRow, type TaskSummary, } from "./frontDeskQueues"; import { useFrontDeskDay } from "./useFrontDeskDay"; import { appointmentsNeedingEncounters, appointmentsTheSlotsMissed, frontDeskBoardAppointments, isOnFrontDeskBoard, lateToastAppointments, } from "./frontDeskDay"; import FrontDeskQueuesColumn from "./FrontDeskQueuesColumn"; import FrontDeskRailColumn from "./FrontDeskRailColumn"; import { readFrontDeskAction } from "../../../components/PortalFrameHeader/frontDeskHeaderAction"; import { useFormSubmissionNotificationContext } from "../../../services/FormSubmissionNotificationService"; import { buildFormSubmissionItems, formatRelativeAge, isSelf, isStaffSide, latestMessageIn, roleFilterOptions, threadNeedsMyReply } from "./frontDeskRail"; import { isDismissedBy } from "../../../utils/messageDismissal"; import { extractAppointmentFromBroadcast, mergeBroadcastAppointment, } from "./receptionistAppointmentBroadcast"; import { holdDelete, holdWrite, newestById, overlayHeld, sameViewerSince, startSearch } from "../../../utils/writeThroughHold"; import { useLifetime } from "../../../hooks/useLifetime"; import { useAppointmentServiceContext, useAuthServiceContext, useFhirServiceContext, usePatientServiceContext, useScheduleServiceContext, useStaffPermissionServiceContext, useSurfaceExpectedPatient, useWebSocketServiceContext, } from "../../../services"; import { useCommunicationManager, useFhirPractitionerRoleBroadcast, useFhirPractitioners, useFhirScheduleBroadcast, useFhirSchedules, useNeedsReply } from "../../../hooks"; import type { PractitionerWithRoles } from "../../../services"; import { formatFhirHumanName } from "../../../utils/fhirHumanName.tsx"; import { awaitsCheckout, withCheckoutDone } from "../../../utils/appointmentCheckout"; import { useAppointmentPatientNames } from "../../../hooks/useAppointmentPatientNames.ts"; import { appointmentDetailTime } from "../../../utils/appointmentWhen"; import { appointmentPatientId, resolveAppointmentPatientLabel, resolveAppointmentPractitionerLabel, type PatientNameResolution, } from "../../../utils/appointmentParticipants.ts"; import QuickMessageModal from "../../../components/QuickMessageModal/QuickMessageModal"; import "./ReceptionistHomePage.css"; import { isAppointmentPlacement } from '../../../utils/appointmentPlacement'; import { usePracticeRooming } from '../ClinicianOverviewPage/usePracticeRooming'; import { appointmentFollowUpLines, appointmentVersion, COMPANIONS_UNCONFIRMED } from '../../../utils/appointmentVersion'; import { readCheckoutDone } from '../../../utils/appointmentCheckout'; import { settleUnknownWrite, writeOutcomeUnknown } from '../../../utils/appointmentWriteOutcome'; import { isRecordChanged } from '../../../utils/recordChanged'; import { isSaveRetired, useSaveScope, type SaveOperation } from '../../../utils/saveOperation'; import { latestKnown } from '../../../hooks/useAppointmentEditBasis'; import { useReminderClearance } from '../../../components/Chat/useReminderClearance'; import { counterpartyRowName, NAME_LOADING, type NameRead } from '../../../utils/messaging/counterpartyRowName'; const APPOINTMENT_CHANGED_REFUSAL = 'Someone else changed this appointment, so nothing was saved. Look at it as it is now and choose again.'; type OverviewMessageItem = { id: string; name: string; reference?: string; message: string; age: string; unread: boolean; unreadCount: number; timestamp: number; /** The message this row shows; a dismissal applies to exactly this one. */ latestMessageId?: string; }; type NameReadFor = (id?: string) => NameRead | undefined; // A participant is named, said to be withheld or unread, or shown as loading - never as its reference. const getReferenceDisplay = (reference?: { display?: string; reference?: string }, readFor?: NameReadFor) => { const [resourceType, id] = reference?.reference?.split("/") ?? []; const name = counterpartyRowName({ display: reference?.display, id, resourceType, read: readFor?.(id) }); return name.kind === "pending" ? NAME_LOADING : name.text; }; const getCommunicationParticipants = (request: CommunicationRequest) => [request.sender, ...(request.recipient || [])].filter(Boolean); const isPatientOrRelatedPersonReference = (reference?: string) => reference?.startsWith("Patient/") || reference?.startsWith("RelatedPerson/"); const hasPatientOrRelatedPersonParticipant = (request: CommunicationRequest) => getCommunicationParticipants(request).some((participant) => isPatientOrRelatedPersonReference(participant?.reference), ); const isStaffOnlyCommunicationRequest = (request: CommunicationRequest) => { const participants = getCommunicationParticipants(request); if (participants.length < 2) { return false; } return participants.every( (participant) => !!participant?.reference && !isPatientOrRelatedPersonReference(participant.reference), ); }; export const getPatientDisplayFromCommunicationRequest = (request: CommunicationRequest, readFor?: NameReadFor) => { const participants = getCommunicationParticipants(request); const patientReference = participants.find((participant) => participant?.reference?.startsWith("Patient/"), ); if (patientReference) { return getReferenceDisplay(patientReference, readFor); } return getReferenceDisplay(request.sender || request.recipient?.[0], readFor); }; const getPatientReferenceFromCommunicationRequest = (request: CommunicationRequest) => getCommunicationParticipants(request).find((participant) => isPatientOrRelatedPersonReference(participant?.reference), )?.reference; const getCommunicationTimestamp = ( request: CommunicationRequest, messages: Communication[], ) => { const messageTimestamps = messages .map((message) => message.sent || message.meta?.lastUpdated) .filter((value): value is string => !!value) .map((value) => new Date(value).getTime()) .filter((value) => !Number.isNaN(value)); if (messageTimestamps.length > 0) { return Math.max(...messageTimestamps); } const fallback = request.authoredOn || request.meta?.lastUpdated; return fallback ? new Date(fallback).getTime() : 0; }; const getLatestCommunicationMessage = ( request: CommunicationRequest, messages: Communication[], ) => { const sortedMessages = [...messages].sort((a, b) => { const aTime = a.sent || a.meta?.lastUpdated || ""; const bTime = b.sent || b.meta?.lastUpdated || ""; return new Date(bTime).getTime() - new Date(aTime).getTime(); }); const latestMessage = sortedMessages[0]; const payloadText = latestMessage?.payload?.find((payload) => typeof payload.contentString === "string" && payload.contentString.trim().length > 0, )?.contentString; if (payloadText?.trim()) { return payloadText.trim(); } const requestNote = request.note?.[0]?.text?.trim(); if (requestNote) { return requestNote; } return "No message preview available."; }; const buildPatientMessageItems = ( requests: CommunicationRequest[], getMessagesForRequest: (request: CommunicationRequest) => Communication[], getUnreadCountForRequest: (threadOrRequestId: string) => number, isDismissedByMe: (message: Communication | undefined) => boolean, readFor?: NameReadFor, ): OverviewMessageItem[] => { return requests .filter(hasPatientOrRelatedPersonParticipant) // πŸ”’ THREADS AWAITING A REPLY, NOT UNREAD ONES (Spencer, 2026-08-13). Under the pooled front desk // "unread by me" is the wrong unit: receptionist A opens it, it clears from her board, and B never // learns it existed. β›” This must stay ABOVE the unread bookkeeping below β€” the unread count still // decides WEIGHT, and conflating the two is the defect. // // β›” AND MINUS MY OWN DISMISSALS, which this rail did not ask about at all. A receptionist pressed // Dismiss in the thread, the band went away, and this rail went on naming the same patient and the // same message through a reload β€” so the one number the control exists to move did not move, and // the screen said the action had worked. `threadNeedsMyReply` is the same question the // practitioner's owed-work list asks, of the same mark on the same message. // // 🟒 Still PERSONAL: the mark names who made it, so my colleague's rail is untouched. That is the // standing rule in one line β€” a dismissal is mine, a reply is everybody's. .filter((request) => threadNeedsMyReply(request, getMessagesForRequest(request), isStaffSide, isDismissedByMe), ) .map((request) => { const messages = getMessagesForRequest(request); const timestamp = getCommunicationTimestamp(request, messages); const unreadCount = request.id ? getUnreadCountForRequest(request.id) : 0; return { id: request.id || `${getPatientDisplayFromCommunicationRequest(request, readFor)}-${timestamp}`, name: getPatientDisplayFromCommunicationRequest(request, readFor), reference: getPatientReferenceFromCommunicationRequest(request), message: getLatestCommunicationMessage(request, messages), age: formatRelativeAge(timestamp), unread: unreadCount > 0, unreadCount, timestamp, latestMessageId: latestMessageIn(messages)?.id, }; }) .sort((a, b) => { if (a.unread !== b.unread) { return a.unread ? -1 : 1; } return b.timestamp - a.timestamp || a.name.localeCompare(b.name); }); }; export const getStaffDisplayFromCommunicationRequest = ( request: CommunicationRequest, currentStaffReference?: string, readFor?: NameReadFor, ) => { const participants = getCommunicationParticipants(request); const otherStaffReference = participants.find((participant) => participant?.reference && participant.reference !== currentStaffReference, ); return getReferenceDisplay(otherStaffReference || request.sender || request.recipient?.[0], readFor); }; const getStaffReferenceFromCommunicationRequest = ( request: CommunicationRequest, currentStaffReference?: string, ) => getCommunicationParticipants(request).find( (participant) => participant?.reference && participant.reference !== currentStaffReference, )?.reference; const buildStaffMessageItems = ( requests: CommunicationRequest[], getMessagesForRequest: (request: CommunicationRequest) => Communication[], getUnreadCountForRequest: (threadOrRequestId: string) => number, isDismissedByMe: (message: Communication | undefined) => boolean, currentStaffReference?: string, readFor?: NameReadFor, ): OverviewMessageItem[] => { return requests .filter(isStaffOnlyCommunicationRequest) // πŸ”’ Same rule, different "ours": a colleague thread is owed when THEY spoke last. // β›” Not "any staff member replied" β€” a direct thread between two colleagues is not pooled, and // treating a third person's reply as mine would clear a thread I am the one being asked about. // The dismissal is read the same way as on the patient rail beside it: the colleague panel offers // the same band, so it has to clear the same count. .filter((request) => threadNeedsMyReply( request, getMessagesForRequest(request), isSelf(currentStaffReference), isDismissedByMe, ), ) .map((request) => { const messages = getMessagesForRequest(request); const timestamp = getCommunicationTimestamp(request, messages); const unreadCount = request.id ? getUnreadCountForRequest(request.id) : 0; const name = getStaffDisplayFromCommunicationRequest(request, currentStaffReference, readFor); return { id: request.id || `${name}-${timestamp}`, name, reference: getStaffReferenceFromCommunicationRequest(request, currentStaffReference), message: getLatestCommunicationMessage(request, messages), age: formatRelativeAge(timestamp), unread: unreadCount > 0, unreadCount, timestamp, latestMessageId: latestMessageIn(messages)?.id, }; }) .sort((a, b) => { if (a.unread !== b.unread) { return a.unread ? -1 : 1; } return b.timestamp - a.timestamp || a.name.localeCompare(b.name); }); }; const UPCOMING_APPOINTMENT_DAYS = 30; // ONE ESCALATION LADDER FOR THIS SURFACE, and it now lives in `frontDeskQueues` beside the queues that // also read it. Do not re-declare these numbers here: the toast that chases a late patient and the row // that calls them late must agree on the minute, and two copies is how they stop agreeing. const LATE_WARNING_MINUTES = LATE_LADDER_MINUTES.warning; /** * (B25) - how many late toasts sonner will actually render at once. * * This mirrors sonner's own `visibleToasts` default rather than setting it. It is used ONLY to size the * band the board reserves at its bottom edge, so being wrong here costs a little unused space or a little * overlap - never a missing toast. It is not a cap on how many late appointments are tracked; the * dismiss bookkeeping above still walks every one. */ const SONNER_VISIBLE_TOASTS = 3; const APPOINTMENT_CLOCK_INTERVAL_MS = 30_000; // How far back to look for still-unresolved (overdue) appointments so the late reminder keeps // surfacing until a receptionist updates the status. The deferred server-side No-Show policy is // the backstop for anything older than this window. const OVERDUE_LOOKBACK_DAYS = 90; // Statuses that mean an overdue appointment hasn't been actioned/resolved yet - these keep // surfacing past their day. Actioned/resolved states (arrived/checked-in/fulfilled/cancelled/ // noshow) intentionally drop off. const OVERDUE_RETENTION_STATUSES = new Set([ "booked", "proposed", "pending", "waitlist", ]); const getLateAppointmentToastId = (appointmentId: string) => `receptionist-late-appointment-${appointmentId}`; const getLocalDayRange = (date = new Date()) => { const start = new Date(date); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(end.getDate() + 1); return { start, end, }; }; const getOverviewAppointmentRange = () => { const now = new Date(); const { start } = getLocalDayRange(now); const end = new Date(now); end.setDate(end.getDate() + UPCOMING_APPOINTMENT_DAYS); return { start: start.toISOString(), end: end.toISOString(), startTimestamp: start.getTime(), endTimestamp: end.getTime(), }; }; const getAppointmentStartTimestamp = (appointment: FhirAppointment) => { if (!appointment.start) return undefined; const timestamp = new Date(appointment.start).getTime(); return Number.isNaN(timestamp) ? undefined : timestamp; }; const isUpcomingAppointmentStatus = (status?: FhirAppointment["status"]) => status !== "cancelled" && status !== "fulfilled" && status !== "noshow" && status !== "entered-in-error"; const isTodayAttentionAppointmentStatus = (status?: FhirAppointment["status"]) => status !== "cancelled" && status !== "fulfilled" && status !== "entered-in-error"; type StatusFlow = { updating?: boolean; /** The status asked for and not saved, so the details can name it and offer it again. */ requested?: ReceptionistAppointmentStatus; error?: string; /** The server refused the change outright; shown in its words and never retried. */ refusal?: string; /** What the saved change left unfinished on the schedule. */ note?: string[]; }; // Call needs a read phone number; Mark no-show waits until the appointment's start allows it. const ReceptionistLateAppointmentToast = ({ appointment, patientNames, minutesLate, practitioner, onOpen, onMarkNoShow, onDismiss, }: { appointment: FhirAppointment; patientNames: PatientNameResolution; minutesLate: number; practitioner: string; onOpen: () => void; onMarkNoShow: () => void; onDismiss: () => void; }) => { // Resolved from the reference, so a record with no denormalised display still names its patient. const patientName = resolveAppointmentPatientLabel(appointment, patientNames); const patientId = appointmentPatientId(appointment); const phone = patientId ? patientNames.phones?.[patientId] : undefined; const at = appointmentDetailTime(appointment.start); const noShowAllowed = !noShowUnavailableReason(appointment); return (
{phone && ( Call )} {noShowAllowed && ( )}
); }; export type WorkScheduleItem = { id: string; scheduleId: string; schedule: Schedule; name: string; role: string; detail: string; /** The rail's two-line row, split rather than re-parsed out of `detail` - see the builder. */ hours: string; location: string; startSort: string; practitionerId: string; practitioner?: Practitioner; }; const dayCodes = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; const getTodayDayCode = () => dayCodes[new Date().getDay()]; const referenceIdFromString = (reference?: string) => { if (!reference) return undefined; const [, id] = reference.split("/"); return id || reference; }; // COMPARED AS CALENDAR DATES, NEVER AS INSTANTS, and that is the whole of it. A planning horizon is // authored as a FHIR `date` with no zone on it; `new Date("2026-08-29")` pins UTC midnight to it, and // a practice west of UTC then reads the horizon a day early at both ends. The visible cost here was a // schedule whose LAST day is today dropping off the board on the one morning it still matters. const isScheduleInTodayWindow = (schedule: Schedule) => { if (schedule.active === false) return false; const zone = viewerTimeZone(); const today = calendarDateInZone(new Date(), zone); const horizonStart = schedule.planningHorizon?.start ? fhirCalendarDate(schedule.planningHorizon.start, zone) : undefined; const horizonEnd = schedule.planningHorizon?.end ? fhirCalendarDate(schedule.planningHorizon.end, zone) : undefined; // Both ends INCLUSIVE: a block is live on its first day and on its last. if (horizonStart && horizonStart > today) return false; if (horizonEnd && horizonEnd < today) return false; return true; }; const getScheduleRoleReferenceId = (schedule: Schedule) => { const practitionerRoleRef = schedule.actor?.find((actor) => actor.reference?.startsWith("PractitionerRole/"), ); return referenceIdFromString(practitionerRoleRef?.reference); }; const findPractitionerForRole = ( roleId: string | undefined, practitionersList: PractitionerWithRoles[], ) => { if (!roleId) return undefined; return practitionersList.find((practitionerWithRoles) => practitionerWithRoles.roles.some((role) => role.id === roleId), ); }; const findPractitionerRoleById = ( roleId: string | undefined, practitionersList: PractitionerWithRoles[], ) => { if (!roleId) return undefined; return practitionersList .flatMap((practitionerWithRoles) => practitionerWithRoles.roles) .find((role) => role.id === roleId); }; const formatScheduleTime = (time?: string) => { if (!time) return ""; const [hours = 0, minutes = 0] = time.split(":").map(Number); const date = new Date(); date.setHours(hours, minutes, 0, 0); return date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true, }); }; const getRoleLabel = (role?: PractitionerRole) => { const label = role?.code?.[0]?.text || role?.code?.[0]?.coding?.[0]?.display || role?.code?.[0]?.coding?.[0]?.code || "Staff"; return label.endsWith("s") ? label.slice(0, -1) : label; }; const getRoleLocationLabel = (role?: PractitionerRole) => { return role?.location?.[0]?.display || "Location not set"; }; // Parse a not-available Period date. Date-only strings resolve to LOCAL midnight (matching the Schedules calendar), // so a full-day time-off correctly overlaps today. const parseTimeOffDate = (value?: string): Date | undefined => { if (!value) return undefined; if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { const [year, month, day] = value.split("-").map(Number); return new Date(year, month - 1, day); } const date = new Date(value); return Number.isNaN(date.getTime()) ? undefined : date; }; const buildWorkScheduleItems = ( schedulesList: Schedule[], practitionersList: PractitionerWithRoles[], ): WorkScheduleItem[] => { const todayDayCode = getTodayDayCode(); return schedulesList .filter(isScheduleInTodayWindow) .flatMap((schedule): WorkScheduleItem[] => { const roleId = getScheduleRoleReferenceId(schedule); const practitionerWithRole = findPractitionerForRole(roleId, practitionersList); const role = findPractitionerRoleById(roleId, practitionersList); if (!role || role.active === false) { return []; } // Availability + location now live on the Schedule; fall back to the role for schedules saved before the move. const scheduleAvailability = getScheduleAvailability(schedule); const availableTimeSource = scheduleAvailability.availableTime.length ? scheduleAvailability.availableTime : role.availableTime || []; const notAvailableSource = scheduleAvailability.notAvailable.length ? scheduleAvailability.notAvailable : role.notAvailable || []; // A not-available period (time off) covering today overrides the weekly schedule. const { start: todayStart, end: todayEnd } = getLocalDayRange(); // A DATE-ONLY END MEANS THROUGH THE END OF THAT DAY. `parseTimeOffDate` returns local // midnight, so comparing against it left the last day of leave showing the practitioner as // working on this board - the same defect the booking picker and the schedules calendar // carried, in the third reader of one set of dates. const blockedByTimeOff = notAvailableSource.some((notAvailable) => { const start = parseTimeOffDate(notAvailable.during?.start); const endValue = parseTimeOffDate(notAvailable.during?.end); const endIsWholeDay = /^\d{4}-\d{2}-\d{2}$/.test(notAvailable.during?.end ?? ''); const end = endValue && endIsWholeDay ? new Date(endValue.getFullYear(), endValue.getMonth(), endValue.getDate() + 1) : endValue; return start !== undefined && end !== undefined && todayStart < end && todayEnd > start; }); if (blockedByTimeOff) { return []; } const availableToday = availableTimeSource.filter( (availableTime: any) => availableTime.available !== false && availableTime.daysOfWeek?.includes(todayDayCode), ); if (availableToday.length === 0) { return []; } const name = formatFhirHumanName(practitionerWithRole?.practitioner.name, { emptyText: "Unknown Staff", }) || role.practitioner?.display || schedule.actor?.[0]?.display || "Unknown Staff"; return availableToday.map((availableTime, index) => { const timeLabel = availableTime.allDay ? "All day" : `${formatScheduleTime(availableTime.availableStartTime)} - ${formatScheduleTime(availableTime.availableEndTime)}`; const locationLabel = getScheduleLocationReference(schedule)?.display?.trim() || getRoleLocationLabel(role); return { id: `${schedule.id || role.id}-${index}`, scheduleId: schedule.id || "", schedule, name, role: getRoleLabel(role), detail: `${timeLabel} Β· ${locationLabel}`, // SPLIT OUT for the rail's two-line row (spec Β§5.2): name - hours on the first line, // primary role - location on the second. `detail` stays because the appointment-support // surfaces still read it; the rail reads these rather than re-splitting a joined string, which // would break the first time a location contained the separator. hours: timeLabel, location: locationLabel, startSort: availableTime.availableStartTime || "00:00:00", practitionerId: practitionerWithRole?.practitioner.id ?? "", practitioner: practitionerWithRole?.practitioner, }; }); }) .sort((a, b) => a.name.localeCompare(b.name) || a.startSort.localeCompare(b.startSort) || a.role.localeCompare(b.role)); }; export default function ReceptionistHomePage() { const navigate = useNavigate(); const lifetime = useLifetime(); const location = useLocation(); const { updateAppointment, fetchAppointment, fetchAppointmentsByStatus, fetchAllAppointmentsForOrgRange } = useAppointmentServiceContext(); const { currentUser, currentOrganization, wpOrganization } = useAuthServiceContext(); // The nav badge's snapshot, read rather than re-fetched, so the rail and the badge cannot disagree. const { newFormSubmissionsCount, newFormSubmissions, newFormSubmissionsStatus, refresh: refreshNewForms, } = useFormSubmissionNotificationContext(); const { subscribe, unsubscribe } = useWebSocketServiceContext(); const { filterAndUnpackBundle } = useFhirServiceContext(); const { setPatientState, fetchPatient } = usePatientServiceContext(); const { canView, activeWorkspace } = useStaffPermissionServiceContext(); // - declares AND releases on unmount. The raw context function does not release, so a // surface that used it left its expectation behind for whatever mounted next. const declareExpectedPatient = useSurfaceExpectedPatient(); const { fetchSlots, schedulesList, schedulesBundle } = useScheduleServiceContext(); const { loading: loadingPractitioners, error: practitionersError, practitionersList, } = useFhirPractitioners(); const { patientCommunicationRequests, staffCommunicationRequests, loading: loadingMessages, error: messagesError, getMessagesForRequest, getUnreadCountForRequest, participantNameRead, } = useCommunicationManager({ errorMessage: "Failed to load messages", }); const [loadingAppointments, setLoadingAppointments] = useState(false); // HAS A LOAD FINISHED IN THIS MOUNT - which is NOT the same question as "is one running now", and the // difference is the whole of the counter defect. // // `loadingAppointments` starts FALSE and only becomes true inside the effect below, so between the // first paint and that effect there is a frame where nothing is loading and nothing has loaded. The // section counts read `!loadingAppointments`, took that frame for an answer, and stated a confident // count from an `appointments` array that a remount had just reset to empty. // // Measured on the receptionist overview 2026-08-31, twice, via two different workspaces: returning to // the board showed "NEEDS ATTENTION! 0" for about a second, then blanked as the effect started, then // corrected itself to 8 - and all eight were "this patient is at least 60 minutes late". // // It starts false on every mount BY DESIGN. A remount has no answer yet however warm the caches are, // and this is the flag that says so. const [appointmentsAnswered, setAppointmentsAnswered] = useState(false); const [appointmentsError, setAppointmentsError] = useState(); /** * - DID THE BOARD ACTUALLY SEE THE WHOLE DAY? * * *"Nothing needs you and nobody is waiting"* is a DERIVED claim about every patient today, and it was * computed from whatever the reads happened to return. Loading, empty, and **not fully read** are * three states, not two - the same distinction landed for *"No visits on your schedule * today"*, which was being shown while the queue beside it still said *"Loading your day..."*. * * It is set by TWO different failures, and lumping them together is deliberate because the board's * answer is the same for both: a page bound reached with more outstanding, and a slot read that * REJECTED - which `Promise.allSettled` already swallowed into an empty list, so one schedule's whole * day could vanish while the board cheerfully declared the practice idle. */ const [boardIncomplete, setBoardIncomplete] = useState(false); /** * IS THE PARTIAL-READ BANNER ON SCREEN? Hoisted because the board's grid declares a track for it and * the banner fills that track, and computing the condition in two places is how the two come to * disagree - which is not a cosmetic disagreement here. A track declared for a child that is not * there took a quarter of the column and cost the busiest queue a visible row. */ const showBoardIncompleteBanner = boardIncomplete && !loadingAppointments; const [appointments, setAppointments] = useState([]); const [appointmentSchedulesById, setAppointmentSchedulesById] = useState>({}); const [showBookingModal, setShowBookingModal] = useState(false); // Checkout: the follow-up booking is for this visit's patient, and the workspace opens on invoices. const [followUpPatient, setFollowUpPatient] = useState(); const [workspaceInitialTab, setWorkspaceInitialTab] = useState(); const [checkoutSaving, setCheckoutSaving] = useState(false); const [checkoutError, setCheckoutError] = useState(); // What a saved checkout left unfinished; and a checkout whose answer never came, offered again only once known. const [checkoutNote, setCheckoutNote] = useState([]); const [checkoutUnknown, setCheckoutUnknown] = useState(false); const [selectedAppointment, setSelectedAppointment] = useState(); const [showAppointmentDetails, setShowAppointmentDetails] = useState(false); const [showAppointmentEdit, setShowAppointmentEdit] = useState(false); const [showPatientQuickView, setShowPatientQuickView] = useState(false); const [showRegisterPatient, setShowRegisterPatient] = useState(false); /** * What another surface handed over, if this page was opened by one. * * IN ROUTER STATE, NOT IN THE URL, and that is a privacy decision rather than a style one. This carries * a patient's name, and a query parameter would put it in the address bar and in browser history - on a * shared front-desk machine that outlives the receptionist who typed it. The product already drops the * hash on sign-out for exactly this reason. */ const [registerPrefill, setRegisterPrefill] = useState(); /** * ARRIVING FROM THE ADD PATIENT FORM, carrying what the receptionist already typed. * * That form cannot register a patient with no email - it collects neither the birth date nor the phone * or address the server needs to identify one - so it sends them here instead of growing into a second * registration surface that would silently skip duplicate detection. * * CONSUMED ONCE. The router entry is replaced immediately, so a refresh, a back-and-forward, or simply * pressing Register again later does not re-open the dialog holding somebody else's name. `replace` * rather than a push: the handoff is not a place in the history a receptionist should be able to * return to. */ useEffect(() => { const handoff = (location.state as { registerPrefill?: RegisterPatientPrefill } | null) ?.registerPrefill; if (!handoff) return; setRegisterPrefill(handoff); setShowRegisterPatient(true); navigate(location.pathname, { replace: true, state: null }); }, [location.pathname, location.state, navigate]); // The staff header's Book opens here; the state is spent once read. useEffect(() => { if (readFrontDeskAction(location.state) !== "book") return; setShowBookingModal(true); navigate(location.pathname, { replace: true, state: null }); }, [location.pathname, location.state, navigate]); const [quickViewPatient, setQuickViewPatient] = useState(); // The visit's patient in the board's compact quick view; registration still opens the workspace. const [deskQuickViewId, setDeskQuickViewId] = useState(); const [loadingQuickViewPatient, setLoadingQuickViewPatient] = useState(false); const [quickViewPatientError, setQuickViewPatientError] = useState(); const [appointmentClock, setAppointmentClock] = useState(() => Date.now()); // Keyed by appointment, so one visit's save never shows under or locks another. const [statusFlows, setStatusFlows] = useState>({}); const statusFlowsRef = useRef(statusFlows); // The board's writes belong to the person signed in; another one, or leaving the board, retires them. const boardScope = useSaveScope(undefined); // A checkout belongs to the visit whose details it was done from, while they are open; closing them, opening // another visit, or another person signing in retires it, and its busy state goes with it. const checkoutScope = useSaveScope(showAppointmentDetails ? selectedAppointment?.id : undefined, () => { setCheckoutSaving(false); setCheckoutUnknown(false); setCheckoutNote((current) => (current.length > 0 ? [] : current)); setCheckoutError(undefined); }); statusFlowsRef.current = statusFlows; const patchStatusFlow = useCallback((appointmentId: string, patch: Partial) => setStatusFlows((current) => ({ ...current, [appointmentId]: { ...current[appointmentId], ...patch } })), []); // Only the save or recovery that made a visit busy ends that, so a late one never frees a visit another is saving. const statusFlowOwnersRef = useRef>({}); const beginStatusFlow = useCallback((appointmentId: string, owner: SaveOperation, patch: Partial) => { statusFlowOwnersRef.current[appointmentId] = owner; statusFlowsRef.current = { ...statusFlowsRef.current, [appointmentId]: { ...statusFlowsRef.current[appointmentId], updating: true }, }; patchStatusFlow(appointmentId, { ...patch, updating: true }); }, [patchStatusFlow]); const endStatusFlow = useCallback((appointmentId: string, owner: SaveOperation) => { if (statusFlowOwnersRef.current[appointmentId] !== owner) return; delete statusFlowOwnersRef.current[appointmentId]; patchStatusFlow(appointmentId, { updating: false }); }, [patchStatusFlow]); // Per appointment: a status write that may or may not have landed, with the confirming read failed. // It survives closing and reopening the details; only a successful read or write settles it. const [unconfirmedStatuses, setUnconfirmedStatuses] = useState>({}); // Visits whose last change was refused because they changed: nothing is chosen until one is loaded as it is now. const [staleStatuses, setStaleStatuses] = useState>({}); // The version each visit's last status write named, so a later check can tell whether it may still land. const statusWriteBasisRef = useRef>({}); const settleUnconfirmed = useCallback((appointmentId: string) => setUnconfirmedStatuses(({ [appointmentId]: _settled, ...rest }) => rest), []); const [highlightAppointmentStatus, setHighlightAppointmentStatus] = useState(false); // The visit whose details are on screen, read by status outcomes that land later. const openDetailsIdRef = useRef(); // The patient whose quick view was last asked for; a late answer for anyone else is dropped. const quickViewRequestRef = useRef(); const [quickMessageTarget, setQuickMessageTarget] = useState(); // The rail's "On today" lens. Undefined is "all roles", never a sentinel string - the options are // derived from the rota and a hardcoded sentinel is one more value that could collide with a real one. const [onTodayRoleFilter, setOnTodayRoleFilter] = useState(); const activeLateToastIdsRef = useRef>(new Set()); const dismissedLateToastIdsRef = useRef>(new Set()); // Bookings this desk or a broadcast just wrote are kept by the shared hold until the search has them. // (B25) - how many late toasts are on screen, so the board can reserve that band rather than // be covered by it. 0 when there are none, and the reserve then costs nothing. const [lateToastCount, setLateToastCount] = useState(0); // The unfinished-note reminder sits over the bottom of the screen too; the board ends above it so its last row can be reached. const boardRef = useRef(null); const reminderRoomRef = useRef(null); const reminderRoom = useReminderClearance(reminderRoomRef, boardRef); useFhirSchedules(); useFhirScheduleBroadcast(); useFhirPractitionerRoleBroadcast(); const workSchedule = useMemo( () => buildWorkScheduleItems(schedulesList, practitionersList), [schedulesList, practitionersList], ); // THE OPTIONS COME FROM TODAY'S ROTA, and are counted over the UNFILTERED list - otherwise choosing // a role rewrites the menu you chose it from, and every other role reads as zero. const onTodayRoleOptions = useMemo( () => roleFilterOptions(workSchedule.map((item) => item.role)), [workSchedule], ); const onTodayStaff = useMemo( () => onTodayRoleFilter ? workSchedule.filter((item) => item.role === onTodayRoleFilter) : workSchedule, [workSchedule, onTodayRoleFilter], ); // ONE RESOLUTION FOR THE WHOLE BOARD, fed by every appointment it holds rather than per list. // The four queues below overlap - the same patient is routinely in today's list AND upcoming - and a // per-list hook would read that patient once per list. The hook dedups by id internally, so handing // it the full set is both fewer reads and the only way the late toast can name someone the queues // have already resolved. const patientNames = useAppointmentPatientNames(appointments); const supportAppointments = useMemo(() => { const now = appointmentClock; const { start, end } = getLocalDayRange(); const todayStart = start.getTime(); const todayEnd = end.getTime(); return appointments.filter((appointment) => { const startTimestamp = getAppointmentStartTimestamp(appointment); if (!appointment.id || startTimestamp === undefined) { return false; } const isFutureAppointment = startTimestamp >= now && isUpcomingAppointmentStatus(appointment.status); const isTodayAppointment = startTimestamp >= todayStart && startTimestamp < todayEnd && isTodayAttentionAppointmentStatus(appointment.status); return isFutureAppointment || isTodayAppointment; }); }, [appointmentClock, appointments]); const { appointmentTasksById, patientTaskTotalsById, taskErrorsByAppointmentId, patientReadinessById, readinessErrorsByPatientId, loading: loadingAppointmentSupport, refresh: refreshAppointmentSupport, } = useReceptionistAppointmentSupport(supportAppointments); const { appointments: selectedPatientAppointments, loading: loadingSelectedPatientAppointments, error: selectedPatientAppointmentsError, refresh: refreshSelectedPatientAppointments, } = useReceptionistPatientAppointments(selectedAppointment); /** * - THE FOUR PRIORITY QUEUES, and why this reads encounters at all. * * Every state on this board used to come from `Appointment.status`, which cannot express *roomed* vs * *with provider* vs *on hold* - those words exist only on the Encounter. So the board now re-buckets * the SHARED appointment+encounter queue (`useFrontDeskDay` `buildQueue`), exactly as the design's * own closing note requires: "the front desk is a third bucketing of the one queue, not a second * system." */ const { items: frontDeskItems, loadingEncounters, encountersAnswered, encounterError, } = useFrontDeskDay(appointments, appointmentClock); // The practice and day the counts last fully answered for; a re-read of that same board keeps them. const boardKey = frontDeskBoardKey(currentOrganization?.id ?? wpOrganization?.current?.id, appointmentClock); const [countsAnsweredFor, setCountsAnsweredFor] = useState(); const boardNeedsNoVisitRead = useMemo( () => appointmentsNeedingEncounters(frontDeskBoardAppointments(appointments, appointmentClock), appointmentClock).length === 0, [appointments, appointmentClock], ); useEffect(() => { if (!appointmentsAnswered || loadingAppointments || loadingEncounters) return; if (encountersAnswered || boardNeedsNoVisitRead) setCountsAnsweredFor(boardKey); }, [appointmentsAnswered, loadingAppointments, loadingEncounters, encountersAnswered, boardNeedsNoVisitRead, boardKey]); // All three agreements per patient, for the board's marker. A booking no longer waits on an answer // (ruling 2026-09-20), so this is where the desk sees that check-in has something to record. const consentStatusesByPatientId = useConsentStatusesByPatient( frontDeskItems.map((item) => item.patientId), ); const appointmentsInVisit = useMemo( () => new Set(frontDeskItems.flatMap((item) => (item.encounter && item.appointment?.id ? [item.appointment.id] : []))), [frontDeskItems], ); const roomsInUse = usePracticeRooming(); const frontDeskQueues = useMemo( () => buildFrontDeskQueues({ items: frontDeskItems, rooming: roomsInUse, now: appointmentClock, // Both resolvers go through the SHARED module. A local participant helper on this board is // what made a correctly-formed appointment render as "Unknown Patient - Unknown Practitioner". patientLabel: (item) => item.appointment ? resolveAppointmentPatientLabel(item.appointment, patientNames) : patientNames.names[item.patientId] || item.patientName || "Unknown patient", practitionerLabel: (item) => item.appointment ? resolveAppointmentPractitionerLabel(item.appointment, practitionersList, "") : "", // The agreements marker, KEPT rather than lost with the list that used to show it. // It rides the detail line rather than becoming a fourth Needs-you row type: the spec // enumerates what that queue contains, and inventing a row it does not name is the exact // failure Β§4.6 exists to prevent. It is also no longer a rounded amber chip - the design // rejects pill shapes outright, because a filled token beside a real button tells the eye // it is one. // THREE STATES, and `undefined` is the load-bearing one: a status not yet fetched must // NEVER flash a marker before the real answer arrives, which is the rule // `NotAuthorizedBadge` already encoded and this must not quietly drop. flagLabel: (item) => owedAgreementsFlag(consentStatusesByPatientId[item.patientId]), // SPENCER, 2026-08-15 - EVERY task the patient still owes, plus the subset that was due BY // THIS VISIT. Not the appointment's share alone: the old count was appointment-scoped, so a // patient's other outstanding work was invisible to the person standing in front of them. // THIS COSTS NO NEW READS. The support hook already fetched every one of the patient's tasks // to fill its per-appointment buckets and was discarding the unattached ones on the way out. taskSummary: (item): TaskSummary | undefined => { const appointmentId = item.appointment?.id; const totals = patientTaskTotalsById[item.patientId]; // THREE STATES, NEVER TWO. A refused or failed lookup says so; a patient we have not been // told about yet returns nothing at all, which the shared vocabulary renders as silence rather // than as "owes nothing". if (appointmentId && taskErrorsByAppointmentId[appointmentId]) return 'unavailable'; if (!totals) return undefined; // OUTSTANDING only, and only for THIS appointment. A completed task that was due here needs // nobody, and counting it would send the desk chasing finished work. const dueHereOutstanding = appointmentId ? (appointmentTasksById[appointmentId] || []).filter( (task) => task.status !== "completed", ).length : 0; return { ...totals, dueHereOutstanding }; }, }), [ frontDeskItems, appointmentClock, patientNames, practitionersList, appointmentTasksById, patientTaskTotalsById, taskErrorsByAppointmentId, consentStatusesByPatientId, roomsInUse, ], ); const currentStaffReference = currentUser?.fhir?.resourceType && currentUser.fhir.id ? `${currentUser.fhir.resourceType}/${currentUser.fhir.id}` : undefined; // MY dismissals, not anybody's. The mark carries who made it, which is what keeps this rail personal // while a reply stays everybody's. const isDismissedByMe = useCallback( (message: Communication | undefined) => isDismissedBy(message, currentStaffReference), [currentStaffReference], ); const patientMessageItems = useMemo( () => buildPatientMessageItems( patientCommunicationRequests, getMessagesForRequest, getUnreadCountForRequest, isDismissedByMe, participantNameRead, ), [patientCommunicationRequests, getMessagesForRequest, getUnreadCountForRequest, isDismissedByMe, participantNameRead], ); const staffMessageItems = useMemo( () => buildStaffMessageItems( staffCommunicationRequests, getMessagesForRequest, getUnreadCountForRequest, isDismissedByMe, currentStaffReference, participantNameRead, ), [staffCommunicationRequests, getMessagesForRequest, getUnreadCountForRequest, isDismissedByMe, currentStaffReference, participantNameRead], ); // DISMISS FROM THE RAIL, through the same hook and mark the Messages panels use, so a dismissal // here and one inside the thread are the same act: personal, and only of the message the row showed. const patientNeedsReply = useNeedsReply({ requests: patientCommunicationRequests, getMessagesForRequest, getUnreadCountForRequest, currentUserRef: currentStaffReference ?? '', isOurSide: isStaffSide, }); const colleagueNeedsReply = useNeedsReply({ requests: staffCommunicationRequests, getMessagesForRequest, getUnreadCountForRequest, currentUserRef: currentStaffReference ?? '', }); const formSubmissionItems = useMemo( () => buildFormSubmissionItems(newFormSubmissions ?? []), [newFormSubmissions], ); const loadOverviewAppointments = useCallback(async () => { if (!schedulesList.length) { setAppointments([]); setAppointmentSchedulesById({}); setAppointmentsError(undefined); // NO schedules is not the same as no schedules WE COULD SEE. If the schedule list itself // truncated, "there are no schedules" is a conclusion drawn from a page, and the board must not // then declare the practice idle. setBoardIncomplete(isSearchTruncated(schedulesBundle)); setLoadingAppointments(false); // AN EMPTY SCHEDULE LIST IS ONLY AN ANSWER ONCE THE SCHEDULES THEMSELVES HAVE ANSWERED. // `schedulesList` initialises to `[]` in its service, so this branch is also reached before // anything has been fetched - and marking the counts read there would state "0 needs you" from a // list nobody has filled yet. The BUNDLE is null until a response sets it, which is the same // distinction this branch already relies on one line up for `boardIncomplete`. setAppointmentsAnswered(Boolean(schedulesBundle)); return; } setLoadingAppointments(true); setAppointmentsError(undefined); // Reset per load, not per render: a stale "incomplete" would suppress a legitimate all-clear // forever, which trains the front desk to ignore the one line that is supposed to mean something. // The SCHEDULE list is folded in from the start - if it truncated, whole schedules were never // asked about, which is worse than a short list and is invisible from anything below. let incomplete = isSearchTruncated(schedulesBundle); const { start, end, startTimestamp, endTimestamp } = getOverviewAppointmentRange(); // Overdue window: from OVERDUE_LOOKBACK_DAYS before today up to today's start. Fetched as a // separate per-schedule query so it doesn't consume the upcoming window's _count budget. const overdueStart = new Date(startTimestamp); overdueStart.setDate(overdueStart.getDate() - OVERDUE_LOOKBACK_DAYS); const overdueStartIso = overdueStart.toISOString(); const started = startSearch(); // A load started for this page and viewer touches neither the shared hold nor this page once either changed. const alive = lifetime(); const admitted = () => alive() && sameViewerSince(started); try { const bundles = await Promise.allSettled( schedulesList.flatMap((schedule) => [ fetchSlots(schedule, start, end).then((bundle) => ({ schedule, bundle })), fetchSlots(schedule, overdueStartIso, start).then((bundle) => ({ schedule, bundle })), ]), ); // Held self-bookings (proposed requests / pending reviews) are loaded directly by status - // a slot-less time-window request has no slot for the schedule/slot query above to reach. let heldAppointments: FhirAppointment[] = []; try { const heldBundle = await fetchAppointmentsByStatus(["proposed", "pending"], true); // A short list of held requests is a patient who asked for an appointment and whom nobody is // shown - so it counts toward the board's honesty, not just toward a row count. if (isSearchTruncated(heldBundle)) incomplete = true; heldAppointments = filterAndUnpackBundle( heldBundle, "Appointment", ) as FhirAppointment[]; } catch (heldError) { console.error("Failed to load pending/request appointments:", heldError); incomplete = true; } /* - A BOOKED APPOINTMENT THE SLOT QUERIES DO NOT RETURN WOULD NEVER REACH THIS BOARD: one with no slot, or one whose slot sits on a schedule that has since been deleted. Everything above arrives through `Slot?...&_revinclude=Appointment:slot`, which returns only appointments that REFERENCE a slot. The held-request lookup right above already works around exactly this for `proposed`/`pending` - the same coupling, solved once and not generalised. Measured on Great River: 15 of 28 appointments have no slot, but 14 of those are cancelled, so the board's real exposure is ONE booked appointment. That is why this is a guard and not a re-source: the slot path is not wrong here, it is incomplete at one edge, and the edge is the one case that must never go missing - a booked visit is somebody arriving at a front desk. CANCELLED ARE STILL EXCLUDED, which is Spencer's ruling for this surface: the queues answer "what needs me now", and a cancelled appointment needs nothing. That ruling is now preserved BY CONSTRUCTION - the rescue uses `isOnFrontDeskBoard`, the board's own rule, rather than restating it. /337 WIDENED THIS, and the reason is worth keeping: it used to read `status === 'booked'`, so the rescue stopped the instant a receptionist marked the patient ARRIVED - the slot-less appointment was listed, then VANISHED at the moment the patient reached the desk. The board displays arrived / checked-in / waitlist / noshow; a literal ten lines from the predicates that say so did not. Failure is non-fatal and LOGGED: this is a safety net over a board that already works, and it must not be able to take that board down. */ let rangeAppointments: FhirAppointment[] = []; try { const range = await fetchAllAppointmentsForOrgRange(start, end, true); rangeAppointments = range.appointments as FhirAppointment[]; const rangeTruncated = range.truncated; // It already pages and already REPORTS hitting its 50-page bound - and this caller was throwing // that report away. The rescue is the only path a slot-less booked visit reaches the board by, // so its incompleteness is the board's incompleteness. if (rangeTruncated) incomplete = true; } catch (rescueError) { console.error('Failed to check for slotless booked appointments:', rescueError); // Non-fatal STILL means the board cannot claim completeness. The comment above is right that // this must not take the board down - it is wrong that it may therefore say nothing. A rescue // that did not run is exactly the one booked visit nobody is shown. incomplete = true; } const nextAppointmentSchedulesById: Record = {}; const scheduleAppointmentsAll = bundles.flatMap((result) => { if (result.status !== "fulfilled") { console.error("Failed to load appointments for schedule:", result.reason); // - THIS RETURN WAS ALREADY A SILENT SHORT BOARD, before any pagination question. // `allSettled` turns a rejected slot read into an empty list for that schedule, so one // practitioner's entire day disappears and the board still says nobody is waiting. incomplete = true; return []; } // A slot page bound reached with more outstanding: the same fact, arriving the other way. if (isSearchTruncated(result.value.bundle)) incomplete = true; const scheduleAppointments = filterAndUnpackBundle( result.value.bundle, "Appointment", ) as FhirAppointment[]; scheduleAppointments.forEach((appointment) => { if (appointment.id) { nextAppointmentSchedulesById[appointment.id] = result.value.schedule; } }); return scheduleAppointments; }); // The rescue keeps what the board shows and no slot query returned: no slot at all, or a slot on // a schedule that was deleted. Said out loud, because the count is a sign of something upstream. const reachedIds = new Set([...scheduleAppointmentsAll, ...heldAppointments].map((appointment) => appointment.id)); // What the board shows is what it rescues; a narrower rule dropped a finished visit from Checkout. const missedBySlots = appointmentsTheSlotsMissed( rangeAppointments, reachedIds, isOnFrontDeskBoard, ); if (missedBySlots.length > 0) { console.warn( `APPOINTMENT_OFF_THE_SLOT_QUERY: rescued ${missedBySlots.length} appointment(s) no schedule's slot ` + 'query returned (no slot, or a slot on a deleted schedule). They are shown (card 293).', ); } // The same visit can come back from the slot, status and range reads at different versions; the newest copy // stands, whichever read has it, and every copy reaches the hold so a newer one (a cancellation included) can // settle what this viewer saved. const nextAppointments = newestById([ ...rangeAppointments.filter((appointment) => Boolean(appointment.id)), ...scheduleAppointmentsAll, ...heldAppointments, ]); if (!admitted()) return; // Every read above is as searched, so the shared hold is laid over the board once: a copy it put there itself // can never pass for the search catching up. The filter below still decides what belongs on it. A board built // from several reads proves no absence. // A visit no slot or status read reached is kept only when the board shows its status. const shownAppointments = overlayHeld("Appointment", nextAppointments, () => true, Date.now(), false, started) .filter((appointment) => reachedIds.has(appointment.id) || isOnFrontDeskBoard(appointment.status)); const dedupedAppointments = Array.from( new Map( shownAppointments .filter((appointment) => { if (!appointment.id) { return false; } // Requests / pending reviews are action items - keep them regardless of time (a // slot-less time-window request has no start to place in the window). if ( appointment.status === "proposed" || appointment.status === "pending" ) { return true; } const appointmentStartTimestamp = getAppointmentStartTimestamp(appointment); if (appointmentStartTimestamp === undefined) { return false; } // Today-onward: keep everything in the upcoming window. if ( appointmentStartTimestamp >= startTimestamp && appointmentStartTimestamp < endTimestamp ) { return true; } // Past: keep only still-unresolved appointments, so an overdue booking keeps // surfacing (late toast / attention) until its status is updated. return ( appointmentStartTimestamp < startTimestamp && !!appointment.status && OVERDUE_RETENTION_STATUSES.has(appointment.status) ); }) .map((appointment) => [appointment.id as string, appointment]), ).values(), ); setAppointments(dedupedAppointments); setAppointmentSchedulesById(nextAppointmentSchedulesById); setBoardIncomplete(incomplete); } catch (error) { if (!admitted()) return; console.error("Failed to load receptionist overview appointments:", error); setAppointmentsError("Failed to load upcoming appointments."); // The error banner already speaks for itself, but the all-clear must not be able to appear // underneath it - an outright failure is the strongest form of "not fully read" there is. setBoardIncomplete(true); } finally { if (admitted()) { setLoadingAppointments(false); // In the `finally`, so a FAILED read still counts as answered. The board has an error banner and a // boardIncomplete flag for that case; leaving the counts permanently withheld instead would trade a // wrong number for a blank one that never resolves. setAppointmentsAnswered(true); } } }, [fetchSlots, fetchAppointmentsByStatus, fetchAllAppointmentsForOrgRange, filterAndUnpackBundle, schedulesList, schedulesBundle, lifetime]); useEffect(() => { loadOverviewAppointments(); }, [loadOverviewAppointments]); useEffect(() => { const handleAppointmentEvent = (cloudEvent: unknown) => { // Update straight from the broadcast payload (immediately consistent) instead of re-fetching via an // eventually-consistent HealthLake search - the search races the write, so a fresh self-booking used to // appear only after a manual refresh. The pendingAppointments memo then surfaces a pending // self-booking automatically, no separate pending refetch needed. const appointment = extractAppointmentFromBroadcast(cloudEvent); if (!appointment?.id) { // Malformed/absent payload (shouldn't happen - the server always includes it): fall back to the // refetch so we never silently drop the event. void loadOverviewAppointments(); void refreshSelectedPatientAppointments(); return; } const isDelete = (cloudEvent as { type?: string })?.type === "com.akashichealthrecords.fhir.appointment.deleted"; // The next search lags the broadcast, so the shared hold keeps it: a delete stays hidden, a change stays shown. if (isDelete) { holdDelete({ resourceType: "Appointment", id: appointment.id }); } else { holdWrite(appointment); } setAppointments((currentAppointments) => mergeBroadcastAppointment(currentAppointments, appointment, isDelete), ); // Keep the open patient-detail pane (if any) in sync - it's id-scoped, not the overview search race. void refreshSelectedPatientAppointments(); }; subscribe("com.akashichealthrecords.fhir.appointment.created", handleAppointmentEvent); subscribe("com.akashichealthrecords.fhir.appointment.updated", handleAppointmentEvent); subscribe("com.akashichealthrecords.fhir.appointment.deleted", handleAppointmentEvent); return () => { unsubscribe("com.akashichealthrecords.fhir.appointment.created", handleAppointmentEvent); unsubscribe("com.akashichealthrecords.fhir.appointment.updated", handleAppointmentEvent); unsubscribe("com.akashichealthrecords.fhir.appointment.deleted", handleAppointmentEvent); }; }, [ loadOverviewAppointments, refreshSelectedPatientAppointments, subscribe, unsubscribe, ]); useEffect(() => { const supportEventNames = [ "com.akashichealthrecords.fhir.task.created", "com.akashichealthrecords.fhir.task.updated", "com.akashichealthrecords.fhir.task.deleted", "com.akashichealthrecords.fhir.patient.updated", "com.akashichealthrecords.fhir.relatedperson.created", "com.akashichealthrecords.fhir.relatedperson.updated", "com.akashichealthrecords.fhir.relatedperson.deleted", "com.akashichealthrecords.fhir.medicationstatement.created", "com.akashichealthrecords.fhir.medicationstatement.updated", "com.akashichealthrecords.fhir.medicationstatement.deleted", "com.akashichealthrecords.fhir.allergyintolerance.created", "com.akashichealthrecords.fhir.allergyintolerance.updated", "com.akashichealthrecords.fhir.allergyintolerance.deleted", "com.akashichealthrecords.fhir.list.created", "com.akashichealthrecords.fhir.list.updated", "com.akashichealthrecords.fhir.list.deleted", ]; const handleSupportEvent = () => { void refreshAppointmentSupport(); }; supportEventNames.forEach((eventName) => subscribe(eventName, handleSupportEvent)); return () => { supportEventNames.forEach((eventName) => unsubscribe(eventName, handleSupportEvent)); }; }, [refreshAppointmentSupport, subscribe, unsubscribe]); /** * sweep - the two overview rows that navigated WITHOUT their item. * * Named handlers rather than inline arrows, deliberately: an inline `navigate("/somewhere")` is how * both of these lost their target in the first place. A function that takes the row makes forgetting * to use it a compile error rather than a silent downgrade. * * Both still NAVIGATE, and that is correct under R5: a person's day and a full conversation both * genuinely need their own page. The defect was never that they left - it was that they left for a * LIST and made the receptionist find the thing again. */ const openStaffSchedule = useCallback((item: WorkScheduleItem) => { navigate("/dashboard/schedules", { state: { openScheduleId: item.scheduleId } }); }, [navigate]); // Takes only what it USES - the thread id. It was typed to the whole overview item, which made it // unusable from the rail for no reason: a handler that needs one field should not demand six. const openPatientMessage = useCallback((item: { id: string }) => { // `item.id` falls back to a synthetic `name-timestamp` when a request has no id. Passing it is // still right: the destination matches on request id, finds nothing, and lands on the tab - exactly // today's behaviour. A request with no id cannot be opened by any route. navigate("/dashboard/staff-messages", { state: { tab: "patients", openRequestId: item.id }, }); }, [navigate]); const openAppointmentDetails = useCallback(( appointment: FhirAppointment, options?: { highlightStatus?: boolean; keepStatusOutcome?: boolean }, ) => { setShowBookingModal(false); setSelectedAppointment(appointment); setShowAppointmentEdit(false); setShowPatientQuickView(false); setDeskQuickViewId(undefined); setQuickViewPatient(undefined); quickViewRequestRef.current = undefined; if (appointment.id && !options?.keepStatusOutcome && !statusFlowsRef.current[appointment.id]?.updating) { patchStatusFlow(appointment.id, { error: undefined, refusal: undefined }); } setHighlightAppointmentStatus(Boolean(options?.highlightStatus)); setCheckoutError(undefined); setShowAppointmentDetails(true); void refreshAppointmentSupport(); }, [patchStatusFlow, refreshAppointmentSupport]); const openFrontDeskRow = useCallback((row: FrontDeskRow) => { if (row.item.appointment) openAppointmentDetails(row.item.appointment); }, [openAppointmentDetails]); const handleAppointmentDetailsOpenChange = (open: boolean) => { setShowAppointmentDetails(open); if (!open) { setHighlightAppointmentStatus(false); const id = selectedAppointment?.id; if (id && !statusFlowsRef.current[id]?.updating) { patchStatusFlow(id, { error: undefined, refusal: undefined }); } } }; const handleEditSelectedAppointment = () => { setHighlightAppointmentStatus(false); setShowPatientQuickView(false); setDeskQuickViewId(undefined); setShowAppointmentDetails(false); setShowAppointmentEdit(true); }; const handleViewSelectedPatientProfile = () => { if (!selectedPatientId) { return; } setHighlightAppointmentStatus(false); setShowAppointmentDetails(false); setShowPatientQuickView(false); setDeskQuickViewId(selectedPatientId); }; const handlePatientRegistered = (newPatient: Patient) => { // Freshly registered: drop straight into the workspace on the new patient to finish any demographics not // captured in the register form. Back closes to the overview (no search/appointment context to return to). quickViewRequestRef.current = newPatient.id; setShowRegisterPatient(false); setSelectedAppointment(undefined); setShowPatientQuickView(true); setQuickViewPatient(newPatient); setLoadingQuickViewPatient(false); setQuickViewPatientError(undefined); // - a patient just created here IS the subject of this surface. declareExpectedPatient(newPatient.id); setPatientState(newPatient); }; const handleBackFromPatientQuickView = () => { setShowPatientQuickView(false); setWorkspaceInitialTab(undefined); setHighlightAppointmentStatus(false); setShowAppointmentDetails(Boolean(selectedAppointment)); }; const handleBackFromDeskQuickView = () => { setDeskQuickViewId(undefined); setHighlightAppointmentStatus(false); setShowAppointmentDetails(Boolean(selectedAppointment)); }; const handlePatientUpdated = (updated: Patient) => { // Reflect an in-modal edit back into the workspace + context (so the tabs/header refresh). setQuickViewPatient(updated); setPatientState(updated); }; const handleCloseAppointmentEdit = () => { setShowAppointmentEdit(false); setShowAppointmentDetails(Boolean(selectedAppointment)); }; // One visit's copy, published to the selection and the board without replacing a newer one either already holds // (a broadcast can install a later version before an older save or reload answers). const publishAppointment = useCallback((incoming: FhirAppointment) => { setSelectedAppointment((selected) => (selected && selected.id === incoming.id ? latestKnown(selected, incoming) : selected)); setAppointments((currentAppointments) => currentAppointments.map((appointment) => ( appointment.id === incoming.id ? latestKnown(appointment, incoming) : appointment ))); }, []); const handleAppointmentUpdated = (updatedAppointment: FhirAppointment) => { publishAppointment(updatedAppointment); void loadOverviewAppointments(); }; const applyStatusOutcome = useCallback(async ( appointmentId: string, requested: ReceptionistAppointmentStatus, outcome: StatusChangeOutcome, ) => { if (outcome.state === "unknown") { setUnconfirmedStatuses((current) => ({ ...current, [appointmentId]: requested })); patchStatusFlow(appointmentId, { error: outcome.unreadable ? STATUS_CHANGE_UNREADABLE : STATUS_CHANGE_UNCONFIRMED }); return; } settleUnconfirmed(appointmentId); if (outcome.state === "superseded") { // Shown as it is now and open to a new choice, without saying the change was not saved. publishAppointment(outcome.current); setStaleStatuses(({ [appointmentId]: _current, ...rest }) => rest); patchStatusFlow(appointmentId, { requested: undefined, refusal: undefined, error: undefined, note: [STATUS_CHANGE_SUPERSEDED] }); return; } if (outcome.state === "refused") { if (outcome.changed) setStaleStatuses((current) => ({ ...current, [appointmentId]: true })); patchStatusFlow(appointmentId, { requested: undefined, error: undefined, refusal: outcome.message }); if (openDetailsIdRef.current !== appointmentId) toast.error(outcome.message, { position: "top-right" }); return; } if (outcome.state === "unsaved") { patchStatusFlow(appointmentId, { error: "Unable to update the appointment status. Please try again." }); // The open details already say so; the toast is for a visit whose details are not on screen. if (openDetailsIdRef.current !== appointmentId) { toast.error("Unable to update the appointment status.", { position: "top-right" }); } return; } const updatedAppointment = outcome.appointment; // The clinician's board reads this check-in's Encounter by id until its search catches up. if (updatedAppointment.status === "arrived" || updatedAppointment.status === "checked-in") noteCheckIn(updatedAppointment.id); patchStatusFlow(appointmentId, { requested: undefined, error: undefined, refusal: undefined, ...(outcome.recovered ? { note: appointmentFollowUpLines([COMPANIONS_UNCONFIRMED]) } : {}), }); publishAppointment(updatedAppointment); setHighlightAppointmentStatus(false); if (updatedAppointment.id) { const lateToastId = getLateAppointmentToastId(updatedAppointment.id); toast.dismiss(lateToastId); activeLateToastIdsRef.current.delete(lateToastId); } void refreshSelectedPatientAppointments(); }, [patchStatusFlow, publishAppointment, refreshSelectedPatientAppointments, settleUnconfirmed]); // Returns whether it saved, so a caller outside the details can open them when it did not. const changeAppointmentStatus = useCallback(async ( appointment: FhirAppointment, nextStatus: ReceptionistAppointmentStatus, ): Promise => { const appointmentId = appointment.id; if (!appointmentId || statusFlowsRef.current[appointmentId]?.updating) return false; // No second write against a state nobody could confirm, or one read before the visit changed. if (unconfirmedStatuses[appointmentId] || staleStatuses[appointmentId]) return false; if (getReceptionistAppointmentStatus(appointment) === nextStatus) { setHighlightAppointmentStatus(false); return true; } const operation = boardScope.start(); beginStatusFlow(appointmentId, operation, { error: undefined, refusal: undefined, note: undefined, requested: nextStatus }); let followUps: string[] = []; const basedOn = appointmentVersion(appointment); statusWriteBasisRef.current[appointmentId] = basedOn; try { const outcome = await settleStatusChange({ requested: nextStatus, // The version the desk acted on, so a change never lands over one made since. write: () => operation.step(() => updateAppointment( applyReceptionistAppointmentStatus(appointment, nextStatus), { basedOn, onFollowUp: (reported) => { followUps = reported; }, transport: operation.transport }, ), true), // An answer that never came is settled by the visit as stored now, read under its own recovery. reread: () => { const recovery = boardScope.start(); return recovery.step(() => fetchAppointment(appointmentId, true, recovery.transport)); }, // A server error may still have saved; only a refusal is definite. isDefinite: (error) => (error instanceof HttpError && error.status < 500) || isSaveRetired(error), refusal: (error) => treatmentAgreementRefusal(error) ?? (isRecordChanged(error) ? APPOINTMENT_CHANGED_REFUSAL : undefined), changed: isRecordChanged, basedOn, }); // Nothing is shown for a person no longer signed in here. if (!operation.belongs()) return false; await applyStatusOutcome(appointmentId, nextStatus, outcome); if (outcome.state === "saved" && followUps.length > 0) { patchStatusFlow(appointmentId, { note: appointmentFollowUpLines(followUps) }); } return outcome.state === "saved"; } finally { endStatusFlow(appointmentId, operation); } }, [applyStatusOutcome, beginStatusFlow, boardScope, endStatusFlow, fetchAppointment, patchStatusFlow, staleStatuses, unconfirmedStatuses, updateAppointment]); const handleSelectedAppointmentStatusChange = useCallback(async ( nextStatus: ReceptionistAppointmentStatus, ) => { if (selectedAppointment) await changeAppointmentStatus(selectedAppointment, nextStatus); }, [changeAppointmentStatus, selectedAppointment]); // An unknown outcome is settled only by a fresh read under a recovery of its own, never a cached copy, and its // answer is used only while that recovery still belongs; nothing is written again from here. const handleCheckSelectedAppointmentStatus = useCallback(async () => { const appointmentId = selectedAppointment?.id; const requested = appointmentId ? unconfirmedStatuses[appointmentId] : undefined; if (!appointmentId || !requested || statusFlowsRef.current[appointmentId]?.updating) return; const recovery = boardScope.start(); beginStatusFlow(appointmentId, recovery, { requested }); try { const outcome = await checkStatusChange({ requested, basedOn: statusWriteBasisRef.current[appointmentId], reread: () => recovery.step(() => fetchAppointment(appointmentId, true, recovery.transport)), }); if (!recovery.belongs()) return; await applyStatusOutcome(appointmentId, requested, outcome); } finally { endStatusFlow(appointmentId, recovery); } }, [applyStatusOutcome, beginStatusFlow, boardScope, endStatusFlow, fetchAppointment, selectedAppointment?.id, unconfirmedStatuses]); // A visit that changed since it was read here is loaded fresh before anything is chosen for it again. const handleReloadSelectedAppointment = useCallback(async () => { const appointmentId = selectedAppointment?.id; if (!appointmentId || statusFlowsRef.current[appointmentId]?.updating) return; const recovery = boardScope.start(); beginStatusFlow(appointmentId, recovery, {}); try { const current = await recovery.step(() => fetchAppointment(appointmentId, true, recovery.transport)); if (!recovery.belongs()) return; publishAppointment(current); setStaleStatuses(({ [appointmentId]: _reloaded, ...rest }) => rest); patchStatusFlow(appointmentId, { refusal: undefined, error: undefined, requested: undefined }); } catch (error) { if (!isSaveRetired(error) && recovery.belongs()) { patchStatusFlow(appointmentId, { refusal: "We could not load this appointment as it is now. Try again." }); } } finally { endStatusFlow(appointmentId, recovery); } }, [beginStatusFlow, boardScope, endStatusFlow, fetchAppointment, patchStatusFlow, publishAppointment, selectedAppointment?.id]); useEffect(() => { const updateClock = () => setAppointmentClock(Date.now()); const interval = window.setInterval(updateClock, APPOINTMENT_CLOCK_INTERVAL_MS); document.addEventListener("visibilitychange", updateClock); return () => { window.clearInterval(interval); document.removeEventListener("visibilitychange", updateClock); }; }, []); useEffect(() => { const visibleToastIds = new Set(); // - WHO MAY RAISE A LATE TOAST IS A RULE, AND IT NOW LIVES BESIDE THE BOARD'S OWN. // This loop used to test `booked` and elapsed minutes and nothing else - no day bound at all - so an // appointment nobody ever marked `fulfilled`/`noshow` alerted FOR EVER. Measured on staging: a patient // with NO appointment today and three still-booked ones from the previous fortnight raised "...is at // least 60 minutes late. Please update the appointment status." all evening, an instruction pointing // at a screen scoped to today where that appointment does not appear. // It degrades with age, so it gets worse the longer a practice uses us, and it poisons the sharpest // channel the front desk has: once most of these are stale, the real one stops being read. lateToastAppointments(appointments, appointmentClock, LATE_WARNING_MINUTES, appointmentsInVisit).forEach(({ appointment, id, elapsedMinutes }) => { const toastId = getLateAppointmentToastId(id); // Dismissed stays dismissed for this visit to the board. if (dismissedLateToastIdsRef.current.has(toastId)) return; visibleToastIds.add(toastId); activeLateToastIdsRef.current.add(toastId); toast.custom( () => ( openAppointmentDetails(appointment, { highlightStatus: true })} onMarkNoShow={() => { void changeAppointmentStatus(appointment, "noshow").then((saved) => { // A save that did not land is shown where every status outcome is: that visit's details. if (!saved) openAppointmentDetails(appointment, { keepStatusOutcome: true }); }); }} onDismiss={() => { dismissedLateToastIdsRef.current.add(toastId); activeLateToastIdsRef.current.delete(toastId); toast.dismiss(toastId); setLateToastCount((count) => Math.max(0, count - 1)); }} /> ), { id: toastId, duration: Infinity, position: "bottom-right", }, ); }); activeLateToastIdsRef.current.forEach((toastId) => { if (!visibleToastIds.has(toastId)) { toast.dismiss(toastId); activeLateToastIdsRef.current.delete(toastId); } }); // (B25) - tell the board how much of its bottom edge the toast stack is occupying, so it // can reserve that band instead of being covered by it. Measured: the toast lands at (1045, 876) at // 1440Γ—900 and overlaps the lowest card in the right column. // Capped at sonner's own `visibleToasts` limit: reserving for ten late appointments when only three // are ever rendered would eat the board on exactly the busy morning it is most needed. setLateToastCount(Math.min(visibleToastIds.size, SONNER_VISIBLE_TOASTS)); // ⚠️ `patientNames` is a dependency because the toast is rendered ONCE, into sonner's own store, by // a callback that closes over it. Without it the toast keeps the resolution it was born with β€” // "Loading patient…" β€” and never re-renders when the name arrives, which would leave the sharpest // alert on this screen permanently anonymous for exactly the appointments this card is about. }, [appointmentClock, appointments, appointmentsInVisit, changeAppointmentStatus, openAppointmentDetails, patientNames, practitionersList]); useEffect(() => () => { activeLateToastIdsRef.current.forEach((toastId) => toast.dismiss(toastId)); activeLateToastIdsRef.current.clear(); }, []); const selectedAppointmentId = selectedAppointment?.id; const selectedPatientId = selectedAppointment ? getAppointmentPatientId(selectedAppointment) : undefined; const selectedAwaitsCheckout = awaitsCheckout( selectedAppointment, frontDeskItems.find((item) => item.appointment?.id !== undefined && item.appointment.id === selectedAppointment?.id) ?.encounter, ); const loadVisitPatient = async (): Promise => { if (!selectedPatientId) return undefined; const patient = await fetchPatient(selectedPatientId, false, undefined, true); return patient?.id === selectedPatientId ? patient : undefined; }; const handleCheckoutInvoices = async () => { const patientId = selectedPatientId; if (!patientId) return; setCheckoutError(undefined); setShowAppointmentDetails(false); setWorkspaceInitialTab("invoices"); setQuickViewPatient(undefined); setQuickViewPatientError(undefined); setLoadingQuickViewPatient(true); quickViewRequestRef.current = patientId; setShowPatientQuickView(true); try { const patient = await loadVisitPatient(); if (quickViewRequestRef.current !== patientId) return; if (!patient) throw new Error("The patient read answered for another patient."); declareExpectedPatient(patientId); setPatientState(patient); setQuickViewPatient(patient); } catch { if (quickViewRequestRef.current === patientId) setQuickViewPatientError("Failed to load the patient's invoices."); } finally { if (quickViewRequestRef.current === patientId) setLoadingQuickViewPatient(false); } }; const handleCheckoutFollowUp = async () => { setCheckoutError(undefined); try { const patient = await loadVisitPatient(); if (!patient) throw new Error("The patient read answered for another patient."); // Only the patient carries over; the desk chooses the service and the time. setFollowUpPatient(patient); setShowAppointmentDetails(false); setShowBookingModal(true); } catch { setCheckoutError("Could not open a follow-up for this patient. Try again."); } }; // Puts a saved checkout on the board and closes the details it was done from; what it left unfinished stays. const installCheckout = (saved: FhirAppointment, followUps: string[]) => { publishAppointment(saved); setCheckoutUnknown(false); if (followUps.length > 0) { setCheckoutNote(appointmentFollowUpLines(followUps)); return; } setShowAppointmentDetails(false); }; // A checkout whose answer never came is settled by the visit as stored now: stamped by this checkout, it landed. const checkCheckout = async (stamped: FhirAppointment) => { const recovery = checkoutScope.start(); setCheckoutSaving(true); try { const at = readCheckoutDone(stamped)?.at; let stored: FhirAppointment | undefined; const settled = await settleUnknownWrite( async () => (stored = await recovery.step(() => fetchAppointment(stamped.id as string, true, recovery.transport))), (current) => Boolean(at) && readCheckoutDone(current)?.at === at, ); if (settled === "saved" && stored) installCheckout(stored, [COMPANIONS_UNCONFIRMED]); else { setCheckoutUnknown(true); setCheckoutError("We could not confirm whether checkout was saved. Close this and open the visit again to check before trying again."); } } catch { // Retired: nothing is said for details no longer on screen. } finally { if (recovery.belongs()) setCheckoutSaving(false); } }; const handleCheckoutDone = async () => { if (!selectedAppointment || !currentStaffReference) { setCheckoutError("Checkout was not saved. Try again."); return; } const staffName = currentUser?.fhir?.resourceType === "Practitioner" ? formatFhirHumanName(currentUser.fhir.name, { emptyText: "" }) || undefined : undefined; const stamped = withCheckoutDone(selectedAppointment, { reference: currentStaffReference, display: staffName, at: new Date().toISOString(), }); setCheckoutSaving(true); setCheckoutError(undefined); setCheckoutNote([]); // Belongs to this visit's open details: closing them, or opening another visit, retires it. const operation = checkoutScope.start(); let followUps: string[] = []; let settling = false; try { const basedOn = appointmentVersion(selectedAppointment); const saved = (await operation.step(() => updateAppointment(stamped, { basedOn, onFollowUp: (reported) => { followUps = reported; }, transport: operation.transport, }), true)) ?? stamped; installCheckout(saved, followUps); } catch (error) { if (isSaveRetired(error)) return; if (isRecordChanged(error)) { setCheckoutError("Someone else changed this appointment, so checkout was not saved. Look at it as it is now and try again."); } else if (writeOutcomeUnknown(error)) { settling = true; } else { setCheckoutError("Checkout was not saved. Try again."); } } finally { if (operation.belongs() && !settling) setCheckoutSaving(false); } if (settling) await checkCheckout(stamped); }; const selectedAppointmentTasks = selectedAppointmentId ? appointmentTasksById[selectedAppointmentId] || [] : []; const selectedAppointmentTasksLoaded = selectedAppointmentId ? Object.prototype.hasOwnProperty.call(appointmentTasksById, selectedAppointmentId) : false; const selectedStatusFlow = selectedAppointment?.id ? statusFlows[selectedAppointment.id] : undefined; openDetailsIdRef.current = showAppointmentDetails ? selectedAppointment?.id : undefined; const selectedPatientReadiness = selectedPatientId ? patientReadinessById[selectedPatientId] : undefined; return (
{/* πŸ”» THE TITLE, SUBTITLE AND BREADCRUMB ARE GONE, and the measurement is why: they cost 155px β€” 20% of a 1366x768 screen β€” before the first appointment, on the screen a receptionist looks at more than any other in the product. All three describe the page to someone already looking at it. ~93px recovered is two more patients visible without scrolling. Search, Book and Register live in the staff header, which opens their dialogs here. */} {/* 🏷️ CARD 235 / B102 β€” THE PAGE'S NAME, FOR THE OUTLINE ONLY, AND IT COSTS ZERO PIXELS. πŸ“ Measured on the deployed build: this screen had **no heading of any level** inside `
` across 804 characters of content, and its name existed only as a `` in the breadcrumb. β‡’ Somebody navigating by heading lands on the front desk's home screen and finds nothing at all β€” the same defect B20 recorded for My Records, on the surface the operational wedge lives on all day. β›” IT IS `sr-only` ON PURPOSE, and that is the whole care in this change. The visible title, subtitle and breadcrumb were REMOVED on measurement β€” 155px, 20% of a 1366x768 screen, "two more patients visible without scrolling". Putting a visible heading back would undo a deliberate decision to buy an accessibility one; a hidden heading buys it for free. πŸ“Œ `h2`, never `h1` β€” the WordPress theme already supplies the document's `h1` (card 644). */}

Receptionist overview

{/* πŸ”΄ Card 114 β€” the invitee's own list, ALSO here. It was mounted only on the clinician overview (`#/dashboard/overview`), and a staff account whose workspaces are Admin/Receptionist has no nav path there: Admin has no Overview item, and the Receptionist's "Overview" lands on `#/dashboard/receptionist` β€” this page. So the invitation email said "Sign in to view" and there was nothing to click (found in a browser, card 114 UI spec S4). ⭐ It costs the majority nothing: the component self-gates on the viewer's `resourceType` and on having any unanswered invitation, so it renders nothing and fires no request for everyone else. */}
{/* πŸ”» FIVE FIXED-HEIGHT PANELS BECAME FOUR PRIORITY QUEUES, and the reason is measured rather than aesthetic: the old surface REPORTED CATEGORIES, NOT WORK. Five panels of "no X found" is a status board, and an empty one still held ~460px open β€” so the quiet parts of the day pushed the busy parts off screen. Every section here caps, scrolls inside itself, and renders NOTHING when empty, handing its height back to the day ahead. β›” Do not re-open the layout: it went ~15 rounds with Spencer against live measurement. */} {appointmentsError && ( {appointmentsError} )} {/* ⚠️ A failed encounter read is SAID ALOUD rather than degrading the queues to appointment-only states. Silently, an occupied waiting room renders as an empty one. */} {encounterError && ( {encounterError} )} {frontDeskBoardIsLoading({ appointmentsAnswered, loadingAppointments, encountersAnswered, loadingEncounters }) && (
Loading today's board...
)} {/* 🚨 CARD 405 β€” SAID ALOUD, because the alternative is a board that looks calm. When a read came back short the queues below are a SUBSET, and any zero under this banner is a count of what LOADED rather than of what exists. β›” Not a silent withhold: a blank board and a genuinely quiet morning look identical, which is the confusion this replaces. πŸ”‘ Card 736 β€” this banner is now the ONLY thing carrying that doubt, and it sits ABOVE the four sections deliberately: the sections still render under it, because removing a queue is what makes a failed read look like a bug. */} {/* 🚨 THE BANNER AND THE BOARD ARE ONE GRID CHILD, and that is a layout fix rather than tidying. πŸ“ MEASURED on the running app with the appointment read deliberately blocked: the main column is `grid-template-rows: 1fr 0.5fr`, so with the banner present the BANNER took the 1fr row β€” stretching to 475px for a single line of text β€” and the board was pushed into the 0.5fr row at y=633. On a 1366Γ—768 screen that is the board off the bottom of the viewport: a receptionist whose read failed saw a warning and what looked like an empty page. ⭐ It is the old "the board goes blank" symptom wearing a different hat: the sections DO render now, they were just below the fold. β‡’ Wrapped so the pair occupies ONE row: the banner hugs its content and the board gets the rest. `minmax(0,1fr)` because the board MEASURES this box to decide how many rows it can show, and an `auto` track would let it size to its own content and never bound anything. */} {/* AND THE SECOND TRACK IS ONLY DECLARED WHEN SOMETHING IS IN IT, which is the same rule card 776 applied one level up - and the same defect one level down. MEASURED on the running board at 1366x768, signed in at Great River with no failed read: this grid computed `298.286px 241.714px` for ONE child. The banner is absent on an ordinary morning, so the queues column landed in the `auto` track and the `1fr` track - 242px, a quarter of the column - held nothing at all. IT IS NOT WASTED SPACE, IT IS LOST WORK. FrontDeskQueuesColumn MEASURES this box to decide how many rows each queue may show, so the phantom track shrank the wrapper to 298px, the budget came out at two rows, and Needs attention rendered a 71px cap over 108px of content: three items, two visible, one hidden, while 242px sat empty directly beneath it. That is 776's exact reported symptom surviving 776's fix, because the outer grid was only ever half the cause. Do not "fix" this by dropping the second track outright - it is right when the banner is there, and that is the case this wrapper exists for. Declare it when it is occupied. */}
{showBoardIncompleteBanner && ( Some of today's appointments could not be loaded, so this board may be showing only part of the day. Refresh to try again. )} {/* πŸ”» CARD 736 β€” THE SINGLE ALL-CLEAR LINE IS RETIRED, and `boardIsAllClear` with it. Spencer, reviewing the quiet-morning frame: *"Zero is information as opposed to not displaying and wondering if something is broken."* β‡’ The four sections below each state their own zero, and four true statements do not need a fifth on top saying the same thing less precisely. 🚨 IT HAD A FAILURE MODE NOBODY HAD NOTICED, which is the real reason it goes rather than simply being redundant: it was correctly WITHHELD whenever a message was unanswered β€” which is most quiet mornings β€” and nothing took its place. Observed live at Great River on 2026-08-25: four empty queues, three unanswered threads, and a completely blank column. ⭐ THE REASONING IT CARRIED IS NOT LOST, it has moved down one level. Cards 397, 405, 428 and 473 all landed the same rule β€” **loading, empty, and not-fully-read are three states, and only the middle one may make a claim** β€” and that rule is now `countsRead` on each section, where it is enforced four times instead of once. β›” `boardIncomplete` still renders its banner ABOVE the sections, and the sections still render under it: a failed read must never remove a queue, because a missing queue is indistinguishable from a bug. */} patientNames.photos?.[patientId]} onOpenFullDay={() => navigate("/dashboard/appointments")} // NOT `!boardIncomplete`. An incomplete read still read SOMETHING, and its own banner above // carries the doubt for the whole board - suppressing the zeroes as well would leave a // half-loaded board with a warning and four unexplained blanks under it. countsRead={frontDeskCountsAreRead({ appointmentsAnswered, loadingAppointments, loadingEncounters, answeredFor: countsAnsweredFor, showingFor: boardKey, })} rooming={roomsInUse} />
{/* πŸ”’ THE RAIL LISTS WHAT IS OWED, NOT WHAT IS UNREAD (Spencer, 2026-08-13). The two panels it replaces showed every thread that existed, newest first, read or not, answered or not β€” which on a busy morning is a list of everything that has ever happened rather than a list of what the practice still has to do. */} navigate("/dashboard/staff-messages", { state: { tab: "colleagues", openRequestId: item.id }, }) } onOpenAllMessages={(tab) => navigate("/dashboard/staff-messages", { state: { tab } }) } onDismissPatientThread={(item) => patientNeedsReply.dismiss(item.id, item.latestMessageId ?? '')} onDismissColleagueThread={(item) => colleagueNeedsReply.dismiss(item.id, item.latestMessageId ?? '')} isDismissing={(id) => patientNeedsReply.isDismissing(id) || colleagueNeedsReply.isDismissing(id)} loadingMessages={loadingMessages} messagesError={messagesError} newFormsSubmitted={newFormSubmissionsCount} newFormsStatus={newFormSubmissionsStatus} onRetryNewForms={() => { void refreshNewForms(); }} newFormSubmissions={formSubmissionItems} onOpenFormSubmissions={() => navigate('/dashboard/form-submissions')} onToday={onTodayStaff} roleOptions={onTodayRoleOptions} roleFilter={onTodayRoleFilter} onRoleFilterChange={setOnTodayRoleFilter} // Clicking a person opens the quick-message composer IN PLACE - settled 2026-07-14, and // the point of it is that the front desk never leaves the board to send one line. // Messaging yourself is not a thing; that row opens their day instead. onOpenPerson={(item) => { if (item.practitioner && item.practitionerId !== currentUser?.fhir?.id) { setQuickMessageTarget(item.practitioner); return; } openStaffSchedule(item); }} // - the "On today" heading is a door to the full schedule. A plain navigate lands // on today: `SchedulesPage` holds its range in local state initialised to today, with no URL // parameter and no storage, and this rail is on another route so that page always mounts // fresh. The prop's own comment records what to change if it ever remembers a date. onOpenScheduleToday={() => navigate("/dashboard/schedules")} loadingStaff={loadingPractitioners} staffError={practitionersError} />
void refreshAppointmentSupport()} statusUpdating={Boolean(selectedStatusFlow?.updating)} statusError={selectedStatusFlow?.error} statusRefusal={selectedStatusFlow?.refusal} statusNote={selectedStatusFlow?.note} requestedStatus={selectedStatusFlow?.requested} statusUnknown={Boolean(selectedAppointment?.id && unconfirmedStatuses[selectedAppointment.id])} onCheckStatus={() => void handleCheckSelectedAppointmentStatus()} statusStale={Boolean(selectedAppointment?.id && staleStatuses[selectedAppointment.id])} onReloadStatus={() => void handleReloadSelectedAppointment()} onRetryStatus={ selectedStatusFlow?.requested && !(selectedAppointment?.id && (unconfirmedStatuses[selectedAppointment.id] || staleStatuses[selectedAppointment.id])) ? () => void handleSelectedAppointmentStatusChange(selectedStatusFlow.requested as ReceptionistAppointmentStatus) : undefined } highlightStatus={highlightAppointmentStatus} onOpenChange={handleAppointmentDetailsOpenChange} onEdit={handleEditSelectedAppointment} onStatusChange={handleSelectedAppointmentStatusChange} onViewProfile={selectedPatientId ? handleViewSelectedPatientProfile : undefined} onDismissStatusError={() => { if (selectedAppointment?.id) patchStatusFlow(selectedAppointment.id, { error: undefined, requested: undefined }); }} checkout={selectedAwaitsCheckout ? { onInvoices: mayOpenPatientInvoices(activeWorkspace, canView) ? () => void handleCheckoutInvoices() : undefined, onScheduleFollowUp: () => void handleCheckoutFollowUp(), onDone: () => void handleCheckoutDone(), saving: checkoutSaving || checkoutUnknown, error: checkoutError, note: checkoutNote, } : undefined} checkoutSavedNote={selectedAwaitsCheckout ? undefined : checkoutNote} /> {showAppointmentEdit && selectedAppointment && ( { if (open) { setShowAppointmentEdit(true); } else { handleCloseAppointmentEdit(); } }} > {/* THE NAME A SCREEN READER HEARS, and it has to be the name on the screen. Placing a request and editing a booking are different jobs, and the visible title already says which; announcing "Edit Appointment" over a dialog headed "Place Appointment" corrects the sighted reader and nobody else. */} {isAppointmentPlacement(selectedAppointment) ? 'Place Appointment' : 'Edit Appointment'} Update the patient, practitioner, appointment type, date, time, status, and optional tasks. { void refreshAppointmentSupport(); }} onUpdated={() => { handleCloseAppointmentEdit(); void refreshAppointmentSupport(); }} /> )} { setShowPatientQuickView(open); if (!open) setWorkspaceInitialTab(undefined); }} onBack={handleBackFromPatientQuickView} initialTab={workspaceInitialTab} backLabel={selectedAppointment ? "Back to appointment" : undefined} onPatientUpdated={handlePatientUpdated} /> {deskQuickViewId && ( setDeskQuickViewId(undefined)} onBack={handleBackFromDeskQuickView} backLabel={selectedAppointment ? "Back to appointment" : undefined} /> )} { setShowRegisterPatient(next); // The handoff is spent once the dialog closes. Leaving it would re-seed the NEXT patient a // receptionist registers with the previous one is names. if (!next) setRegisterPrefill(undefined); }} onRegistered={handlePatientRegistered} prefill={registerPrefill} /> { setShowBookingModal(open); if (!open) setFollowUpPatient(undefined); }} > Book Appointment Search for a patient, select the visit details, and optionally assign patient tasks. { if (createdAppointment.id) { // The shared hold carries it across the refresh below, which searches before the index has it. setAppointments((currentAppointments) => [ createdAppointment, ...currentAppointments.filter( (appointment) => appointment.id !== createdAppointment.id, ), ]); } }} onBooked={() => { setShowBookingModal(false); setFollowUpPatient(undefined); void loadOverviewAppointments(); void refreshAppointmentSupport(); }} /> setQuickMessageTarget(undefined)} /> ); }