import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAcceptedTeamVisits, teamVisitEvents } from '../../../hooks/useAcceptedTeamVisits'; import { Spinner } from "../../../components/ui/spinner"; import { DismissibleAlert } from '../../../components/ui/dismissible-alert'; import { findOwnSchedule, shouldSeedOwnSchedule } from '../../../utils/booking/ownSchedule'; import dayGridPlugin from '@fullcalendar/daygrid'; import interactionPlugin from '@fullcalendar/interaction'; import listPlugin from '@fullcalendar/list'; import { practiceTimeZoneOf, viewerZoneLabelFor } from '../../../utils/practiceTimezone'; import FullCalendar from '@fullcalendar/react'; import timeGridPlugin from '@fullcalendar/timegrid'; import type { DateSelectArg, DatesSetArg, EventClickArg, EventContentArg, EventInput, } from '@fullcalendar/core'; import type { Appointment, Patient, PractitionerRole, Schedule, Slot, } from 'fhir/r4'; import { CalendarDays, ChevronLeft, ChevronRight, List as ListIcon, Plus } from 'lucide-react'; import { useLocation, useNavigate } from 'react-router-dom'; import { readFrontDeskAction } from '../../../components/PortalFrameHeader/frontDeskHeaderAction'; import { useAppointmentServiceContext, useAuthServiceContext, useFhirServiceContext, usePractitionerRoleServiceContext, usePractitionerServiceContext, usePatientServiceContext, useScheduleServiceContext, useSurfaceExpectedPatient, useWebSocketServiceContext, } from '../../../services'; import type { PractitionerWithRoles } from '../../../services/PractitionerService.tsx'; import AppointmentsFilterRow from './AppointmentsFilterRow'; import type { PractitionerComboOption } from './PractitionerCombobox'; import { ALL_LOCATIONS_KEY, filterAppointmentsToLocation, locationFilterOptions } from './appointmentLocationFilter'; import { useFhirPractitioners, useFhirSchedules, useRouteAuthorization } from '../../../hooks'; import { useFhirLocations } from '../../../hooks/useFhirLocations.tsx'; import { useIsTabletOrNarrower } from '../../../hooks/useIsPhoneWidth'; import { scheduleSiteFor } from '../../../utils/booking/physicalSite'; import { resolveAppointmentPatientLabel, useAppointmentPatientNames, } from '../../../hooks/useAppointmentPatientNames'; import { resolveAppointmentPractitionerLabel } from '../../../utils/appointmentParticipants.ts'; import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger, } from '../../../components/ui/dialog'; import ReceptionistBookAppointment from '../ReceptionistHomePage/ReceptionistBookAppointment.tsx'; import ReceptionistAppointmentDetailsDialog from '../ReceptionistHomePage/ReceptionistAppointmentDetailsDialog.tsx'; import ReceptionistPatientQuickViewDialog from '../ReceptionistHomePage/ReceptionistPatientQuickViewDialog.tsx'; import { holdBroadcastAppointment } from '../ReceptionistHomePage/receptionistAppointmentBroadcast.ts'; import { getAppointmentPatientId, useReceptionistAppointmentSupport, useReceptionistPatientAppointments, } from '../ReceptionistHomePage/useReceptionistAppointmentSupport.ts'; import { applyReceptionistAppointmentStatus, checkStatusChange, STATUS_CHANGE_SUPERSEDED, getAppointmentArrivalTimeliness, getReceptionistAppointmentStatus, } from '../ReceptionistHomePage/receptionistAppointmentStatus.ts'; import { latestKnown } from '../../../hooks/useAppointmentEditBasis'; import type { ReceptionistAppointmentStatus } from '../ReceptionistHomePage/receptionistAppointmentStatus.ts'; import { treatmentAgreementRefusal } from '../../../utils/treatmentAgreementRefusal'; import { formatFhirHumanName } from '../../../utils/fhirHumanName.tsx'; // / D3 - the toolbar's count and its qualifier read ONE view. No local counting here. import { selectCounts, countWhere } from '../../../utils/counting/countingSelector.ts'; import { appointmentCountText } from './appointmentCountText'; import { appointmentWhere } from './appointmentWhere'; import { filterAppointmentsToPractitioner, groupSchedulesByPerson, scheduleActorSelection, schedulesActorSelection, } from './appointmentPractitionerFilter'; import './AppointmentsPage.css'; import '../ReceptionistHomePage/ReceptionistHomePage.css'; // - WordPress's ToggleControl, swapped for the ui/* adapter. Same props, same JSX; // only `__nextHasNoMarginBottom` is dropped, which means nothing outside WordPress. import { ToggleControl } from '../../../components/ui/toggle-control'; import { appointmentSubjectLine } from '../../../utils/appointmentSubject'; import { isAppointmentPlacement } from '../../../utils/appointmentPlacement'; import { appointmentFollowUpLines, appointmentVersion, COMPANIONS_UNCONFIRMED } from '../../../utils/appointmentVersion'; import { useAppointmentStatusHolds } from './useAppointmentStatusHolds'; import { settleUnknownWrite, writeOutcomeUnknown } from '../../../utils/appointmentWriteOutcome'; import { isRecordChanged } from '../../../utils/recordChanged'; import { isSaveRetired, useSaveScope } from '../../../utils/saveOperation'; import { useCancelledVisitResave } from './useCancelledVisitResave'; import { useDetailsOpening } from './useDetailsOpening'; import { revealWhenDrawn } from './revealWhenDrawn'; type AppointmentViewMode = 'list' | 'calendar'; type AppointmentRange = 'day' | 'week' | 'month' | 'year'; type AppointmentStatusDisplay = { key: string; label: string; }; const getReferenceId = (reference?: string) => { if (!reference) { return undefined; } const parts = reference.split('/'); return parts[parts.length - 1] || undefined; }; const getSchedulePractitionerRoleId = (schedule?: Schedule | null) => { const roleReference = schedule?.actor?.find((actor) => actor.reference?.startsWith('PractitionerRole/'), ); return getReferenceId(roleReference?.reference); }; const getPractitionerRoleForSchedule = ( schedule: Schedule | null | undefined, practitionersList: PractitionerWithRoles[], ) => { const roleId = getSchedulePractitionerRoleId(schedule); if (!roleId) { return undefined; } return practitionersList .flatMap((practitionerWithRoles) => practitionerWithRoles.roles) .find((role) => role.id === roleId); }; const getPractitionerForSchedule = ( schedule: Schedule | null | undefined, practitionersList: PractitionerWithRoles[], ) => { const roleId = getSchedulePractitionerRoleId(schedule); if (!roleId) { return undefined; } return practitionersList.find((practitionerWithRoles) => practitionerWithRoles.roles.some((role) => role.id === roleId), ); }; const getSchedulePractitionerDisplay = ( schedule: Schedule, practitionersList: PractitionerWithRoles[], ) => { const practitionerActor = schedule.actor?.find((actor) => actor.reference?.startsWith('Practitioner/'), ); const roleActor = schedule.actor?.find((actor) => actor.reference?.startsWith('PractitionerRole/'), ); const practitionerId = getReferenceId(practitionerActor?.reference); const practitionerWithRoles = getPractitionerForSchedule(schedule, practitionersList) || (practitionerId ? practitionersList.find((item) => item.practitioner.id === practitionerId) : undefined); return formatFhirHumanName(practitionerWithRoles?.practitioner.name, { emptyText: practitionerActor?.display?.trim() || roleActor?.display || 'Unnamed practitioner', }); }; // THE LOCAL PRACTITIONER HELPER THAT STOOD HERE IS GONE - it now lives in // `utils/appointmentParticipants.ts` alongside the patient resolver. There were THREE copies of this // idea across the app and they disagreed: this one resolved practitioners and had no path at all for a // patient, while the receptionist board and its dialog resolved NEITHER. One appointment therefore // named its practitioner here and read "Unknown Practitioner" one screen over. // Do not reintroduce a local copy. Same question same answer, everywhere. // WHAT THE VISIT IS FOR, from the one shared helper. This was a local copy that rendered the appointment // TYPE - which patient self-booking hardcodes to ROUTINE and never lets anybody change - in the slot a // reader scans for the service. The comment a few lines above is a warning written by whoever last paid // for three copies of one idea disagreeing; this is the next question, answered once. const getAppointmentTypeDisplay = (appointment: Appointment) => appointmentSubjectLine(appointment); const getAppointmentStatusDisplay = ( appointment: Appointment, ): AppointmentStatusDisplay => { switch (appointment.status) { case 'booked': return { key: 'booked', label: 'Booked' }; case 'arrived': { const timeliness = getAppointmentArrivalTimeliness(appointment); return timeliness === 'late' ? { key: 'arrived-late', label: 'Arrived โ€” Late' } : { key: 'arrived-on-time', label: 'Arrived โ€” On Time' }; } case 'cancelled': return { key: 'cancelled', label: 'Cancelled' }; case 'noshow': return { key: 'no-show', label: 'No Show' }; case 'checked-in': return { key: 'checked-in', label: 'Checked In' }; // - "Completed", the word the receptionist's own status control and the appointment // details dialog both use. `Fulfilled` was FHIR's term leaking onto a staff screen, and a third // spelling of one status is how two people describing the same appointment disagree. case 'fulfilled': return { key: 'fulfilled', label: 'Completed' }; case 'pending': return { key: 'pending', label: 'Pending' }; case 'proposed': return { key: 'proposed', label: 'Proposed' }; case 'waitlist': return { key: 'waitlisted', label: 'Waitlisted' }; case 'entered-in-error': return { key: 'entered-in-error', label: 'Entered in Error' }; default: return { key: 'unknown', label: 'Unknown' }; } }; const appointmentStatusMark = (key: string) => { if (['arrived-late', 'no-show'].includes(key)) return 'var(--danger)'; if (key === 'fulfilled') return 'var(--primary)'; if (['cancelled', 'entered-in-error', 'unknown'].includes(key)) return 'var(--faint)'; return 'var(--sage)'; }; const isAppointmentWithinRange = ( appointment: Appointment, rangeStart: Date, rangeEnd: Date, ) => { if (!appointment.start) { return false; } const appointmentStart = new Date(appointment.start).getTime(); const rangeStartTimestamp = rangeStart.getTime(); const rangeEndTimestamp = rangeEnd.getTime(); if (Number.isNaN(appointmentStart)) { return false; } if (appointment.end) { const appointmentEnd = new Date(appointment.end).getTime(); if (!Number.isNaN(appointmentEnd)) { return ( appointmentStart < rangeEndTimestamp && appointmentEnd > rangeStartTimestamp ); } } return ( appointmentStart >= rangeStartTimestamp && appointmentStart < rangeEndTimestamp ); }; const getFullCalendarView = ( viewMode: AppointmentViewMode, range: AppointmentRange, ) => { if (range === 'year') { return 'listYear'; } if (viewMode === 'list') { const listViews: Record, string> = { day: 'listDay', week: 'listWeek', month: 'listMonth', }; return listViews[range]; } const calendarViews: Record, string> = { day: 'timeGridDay', week: 'timeGridWeek', month: 'dayGridMonth', }; return calendarViews[range]; }; const AppointmentsPage = () => { const navigate = useNavigate(); const { USER_ROLES, wpOrganization, currentUser, userRole } = useAuthServiceContext(); const location = useLocation(); // The front desk's view of this page, as the receptionist deck draws it. const isDesk = userRole === USER_ROLES.RECEPTIONIST; // Week columns at tablet width hold about one name; overlapping visits go side by side, not on top. const tabletOrNarrower = useIsTabletOrNarrower(); /** * Whose clock is this whole calendar on? * * A BANNER, NOT A SUFFIX ON EVERY EVENT, and the reason is the TIME AXIS. FullCalendar draws its * own hour gutter (7am, 8am,...) as well as the events; labelling each event while the axis beside it * stayed bare would be worse than saying nothing, because the two would appear to disagree. One * statement covers the entire grid, axis included. * * Keyed on the ORGANIZATION rather than a per-appointment site, deliberately: a calendar spans * every site at once, so there is no single "the practice's clock" to name per row. The org is the * honest page-level proxy, and the sentence stays true regardless - the times ARE the viewer's. * Hidden entirely when the clocks match, which is the ordinary in-practice case. */ const viewerZoneLabel = useMemo( () => viewerZoneLabelFor(new Date(), practiceTimeZoneOf(wpOrganization.current ?? undefined)), [wpOrganization], ); const { subscribe, unsubscribe } = useWebSocketServiceContext(); // - the calendar was the ONE availability caller still reading a block in the // organization's clock. `useFhirLocations` reads the shared LocationService list (5-minute cache, and // an early return on a hit), so this is the list every other staff surface already holds rather than // a second fetch for this page. const { locationsList } = useFhirLocations(); const { transformFromFhirPractitionerRole, } = usePractitionerRoleServiceContext(); const { schedulesList, getScheduleAvailabilityEvents, fetchSlots, transformSlotToFullCalendarEvent, } = useScheduleServiceContext(); const { transformAppointmentToFullCalendarEvent, updateAppointment, fetchAppointment, fetchAllAppointmentsForOrgRange, } = useAppointmentServiceContext(); const { fetchPatient, setPatientState } = usePatientServiceContext(); // - declares AND releases on unmount; see the hook. const declareExpectedPatient = useSurfaceExpectedPatient(); const { practitionersList } = usePractitionerServiceContext(); const { filterAndUnpackBundle, } = useFhirServiceContext(); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); // The last appointment load failed, so the list and count are unknown rather than empty. const [appointmentsFailed, setAppointmentsFailed] = useState(false); const [selectedSchedule, setSelectedSchedule] = useState(null); const [locationFilterKey, setLocationFilterKey] = useState(ALL_LOCATIONS_KEY); const [practitionerRole, setPractitionerRole] = useState(null); const [, setPractitionerId] = useState(); const [appointments, setAppointments] = useState([]); const [appointmentScheduleIds, setAppointmentScheduleIds] = useState>({}); const [showAvailability, setShowAvailability] = useState(true); /** * DERIVED, NOT FETCHED - and that change is what makes fix land here at all. * * This used to be `useState` written from inside `loadAppointments`, which meant the availability was * computed ONCE against whatever the appointment fetch happened to see. Locations arrive on their own * schedule, so passing a site into a value computed there would have been **a silent no-op whenever * the list had not arrived yet** - the fix would look done and the blocks would still be read in the * organization's clock. Availability is a pure function of the schedule, the role and the site; it * was never fetch state. * * It also removes three `setAvailabilityEvents([])` resets: selecting a schedule now paints its * availability immediately instead of blanking until the round trip returns. */ const availabilityEvents = useMemo(() => { if (!selectedSchedule || !practitionerRole) { return []; } return getScheduleAvailabilityEvents( selectedSchedule, transformFromFhirPractitionerRole(practitionerRole), // The PHYSICAL site, never `location[0]` - a video-enabled block carries a virtual Location // actor too, and a virtual location has no clock. scheduleSiteFor(selectedSchedule, practitionerRole ?? undefined, locationsList), ) as EventInput[]; }, [ getScheduleAvailabilityEvents, locationsList, practitionerRole, selectedSchedule, transformFromFhirPractitionerRole, ]); const [slotEvents, setSlotEvents] = useState([]); const [showSlots, setShowSlots] = useState(false); const [viewMode, setViewMode] = useState('list'); const [range, setRange] = useState('day'); const [calendarTitle, setCalendarTitle] = useState('Today'); const [showAppointmentModal, setShowAppointmentModal] = useState(false); const [showAppointmentDetails, setShowAppointmentDetails] = useState(false); const [showAppointmentEdit, setShowAppointmentEdit] = useState(false); const [showPatientQuickView, setShowPatientQuickView] = useState(false); const [showBookingModal, setShowBookingModal] = useState(false); const [selectedTimeRange, setSelectedTimeRange] = useState<{ start: string; end: string } | null>(null); const [selectedAppointment, setSelectedAppointment] = useState(); const [selectedAppointmentSchedule, setSelectedAppointmentSchedule] = useState(); const [appointmentModalSchedule, setAppointmentModalSchedule] = useState(null); // (The dragged practitioner ROLE is no longer held separately: the booking form resolves the role // from the schedule it is seeded with, which is also what makes it write the PractitionerRole // participant the old calendar modal omitted.) const [quickViewPatient, setQuickViewPatient] = useState(); const [loadingQuickViewPatient, setLoadingQuickViewPatient] = useState(false); const [quickViewPatientError, setQuickViewPatientError] = useState(); const [appointmentStatusUpdating, setAppointmentStatusUpdating] = useState(false); // A status change belongs to the appointment selected and the person signed in; another of either retires it. // A status change belongs to the appointment selected and the person signed in; another of either retires it, // and its busy state with it, so it never clears or holds a later selection's. const statusScope = useSaveScope(selectedAppointment?.id, () => setAppointmentStatusUpdating(false)); const [appointmentStatusNote, setAppointmentStatusNote] = useState([]); const [appointmentStatusError, setAppointmentStatusError] = useState(); const [appointmentStatusRefusal, setAppointmentStatusRefusal] = useState(); const { held: heldStatus, hold: holdStatus, release: releaseStatusHold } = useAppointmentStatusHolds(selectedAppointment?.id); const calendarRef = useRef(null); const calendarShellRef = useRef(null); // The hidden visits open where their link is, and one near the foot of a tablet screen opened below it. const showHiddenVisits = useCallback(() => { revealWhenDrawn(calendarShellRef.current, '.fc-more-popover'); return 'popover' as const; }, []); const visibleRangeRef = useRef<{ start: Date; end: Date } | null>(null); const loadAppointmentsRequestIdRef = useRef(0); // What was just booked or broadcast is laid over the range read by the shared hold, in the service. // The appointment load waits on the schedules read, so a schedules read that has not answered or // failed is never shown as a day with no appointments. const { error: schedulesError, schedulesBundle: schedulesRead } = useFhirSchedules(); useFhirPractitioners(); useRouteAuthorization({ allowedRoles: [USER_ROLES.PRACTITIONER, USER_ROLES.RECEPTIONIST], }); const schedulesById = useMemo( () => new Map( schedulesList .filter((schedule): schedule is Schedule & { id: string } => Boolean(schedule.id)) .map((schedule) => [schedule.id, schedule]), ), [schedulesList], ); const { appointments: selectedPatientAppointments, loading: loadingSelectedPatientAppointments, error: selectedPatientAppointmentsError, refresh: refreshSelectedPatientAppointments, } = useReceptionistPatientAppointments( selectedAppointment, Boolean(selectedAppointment), ); const selectedAppointmentSupportAppointments = useMemo(() => { if (!selectedAppointment) { return []; } const appointmentsById = new Map(); [...selectedPatientAppointments, selectedAppointment].forEach((appointment, index) => { const key = appointment.id || `${appointment.start || 'no-start'}:${appointment.end || 'no-end'}:${index}`; appointmentsById.set(key, appointment); }); return Array.from(appointmentsById.values()); }, [selectedAppointment, selectedPatientAppointments]); const { appointmentTasksById, taskErrorsByAppointmentId, patientReadinessById, readinessErrorsByPatientId, loading: loadingAppointmentSupport, refresh: refreshAppointmentSupport, } = useReceptionistAppointmentSupport(selectedAppointmentSupportAppointments); const selectedAppointmentId = selectedAppointment?.id; const selectedPatientId = selectedAppointment ? getAppointmentPatientId(selectedAppointment) : undefined; const selectedAppointmentTasks = selectedAppointmentId ? appointmentTasksById[selectedAppointmentId] || [] : []; const selectedAppointmentTasksLoaded = selectedAppointmentId ? Object.prototype.hasOwnProperty.call(appointmentTasksById, selectedAppointmentId) : false; const selectedPatientReadiness = selectedPatientId ? patientReadinessById[selectedPatientId] : undefined; const findScheduleForAppointment = useCallback((appointment: Appointment) => { const practitionerReference = appointment.participant?.find((participant) => participant.actor?.reference?.startsWith('Practitioner/') || participant.actor?.reference?.startsWith('PractitionerRole/'), )?.actor?.reference; const practitionerId = practitionerReference?.startsWith('Practitioner/') ? getReferenceId(practitionerReference) : undefined; const practitionerRoleId = practitionerReference?.startsWith('PractitionerRole/') ? getReferenceId(practitionerReference) : undefined; if (!practitionerId && !practitionerRoleId) { return undefined; } return schedulesList.find((schedule) => { const directMatch = schedule.actor?.some((actor) => (practitionerId && actor.reference === `Practitioner/${practitionerId}`) || (practitionerRoleId && actor.reference === `PractitionerRole/${practitionerRoleId}`), ); if (directMatch) { return true; } const scheduleRole = getPractitionerRoleForSchedule(schedule, practitionersList); return ( (practitionerRoleId && scheduleRole?.id === practitionerRoleId) || ( practitionerId && getReferenceId(scheduleRole?.practitioner?.reference) === practitionerId ) ); }); }, [practitionersList, schedulesList]); const loadAppointments = useCallback(async () => { if (!visibleRangeRef.current) { return; } const requestId = ++loadAppointmentsRequestIdRef.current; const targetSchedules = selectedSchedule ? [selectedSchedule] : schedulesList.filter((schedule) => Boolean(schedule.id)); if (targetSchedules.length === 0 && !selectedSchedule && !schedulesRead && !schedulesError) { // Schedules are still being read; this load runs again when they arrive. setLoading(true); return; } if (targetSchedules.length === 0 && !selectedSchedule && schedulesError) { setAppointments([]); setAppointmentScheduleIds({}); setSlotEvents([]); setAppointmentsFailed(true); setError('Schedules could not be loaded, so appointments cannot be listed. Please try again.'); setLoading(false); return; } if (targetSchedules.length === 0) { setAppointmentsFailed(false); setAppointments([]); setAppointmentScheduleIds({}); setSlotEvents([]); setLoading(false); return; } setLoading(true); setError(undefined); const requestedRange = { start: new Date(visibleRangeRef.current.start), end: new Date(visibleRangeRef.current.end), }; const { start, end } = requestedRange; try { const results = await Promise.allSettled( targetSchedules.map(async (schedule) => ({ schedule, bundle: await fetchSlots( schedule, start.toISOString(), end.toISOString(), ), })), ); const nextAppointments = new Map(); const nextAppointmentScheduleIds: Record = {}; const nextSlots = new Map(); let failedScheduleCount = 0; /* - APPOINTMENTS NO LONGER COME FROM THE SLOT BUNDLE. This surface used to read them out of `Slot?...&_revinclude=Appointment:slot`, and a revinclude on `Appointment:slot` returns ONLY appointments that reference a slot. Measured on Great River: 28 appointments in the org, 13 reachable - the calendar was silently missing 15. Spencer's ruling: this is a RECORD surface and always shows cancelled ones; the day board is a work queue and keeps excluding them. THE SLOT QUERY STAYS, AND IS NOT A SECOND SOURCE OF APPOINTMENTS. It is what produces AVAILABILITY (free slots to book into), which is a different question from what is booked. The card is explicit: replace the source, do not run the two side by side and merge - two answers to "which appointments exist" is how they drift. */ results.forEach((result) => { if (result.status !== 'fulfilled') { failedScheduleCount += 1; console.error('Failed to load availability for a schedule:', result.reason); return; } const { bundle } = result.value; (filterAndUnpackBundle(bundle, 'Slot') as Slot[]).forEach((slot) => { if (slot.id) { nextSlots.set(slot.id, slot); } }); }); /* The appointments themselves, from a source with no slot in it. Still range-filtered client-side: `date` bounds the appointment's START, and `isAppointmentWithinRange` is what the rest of this page's overlap logic already agrees with. */ const { appointments: rangeAppointments, truncated } = await fetchAllAppointmentsForOrgRange( start.toISOString(), end.toISOString(), ); rangeAppointments.forEach((appointment) => { if (!appointment.id || !isAppointmentWithinRange(appointment, start, end)) { return; } nextAppointments.set(appointment.id, appointment); }); /* The schedule an appointment belongs to is now derived from its own slot where it HAS one, rather than from which schedule's bundle it arrived in. An appointment with no slot simply has no schedule id here - which is correct and is the whole point of the card: it is a real appointment that no schedule query would ever have returned. */ results.forEach((result) => { if (result.status !== 'fulfilled') return; const { schedule, bundle } = result.value; if (!schedule.id) return; const slotIds = new Set( (filterAndUnpackBundle(bundle, 'Slot') as Slot[]).map((s) => s.id).filter(Boolean), ); nextAppointments.forEach((appointment) => { const slotRef = appointment.slot?.[0]?.reference?.split('/')[1]; if (appointment.id && slotRef && slotIds.has(slotRef)) { nextAppointmentScheduleIds[appointment.id] = schedule.id as string; } }); }); if (truncated) { // SAID, never swallowed. A record view quietly missing entries is worse than one that is // obviously empty - that is this card's own principle, and it would be self-defeating to // replace a slot-shaped incompleteness with a page-shaped one in silence. console.error( 'APPOINTMENT_RANGE_TRUNCATED: hit the page bound loading appointments for this range; ' + 'the calendar is showing a subset. Move this aggregation server-side rather than ' + 'raising the cap (card 293).', ); } if (requestId !== loadAppointmentsRequestIdRef.current) { return; } // targetSchedules narrows the SLOT queries; the org-wide appointment fetch beside it does not, // so without this the practitioner filter changed its own label and nothing else. Read off // selectedSchedule rather than practitionerRole because this callback deliberately does not // depend on that object - see the note under its dependency list. // EVERY schedule this person has, not just the one behind the option. The list merged them; // matching on one hat would drop any appointment recorded against the other, and the screen // would look exactly as correct as it does now. const selection = selectedSchedule ? schedulesActorSelection( (selectedSchedule.id ? schedulesForOptionKey.get(selectedSchedule.id) : undefined) ?? [selectedSchedule], ) : undefined; const visibleAppointments = filterAppointmentsToPractitioner( Array.from(nextAppointments.values()), selection, ); setAppointments(visibleAppointments); setAppointmentsFailed(false); setAppointmentScheduleIds(nextAppointmentScheduleIds); if (selectedSchedule) { setSlotEvents( Array.from(nextSlots.values()).map( (slot) => transformSlotToFullCalendarEvent(slot) as EventInput, ), ); } else { setSlotEvents([]); } /* * THIS BANNER NAMED THE WRONG SUBJECT, and it accused the one thing on screen that was * correct. * * Observed on the receptionist's Appointments screen: *"Failed to load appointments. * Please try again."* sitting ABOVE two appointments that had loaded, under a header * correctly reading *"2 appointments - 1 cancelled"*. What had actually failed was the * `Slot` search - `424, "Timeout during custom Idp execution"`. * * `failedScheduleCount` COUNTS AVAILABILITY FAILURES, NOT APPOINTMENT ONES, and * is why: appointments were moved OFF the slot bundle when a `_revinclude` was found to be * hiding 15 of 28. Since then a slot failure cannot mean the list failed - they come from * different requests - but this message had not moved with them. * * AND TELLING THEM THE LIST FAILED IS WORSE THAN SILENCE. The next thing a receptionist * does is refresh a screen that was already right, and then distrust it. The honest message * is that the BOOKING GRID is incomplete while the list below it is not. * * AND THE GUARD IS NOT DECORATION: `failedScheduleCount === targetSchedules.length` is * `0 === 0` for a practice with NO practitioner schedules configured, so a brand-new practice * was told its appointments had failed to load. "All of them" has to mean all of something. * * The upstream timeout is its own question. This is what the screen says when it happens, * which has to be right either way because it will happen again. */ if (targetSchedules.length > 0 && failedScheduleCount === targetSchedules.length) { setError( 'Availability could not be loaded, so open slots may be missing from the calendar. ' + 'The appointments listed are correct.', ); } else if (failedScheduleCount > 0) { setError( `Availability could not be loaded for ${failedScheduleCount} ` + `${failedScheduleCount === 1 ? 'practitioner schedule' : 'practitioner schedules'}, ` + 'so open slots may be missing from the calendar. The appointments listed are correct.', ); } } catch (loadError) { if (requestId !== loadAppointmentsRequestIdRef.current) { return; } console.error('Failed to load appointments:', loadError); // The previous range's rows are not this range's, so nothing is left standing in for them. setAppointments([]); setSlotEvents([]); setAppointmentsFailed(true); setError('Could not load appointments. Try again.'); } finally { if (requestId === loadAppointmentsRequestIdRef.current) { setLoading(false); } } // ๐Ÿ“Œ `getScheduleAvailabilityEvents`, `transformFromFhirPractitionerRole` and `practitionerRole` // are gone from here because availability is no longer computed in this fetch โ€” it is derived // above. Leaving them would re-run a full appointment load whenever the role object changed. }, [ fetchAllAppointmentsForOrgRange, fetchSlots, filterAndUnpackBundle, schedulesList, schedulesRead, schedulesError, selectedSchedule, transformSlotToFullCalendarEvent, ]); useEffect(() => { if (visibleRangeRef.current) { void loadAppointments(); } }, [loadAppointments]); useEffect(() => { const handleAppointmentEvent = (cloudEvent: unknown) => { // A booking made elsewhere is not searchable for minutes, so hold the broadcast's copy first. holdBroadcastAppointment(cloudEvent); void loadAppointments(); 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); }; }, [loadAppointments, refreshSelectedPatientAppointments, subscribe, unsubscribe]); useEffect(() => { const calendarApi = calendarRef.current?.getApi(); if (!calendarApi) { return; } const nextView = getFullCalendarView(viewMode, range); if (calendarApi.view.type !== nextView) { calendarApi.changeView(nextView); } }, [range, viewMode]); const clearSelectedSchedule = () => { setSelectedSchedule(null); setPractitionerRole(null); setPractitionerId(undefined); setSlotEvents([]); setShowSlots(false); }; /* - OPEN ON THE CLINICIAN'S OWN CALENDAR. Spencer, 2026-08-14: *"yes it should open on theirs."* This page never referenced the signed-in user at all, so the calendar stayed empty until somebody was picked out of the colleague SEARCH - meaning **a clinician had to look themselves up by name to see their own week**, and this is the only week view in the product. It SEEDS, it never overrides: `shouldSeedOwnSchedule` runs once and only while nothing is selected, so a re-fetch or a WebSocket update cannot yank the calendar back off a colleague the user chose. A receptionist or admin with no clinical role finds no own-schedule and is left exactly as before - the picker is their normal path and must not be disturbed. */ const seededOwnSchedule = useRef(false); useEffect(() => { // The desk opens on every practitioner, as its deck draws; only a clinician opens on their own. if (isDesk) return; if (!shouldSeedOwnSchedule(Boolean(selectedSchedule), seededOwnSchedule.current)) return; if (schedulesList.length === 0 || practitionersList.length === 0) return; const own = findOwnSchedule({ schedules: schedulesList, practitionerId: currentUser?.fhir?.id, practitionerIdForSchedule: (schedule) => getReferenceId( getPractitionerRoleForSchedule(schedule, practitionersList)?.practitioner?.reference, ), }); // Marked seeded either way: a clinician with no schedule must not be re-checked on every render, // and a viewer with no clinical role must not have the picker cleared for them. seededOwnSchedule.current = true; if (own) handleScheduleChange(own); }, [schedulesList, practitionersList, selectedSchedule, currentUser, isDesk]); // The header's Book opens the booking over this page rather than leaving it. useEffect(() => { if (readFrontDeskAction(location.state) !== 'book') return; setShowBookingModal(true); navigate(location.pathname, { replace: true, state: null }); }, [location.pathname, location.state, navigate]); const handleScheduleChange = (schedule: Schedule) => { const selectedPractitionerRole = getPractitionerRoleForSchedule( schedule, practitionersList, ); if (!selectedPractitionerRole) { setError('Practitioner role not found for the selected schedule.'); clearSelectedSchedule(); return; } const selectedPractitionerId = getReferenceId( selectedPractitionerRole.practitioner?.reference, ); setError(undefined); setSelectedSchedule(schedule); setPractitionerRole(selectedPractitionerRole); setPractitionerId(selectedPractitionerId); setSlotEvents([]); }; const handlePractitionerComboSelect = (scheduleId: string | undefined) => { const schedule = scheduleId ? schedulesList.find((item) => item.id === scheduleId) : undefined; if (schedule) handleScheduleChange(schedule); else clearSelectedSchedule(); }; // A practitioner's own calendar also shows the team video visits they accepted, including ones another // organization hosts. The front desk works other people's calendars, so it does not load the desk member's own. const showsOwnTeamVisits = currentUser?.fhir?.resourceType === 'Practitioner' && !isDesk; const { visits: teamVisits, failed: teamVisitsFailed, load: loadTeamVisits } = useAcceptedTeamVisits(); const handleDatesSet = useCallback((dateInfo: DatesSetArg) => { visibleRangeRef.current = { start: new Date(dateInfo.start), end: new Date(dateInfo.end), }; setCalendarTitle(dateInfo.view.title || 'Appointments'); void loadAppointments(); if (showsOwnTeamVisits) void loadTeamVisits(new Date(dateInfo.start), new Date(dateInfo.end)); }, [loadAppointments, loadTeamVisits, showsOwnTeamVisits]); // - resolves each DISTINCT patient referenced by the visible appointments, once. const patientNames = useAppointmentPatientNames(appointments); const locationOptions = useMemo(() => locationFilterOptions(locationsList), [locationsList]); const shownAppointments = useMemo( () => filterAppointmentsToLocation( appointments, locationFilterKey === ALL_LOCATIONS_KEY ? undefined : locationFilterKey, ), [appointments, locationFilterKey], ); const appointmentEvents = useMemo( () => shownAppointments.map((appointment): EventInput => { const scheduleId = appointment.id ? appointmentScheduleIds[appointment.id] : undefined; const schedule = scheduleId ? schedulesById.get(scheduleId) : selectedSchedule || undefined; const fallbackPractitioner = schedule ? getSchedulePractitionerDisplay(schedule, practitionersList) : 'Unknown practitioner'; // The fallback is the SCHEDULE's practitioner, which is better than a bare "Unknown" and // is the one thing this call site adds over the shared resolver's default. const practitionerName = resolveAppointmentPractitionerLabel( appointment, practitionersList, fallbackPractitioner, ); // - resolved from the REFERENCE, with `actor.display` as an optimisation only. // The helper this used to call resolved a live record for Practitioner/PractitionerRole and // had no path at all for a Patient reference: it fell through to // `display || 'Unknown patient'`, so a row could name the patient only if the record // happened to carry a denormalised display. 19 of 27 carry none. const patientName = resolveAppointmentPatientLabel(appointment, patientNames); const appointmentType = getAppointmentTypeDisplay(appointment); const statusDisplay = getAppointmentStatusDisplay(appointment); const baseEvent = transformAppointmentToFullCalendarEvent(appointment) as EventInput; return { ...baseEvent, title: patientName, extendedProps: { ...(baseEvent.extendedProps || {}), type: 'appointment', patientName, practitionerName, appointmentType, appointmentStatusKey: statusDisplay.key, appointmentStatusLabel: statusDisplay.label, scheduleId, // The clinician's deck tags each row with where it happens; the desk's deck does not. where: isDesk ? undefined : appointmentWhere(appointment, locationsList), }, classNames: [...((baseEvent.classNames as string[] | undefined) ?? []), `ahr-appt--${statusDisplay.key}`], // A neutral block and a status-coloured mark, from the palette rather than fixed hues. backgroundColor: 'var(--green-soft)', borderColor: appointmentStatusMark(statusDisplay.key), textColor: 'var(--ink-strong)', }; }), [ appointmentScheduleIds, shownAppointments, // - load-bearing: the names arrive AFTER this memo first runs, so without this // dependency every row would keep the "Loading patient..." it was first built with. patientNames, practitionersList, schedulesById, selectedSchedule, transformAppointmentToFullCalendarEvent, isDesk, locationsList, ], ); /** * / D3 - the toolbar's count and its qualifier come from ONE view. * * WHY THIS MOVED OFF A LOCAL `.filter()`. B28's first fix derived the cancelled tally from its own * pass over `appointmentEvents` while the total came from `appointmentEvents.length` - two numbers in * one sentence, computed in two places, free to drift the moment either changes. That is the SAME * SHAPE as the defect being fixed, which is exactly what D3 says a per-component count does. * * `countWhere` counts inside the view, so "3 appointments - 1 cancelled" describes one set by * construction. `utils/counting/countingSelector.ts` * * No `visibleLimit`: the calendar renders every event it is given, so `total === visible.length` * and `more` is 0 here. That is the honest shape for this surface - the peer-count level belongs to * the lists D3 describes, not to a calendar. */ const appointmentCounts = useMemo( () => selectCounts( appointmentEvents.map((event, index) => ({ id: String(event.id ?? index), label: (event.extendedProps as { appointmentStatusLabel?: string } | undefined) ?.appointmentStatusLabel, })), ), [appointmentEvents], ); const cancelledAppointmentCount = useMemo( () => countWhere(appointmentCounts, (item) => item.label === 'Cancelled'), [appointmentCounts], ); const calendarEvents = useMemo(() => { const events: EventInput[] = [...appointmentEvents]; if (showsOwnTeamVisits) { events.push(...teamVisitEvents(teamVisits, new Set(appointments.map((item) => item.id).filter((id): id is string => !!id)))); } if (selectedSchedule && viewMode === 'calendar') { if (showSlots) { events.push(...slotEvents); } if (showAvailability) { events.push(...availabilityEvents); } } return events; }, [ appointmentEvents, appointments, availabilityEvents, selectedSchedule, showAvailability, showSlots, showsOwnTeamVisits, slotEvents, teamVisits, viewMode, ]); const renderEventContent = useCallback((eventInfo: EventContentArg) => { const { extendedProps } = eventInfo.event; if (extendedProps.type !== 'appointment') { return ( {eventInfo.timeText ? `${eventInfo.timeText} ` : ''} {eventInfo.event.title} ); } const isListView = eventInfo.view.type.startsWith('list'); return (
{!isListView && eventInfo.timeText && ( {eventInfo.timeText} )}
{extendedProps.patientName || 'Unknown patient'} {extendedProps.appointmentStatusLabel || 'Unknown'}
{extendedProps.appointmentType || 'Appointment'} {extendedProps.practitionerName || 'Unknown practitioner'}
{isListView && extendedProps.where && ( {extendedProps.where.label} )}
); }, []); const handleEventClick = (info: EventClickArg) => { if (info.event.extendedProps.type !== 'appointment') { return; } const appointment = appointments.find((item) => item.id === info.event.id); if (!appointment) { return; } const scheduleId = appointment.id ? appointmentScheduleIds[appointment.id] : undefined; const schedule = scheduleId ? schedulesById.get(scheduleId) || findScheduleForAppointment(appointment) : selectedSchedule || findScheduleForAppointment(appointment); setSelectedAppointment(appointment); setSelectedAppointmentSchedule(schedule); setSelectedTimeRange(null); setShowAppointmentModal(false); setShowAppointmentEdit(false); setShowPatientQuickView(false); setQuickViewPatient(undefined); setQuickViewPatientError(undefined); setAppointmentStatusError(undefined); setAppointmentStatusRefusal(undefined); setShowAppointmentDetails(true); void refreshAppointmentSupport(); }; const handleCalendarSelect = (selectInfo: DateSelectArg) => { if (!selectedSchedule || !practitionerRole) { setError('Select a practitioner before creating an appointment from the calendar.'); selectInfo.view.calendar.unselect(); return; } setSelectedAppointment(undefined); setSelectedAppointmentSchedule(undefined); setShowAppointmentDetails(false); setShowAppointmentEdit(false); setShowPatientQuickView(false); setSelectedTimeRange({ start: selectInfo.startStr, end: selectInfo.endStr, }); setAppointmentModalSchedule(selectedSchedule); setShowAppointmentModal(true); selectInfo.view.calendar.unselect(); }; const handleAppointmentCreated = async (_appointment?: Appointment) => { setShowAppointmentModal(false); setSelectedTimeRange(null); setSelectedAppointment(undefined); setSelectedAppointmentSchedule(undefined); setSuccess('Appointment created successfully.'); setTimeout(() => setSuccess(undefined), 3_000); await loadAppointments(); }; const handleAppointmentDetailsOpenChange = (open: boolean) => { setShowAppointmentDetails(open); if (!open) { setAppointmentStatusError(undefined); setAppointmentStatusRefusal(undefined); } }; const handleEditSelectedAppointment = () => { setShowPatientQuickView(false); setShowAppointmentDetails(false); setShowAppointmentEdit(true); }; const handleViewSelectedPatientProfile = async () => { if (!selectedPatientId) { return; } setShowAppointmentDetails(false); setShowPatientQuickView(true); setLoadingQuickViewPatient(true); setQuickViewPatient(undefined); setQuickViewPatientError(undefined); try { const patient = await fetchPatient(selectedPatientId, false, undefined, true); setQuickViewPatient(patient); } catch (profileError) { console.error('Failed to load patient quick view:', profileError); setQuickViewPatient(undefined); setQuickViewPatientError('Failed to load the patient profile.'); } finally { setLoadingQuickViewPatient(false); } }; const handleBackFromPatientQuickView = () => { setShowPatientQuickView(false); setShowAppointmentDetails(Boolean(selectedAppointment)); }; const handleViewFullPatientProfile = () => { const patientId = quickViewPatient?.id || selectedPatientId; if (!patientId) { return; } if (quickViewPatient) { // - the id we are NAVIGATING to is the expectation, not the object we happen to // hold. That distinction is the point on this surface: `quickViewPatient` is whoever the // quick-view dialog last loaded, and if it ever disagreed with `patientId` the chart would // open on one patient while the URL named another - exact defect, arriving from a // different direction. Declaring the ROUTE's id makes that mismatch clear the context // instead of showing the wrong chart. declareExpectedPatient(patientId); setPatientState(quickViewPatient); } setShowPatientQuickView(false); navigate(`/dashboard/patients/${patientId}`); }; const handleCloseAppointmentEdit = () => { setShowAppointmentEdit(false); setShowAppointmentDetails(Boolean(selectedAppointment)); }; // One appointment's copy, published to the selection and the calendar 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: Appointment) => { 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 handleEditedAppointmentUpdated = (updatedAppointment: Appointment) => { publishAppointment(updatedAppointment); setSelectedAppointmentSchedule(findScheduleForAppointment(updatedAppointment)); void loadAppointments(); void refreshSelectedPatientAppointments(); }; const handleAppointmentEditCompleted = () => { handleCloseAppointmentEdit(); setSuccess('Appointment updated successfully.'); setTimeout(() => setSuccess(undefined), 3_000); void refreshAppointmentSupport(); void refreshSelectedPatientAppointments(); }; // A cancelled visit saved again exactly as it is: the server closes its visit records the cancellation left open. // It belongs to this opening of the details, which closing, editing or opening the patient ends. const handleCancelledVisitResaved = useCallback((saved: Appointment) => { publishAppointment(saved); void loadAppointments(); void refreshSelectedPatientAppointments(); }, [publishAppointment, loadAppointments, refreshSelectedPatientAppointments]); const handleCancelledVisitReloaded = useCallback((current: Appointment) => { publishAppointment(current); void loadAppointments(); }, [publishAppointment, loadAppointments]); // A new opening each time the details are shown, however they came back (Back from the patient, closing Edit). const detailsOpeningKey = useDetailsOpening(showAppointmentDetails, selectedAppointment?.id); const cancelledResave = useCancelledVisitResave(selectedAppointment, detailsOpeningKey, { onSaved: handleCancelledVisitResaved, onReloaded: handleCancelledVisitReloaded, }); const handleSelectedAppointmentStatusChange = useCallback(async ( nextStatus: ReceptionistAppointmentStatus, ) => { if (!selectedAppointment?.id || appointmentStatusUpdating || heldStatus) { return; } if (getReceptionistAppointmentStatus(selectedAppointment) === nextStatus) { return; } setAppointmentStatusUpdating(true); setAppointmentStatusError(undefined); setAppointmentStatusRefusal(undefined); setAppointmentStatusNote([]); const operation = statusScope.start(); let followUps: string[] = []; const basedOn = appointmentVersion(selectedAppointment); const hold = (kind: 'unknown' | 'stale') => holdStatus(selectedAppointment.id as string, { kind, requested: nextStatus, basedOn }); try { // The version selected when the change was asked for, so it never lands over a change made since. let updatedAppointment: Appointment; try { updatedAppointment = await operation.step(() => updateAppointment( applyReceptionistAppointmentStatus(selectedAppointment, nextStatus), { basedOn, onFollowUp: (reported) => { followUps = reported; }, transport: operation.transport }, ), true); } catch (writeError) { if (!writeOutcomeUnknown(writeError)) throw writeError; // Its answer never came: the appointment as stored now says whether it landed, read under a // recovery of its own; nothing is sent again until that is known. const recovery = statusScope.start(); let stored: Appointment | undefined; const settled = await settleUnknownWrite( async () => (stored = await recovery.step(() => fetchAppointment(selectedAppointment.id as string, true, recovery.transport))), (current) => getReceptionistAppointmentStatus(current) === nextStatus, ); if (!recovery.belongs()) return; if (settled === 'saved' && stored) { updatedAppointment = stored; // Found saved, but what it did beside the visit was never reported. followUps = [COMPANIONS_UNCONFIRMED]; } else { hold('unknown'); return; } } if (followUps.length > 0) setAppointmentStatusNote(appointmentFollowUpLines(followUps)); publishAppointment(updatedAppointment); setSelectedAppointmentSchedule(findScheduleForAppointment(updatedAppointment)); await loadAppointments(); void refreshSelectedPatientAppointments(); } catch (statusError) { // Nothing is shown for an appointment or person no longer on screen. if (isSaveRetired(statusError)) return; console.error('Failed to update appointment status:', statusError); const refusal = treatmentAgreementRefusal(statusError); if (refusal) setAppointmentStatusRefusal(refusal); else if (isRecordChanged(statusError)) { hold('stale'); setAppointmentStatusError('Someone else changed this appointment, so its status was not changed. Load it as it is now and choose again.'); void loadAppointments(); void refreshSelectedPatientAppointments(); } else setAppointmentStatusError('Unable to update the appointment status. Please try again.'); } finally { if (operation.belongs()) setAppointmentStatusUpdating(false); } }, [ appointmentStatusUpdating, fetchAppointment, findScheduleForAppointment, heldStatus, holdStatus, loadAppointments, publishAppointment, refreshSelectedPatientAppointments, selectedAppointment, statusScope, updateAppointment, ]); // An unconfirmed change is settled only by a fresh read under a recovery of its own; nothing is written again. const handleCheckSelectedAppointmentStatus = useCallback(async () => { if (!heldStatus || heldStatus.kind !== 'unknown' || appointmentStatusUpdating) return; const { id, requested, basedOn } = heldStatus; setAppointmentStatusUpdating(true); const recovery = statusScope.start(); try { const outcome = await checkStatusChange({ requested, basedOn, reread: () => recovery.step(() => fetchAppointment(id, true, recovery.transport)), }); if (!recovery.belongs() || outcome.state === 'unknown') return; releaseStatusHold(id); if (outcome.state === 'saved') { setAppointmentStatusError(undefined); if (outcome.recovered) setAppointmentStatusNote(appointmentFollowUpLines([COMPANIONS_UNCONFIRMED])); publishAppointment(outcome.appointment); void loadAppointments(); } else if (outcome.state === 'superseded') { // Open to a new choice on the appointment as it is now, without saying the change was not saved. publishAppointment(outcome.current); setAppointmentStatusError(undefined); setAppointmentStatusNote([STATUS_CHANGE_SUPERSEDED]); } } finally { if (recovery.belongs()) setAppointmentStatusUpdating(false); } }, [appointmentStatusUpdating, fetchAppointment, heldStatus, loadAppointments, publishAppointment, releaseStatusHold, statusScope]); // An appointment that changed since it was read here is loaded fresh before anything is chosen for it again. const handleReloadSelectedAppointment = useCallback(async () => { if (!heldStatus || heldStatus.kind !== 'stale' || appointmentStatusUpdating) return; setAppointmentStatusUpdating(true); const recovery = statusScope.start(); try { const current = await recovery.step(() => fetchAppointment(heldStatus.id, true, recovery.transport)); if (!recovery.belongs()) return; publishAppointment(current); releaseStatusHold(heldStatus.id); setAppointmentStatusError(undefined); } catch (error) { if (!isSaveRetired(error) && recovery.belongs()) { setAppointmentStatusError('We could not load this appointment as it is now. Try again.'); } } finally { if (recovery.belongs()) setAppointmentStatusUpdating(false); } }, [appointmentStatusUpdating, fetchAppointment, heldStatus, publishAppointment, releaseStatusHold, statusScope]); // ONE ENTRY PER PERSON, NOT PER SCHEDULE. // // A practitioner holds one schedule per site, and can hold more than one role at one organization - // so this list showed the same doctor twice, byte-identical, with nothing to tell them apart. Every // reasonable reading of that is wrong: two doctors with one name, a duplicate account, or a bug about // to double-book somebody. // // The grouping is on the PERSON the schedule names, never on the rendered label. Two genuinely // different practitioners who happen to share a name are two people and stay two rows; a schedule // that names neither a practitioner nor a role keeps its own row, because merging those would hide // the data problem rather than show it. const schedulesByPerson = useMemo( () => groupSchedulesByPerson( schedulesList.filter((schedule): schedule is Schedule & { id: string } => Boolean(schedule.id)), ), [schedulesList], ); // The representative schedule's id keys the option; every schedule in its group is what the FILTER // is built from, so a person merged here keeps both hats' appointments. const schedulesForOptionKey = useMemo(() => { const map = new Map(); for (const group of schedulesByPerson) map.set(group[0].id, group); return map; }, [schedulesByPerson]); // One entry per person; the viewer is marked "(you)" so they never have to find themselves. const practitionerComboOptions = useMemo( () => schedulesByPerson .map((group) => { const role = getPractitionerRoleForSchedule(group[0], practitionersList); const personId = getReferenceId(role?.practitioner?.reference) ?? scheduleActorSelection(group[0]).practitionerId; const detail = [ role?.code?.[0]?.text || role?.code?.[0]?.coding?.[0]?.display, role?.specialty?.[0]?.text || role?.specialty?.[0]?.coding?.[0]?.display, ].filter(Boolean).join(' ยท '); return { key: group[0].id, name: getSchedulePractitionerDisplay(group[0], practitionersList), detail: detail || undefined, isYou: Boolean(currentUser?.fhir?.id) && personId === currentUser?.fhir?.id, }; }) .sort((left, right) => left.name.localeCompare(right.name)), [currentUser?.fhir?.id, practitionersList, schedulesByPerson], ); const handleViewModeChange = (nextViewMode: AppointmentViewMode) => { if (range === 'year' && nextViewMode === 'calendar') { return; } setViewMode(nextViewMode); }; const handleRangeChange = (nextRange: AppointmentRange) => { setRange(nextRange); if (nextRange === 'year') { setViewMode('list'); } }; const goToPreviousRange = () => { calendarRef.current?.getApi().prev(); }; const goToNextRange = () => { calendarRef.current?.getApi().next(); }; const goToToday = () => { calendarRef.current?.getApi().today(); }; const closeAppointmentModal = () => { setShowAppointmentModal(false); setSelectedTimeRange(null); setAppointmentModalSchedule(null); }; return (

Appointments

{/* The desk books from the header's Book, so the page draws no second button. */} {!isDesk && ( )} Book Appointment Select a patient, practitioner, appointment type, date, and time. { void loadAppointments(); }} onBooked={() => setShowBookingModal(false)} />
{viewMode === 'calendar' && ( <> )}
) : undefined} /> {success && ( setSuccess(undefined)}> {success} )} {error && ( setError(undefined)}>
{error}
)} {showsOwnTeamVisits && teamVisitsFailed && (

Your team video visits could not be loaded, so they are not shown here. Reload to try again.

)}
{/* Card 239 / finding B28 \u2014 a day whose only appointment is CANCELLED read "1 Appointment", so a receptionist prepares for a visit that is not happening, and a dead day is indistinguishable at a glance from a real one. \u26d4 The obvious fix \u2014 drop cancelled from the count \u2014 is WRONG HERE, and it is worth saying why: the calendar still RENDERS those rows, each labelled "Cancelled". A count that excluded them would stop describing the list beneath it, which is exactly the defect finding B16 was raised for on the messaging tabs. One count, one meaning: the number of rows on screen. So the count stays honest and gains the operational fact instead. The receptionist scans "3 appointments \u00b7 1 cancelled" and knows both what is listed and what is real. */}
{calendarTitle} {loading ? '\u00a0' : appointmentsFailed ? 'Not loaded' : appointmentCountText(appointmentCounts, cancelledAppointmentCount)}
{(['day', 'week', 'month', 'year'] as AppointmentRange[]).map((rangeOption) => ( ))}
{/* ๐Ÿ• Shown only when this browser's clock differs from the practice's โ€” see viewerZoneLabel. Covers the events AND the hour gutter beside them. */} {viewerZoneLabel && (

Times are shown in your timezone ({viewerZoneLabel}), which is different from the practice’s.

)}
{loading && (
)}
{/* * Booking by dragging a time on the calendar now runs the SAME form as the "Book Appointment" * button above and the edit dialog below โ€” one booking form, three entry points. * * It used to open a separate WordPress-native modal (`AppointmentModal` -> `FhirAppointment`, * both deleted). That form was a generic FHIR editor rather than a booking flow: it had no * visit-service field at all, so every calendar booking was created with `serviceType: []`, * and it wrote no PractitionerRole participant, which made those appointments invisible to * org-scoped appointment queries and to the reminder sweep. Reusing this component rather * than rebuilding a second Radix form is what makes those two defects impossible to * reintroduce here: there is no second form to drift. * * The drag supplies the schedule and the exact time; the patient and the visit service are * still asked for, because the service is what determines who and where can be booked. */} { if (!open) closeAppointmentModal(); }} > Book Appointment Select a patient and visit service, then confirm the practitioner, date, and time. {showAppointmentModal && selectedTimeRange && ( )} void handleCheckSelectedAppointmentStatus()} statusStale={heldStatus?.kind === 'stale'} onReloadStatus={() => void handleReloadSelectedAppointment()} statusNote={appointmentStatusNote} cancelledResave={selectedAppointment?.status === 'cancelled' ? { onResave: () => void cancelledResave.resave(), saving: cancelledResave.saving, note: cancelledResave.note, error: cancelledResave.error, visitRecordLine: cancelledResave.visitRecordLine, standing: cancelledResave.standing, onReload: () => void cancelledResave.reload(), } : undefined} onOpenChange={handleAppointmentDetailsOpenChange} onEdit={handleEditSelectedAppointment} onStatusChange={handleSelectedAppointmentStatusChange} onViewProfile={selectedPatientId ? handleViewSelectedPatientProfile : undefined} /> {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={handleAppointmentEditCompleted} /> )} ); }; export default AppointmentsPage;