import { SelectControl } from "../../../components/ui/select-control"; import { Spinner } from "../../../components/ui/spinner"; import { DismissibleAlert } from '../../../components/ui/dismissible-alert'; import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { Appointment, AppointmentParticipant, Bundle, CodeableConcept, Location, Patient, Practitioner, PractitionerRole, Questionnaire, Reference, Schedule, Slot, Task } from 'fhir/r4'; import { CircleAlert, CircleCheck, CircleX, ClipboardList, FileText, LockKeyhole, Pencil, Trash2, X, } from 'lucide-react'; import { getScheduleSlotIncrement, slotStartTimes, windowEndOnDay } from '../../../config/scheduleSlotIncrement'; import { slotReadWindow } from '../../../utils/booking/slotReadWindow'; import { deskBusyRanges, readBusyTimes } from '../../../utils/booking/busyTimes'; import { toast } from 'sonner'; import { CustomTaskAssignmentModal, QuestionnaireTaskAssignmentModal, } from '../../../components'; import { formatCustomTaskAppointmentDisplay, type AppointmentClock } from '../../../components/CustomTaskAssignmentModal/CustomTaskAssignmentModal.tsx'; import type { CustomTaskAssignmentDraft } from '../../../components/CustomTaskAssignmentModal/CustomTaskAssignmentModal.tsx'; import type { TaskPriority } from '../../../components/TaskPrioritySelect/TaskPrioritySelect'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '../../../components/ui/alert-dialog.tsx'; import { Button } from '../../../components/ui/button.tsx'; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from '../../../components/ui/select.tsx'; import { AKASHIC_TASK_APPOINTMENT_EXTENSION, AKASHIC_TASK_DUE_MODE_EXTENSION, AKASHIC_TASK_RECURRENCE_FREQUENCY_EXTENSION, getAkashicTaskKind, taskDueModePinsAnAppointment, getCustomTaskDueDisplay, getCustomTaskDueMode, getCustomTaskRecurrenceFrequency, getTaskAppointmentId, type PatientSearchBundle, useAppointmentServiceContext, useFhirServiceContext, usePatientServiceContext, usePractitionerRoleServiceContext, usePractitionerServiceContext, useScheduleServiceContext, useTaskServiceContext, } from '../../../services'; import { useFhirHealthcareServices, useFhirLocations, useFhirPractitionerRoleBroadcast, useFhirPractitioners, useFhirScheduleBroadcast, useFhirSchedules, } from '../../../hooks'; import { useAuthServiceContext } from '../../../services/AuthService'; import { useStaffPermissionServiceContext } from '../../../services/StaffPermissionService'; import { canBeBookedAsClinician } from '../../../utils/booking/clinicianSeatGate'; import { useSeatStanding } from '../../../utils/seatStanding'; import { frontDeskRelaxations, serviceIsOfferedByNobody, serviceOfferedByNobodyText, frontDeskRemedyText, type FrontDeskSelection, } from '../../../utils/booking/frontDeskRelaxations'; import { useBookingMatchService } from '../../../services/BookingMatchService'; import { mapPractitionerRankings, qualifiesForRecommendation, type ApiPractitionerRanking, type BookablePractitioner, type PractitionerRecommendation, } from '../../../utils/booking/matchPractitionerSuggestions'; import { buildBookablePractitioners, type BookablePractitionerCandidate, } from '../../../utils/booking/bookablePractitioners'; import PractitionerRecommendationCallout from '../PatientsPage/PatientPage/PatientBookAppointment/PractitionerRecommendationCallout'; import { mapSuggestionsToBookableServices, type BookableSuggestion, } from '../../../utils/booking/matchServiceSuggestions'; import { SUGGESTION_CONFIDENCE_FLOOR } from '../../../config/suggestionFloor'; import { referralsClosedByBooking, type ReferralAboutToClose } from '../../../utils/referralsClosedByBooking'; import { useReferralWorkbenchService } from '../../../services/ReferralWorkbenchService'; import { getOrganizationPolicyFlags } from '../../../services/OrganizationService'; import { CONSENT_INSTRUMENTS, RECORDS_INSTRUMENT } from '../../../config/consentInstruments.ts'; import { bookingAuthorizationNotice } from './bookingAuthorizationNotice.ts'; import { resolveAppointmentDurationMinutes, DURATION_QUICK_PICK_MINUTES, formatDurationLabel, } from '../../../config/appointmentDuration'; import { formatFhirHumanName } from '../../../utils/fhirHumanName.tsx'; import { patientHasLogin } from '../../../utils/cognitoIdentity.ts'; import { formatPreferredAvailability } from '../../../utils/appointmentAvailabilityPreference'; import { isAppointmentPlacement } from '../../../utils/appointmentPlacement'; import { applyReceptionistAppointmentStatus, arrivalDayUnavailableReason, careUnavailableReason, getReceptionistAppointmentStatus, RECEPTIONIST_APPOINTMENT_STATUS_OPTIONS, } from './receptionistAppointmentStatus.ts'; import type { ReceptionistAppointmentStatus } from './receptionistAppointmentStatus.ts'; import { useTreatmentAgreements } from './useTreatmentAgreements'; import { appointmentPatientId } from '../../../utils/appointmentParticipants'; import { getScheduleBookingFlags, getScheduleLocationReference } from '../../../services/ScheduleService.tsx'; import { getPractitionerRoleOrganizationCodings, getPractitionerRolePrimaryDisplay, isBookableServiceRole } from '../../../utils/staffRoleCatalog'; import { onlySites, onlyVirtual } from '../../../utils/roomLocation'; import { isOrgWideRole } from '../../../utils/locationAssignments'; import { resolveBookingTimeZone, viewerDiffersFromPractice, calendarDateInZone, intervalEndDate, viewerTimeZone, wallClockInZone, zonedWallClockToInstant, } from '../../../utils/practiceTimezone'; /** Why a booking cannot be made at a location without a time zone, and what to correct. */ const NO_LOCATION_CLOCK = 'This practice has no time zone set for that location, so we cannot tell what time to book. Set one on the location in Organization settings, then book.'; import { getScheduleLocationReferences } from '../../../utils/scheduleLocations'; import { firstPhysicalSite, scheduleSiteFor } from '../../../utils/booking/physicalSite'; import { offerableLocationOptions } from '../../../utils/scheduleTelehealth'; import { resolveServiceTelehealthEligible } from '../../../config/serviceTelehealthEligible'; import { blockIsVideoOnly, VIDEO_ONLY_PRACTICE_DISABLED, VIDEO_ONLY_SERVICE_INELIGIBLE, } from '../../../utils/booking/videoOnlyBlock'; import { bookingServiceGateSatisfied } from '../../../utils/booking/serviceFirstBooking'; import { monthAvailability, dateUnavailableReason } from '../../../utils/booking/bookingDateAvailability'; import { schedulesForPractitioner, unionAvailability, scheduleCoveringDate, blocksAtChosenSite } from '../../../utils/booking/practitionerScheduleUnion'; import { useOrgAuthorizationServiceContext, type OrgAuthorizationStatusValue, type OrgConsentStatusesResponse, } from '../../../services/OrgAuthorizationService.tsx'; /* `RecordAuthorizationDialog` is deliberately NOT imported here any more. Attestation belongs at check-in, with the patient standing there - see the note at the agreements notice below. */ import { resolveBookingLocationRef, withLocationDisplay } from '../../../utils/booking/bookingAppointmentLocation'; import { describeSelectionForIntent, type BookingIntentConflict } from '../../../utils/booking/bookingIntentConflict'; import { statedModality } from '../../../utils/booking/statedModality'; import { resolveOrgAppointmentPolicy } from '../../../config/appointmentPolicy'; import { appointmentTypes } from '../../../config/appointmentTypes'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; import { treatmentAgreementRefusal } from '../../../utils/treatmentAgreementRefusal'; import { distinguishDuplicateOptions } from '../../../utils/booking/distinguishDuplicateOptions'; import { formatDateRange } from '../../../utils/dateUtils'; import { serviceLocationSummary } from '../../../utils/booking/serviceLocationSummary'; import { getTaskDocumentRequest, withDocumentRequestExtension } from '../../../utils/documentRequestTask'; import { useOpenVisitPatient } from '../ClinicianOverviewPage/useOpenVisitPatient'; import SelectedPatientSummary from './SelectedPatientSummary'; import { bookingPatientSeed } from './bookingPatientSeed'; import { useKeyedRead } from '../../../hooks/useKeyedRead'; import ReceptionistRegisterPatientDialog from './ReceptionistRegisterPatientDialog.tsx'; import { patientSearchResultLine } from './patientSearchResult'; import './ReceptionistBookAppointment.css'; import { bookedServiceLabel, deskSelectableServices, inactiveSelectionRefusal, serviceFieldsToSave, visitServiceIdFor } from '../../../utils/appointmentVisitService'; import { withSelectedServiceRef } from '../../../utils/booking/selectedServiceRef'; import { customTaskTitle } from '../../../utils/appointmentTasks'; import { taskName } from '../../../utils/taskName'; import { applyCustomTaskEdit } from '../../../utils/customTaskEdit'; import { appointmentFollowUpLines, visitEncounterLineOf } from '../../../utils/appointmentVersion'; import type { SaveOperation } from '../../../utils/saveOperation'; import { isRecordChanged } from '../../../utils/recordChanged'; import { isSaveRetired, useSaveScope } from '../../../utils/saveOperation'; import { bookNewAppointment } from '../../../utils/booking/bookNewAppointment'; import { HttpError } from '../../../services/HttpService'; import { newerAppointment, useAppointmentEditBasis } from '../../../hooks/useAppointmentEditBasis'; import type { StaleReason } from '../../../hooks/useEditBaseline'; import { StaleRecordNotice } from '../../../components/StaleRecordNotice/StaleRecordNotice'; import { rescheduleAppointment, RescheduleUnconfirmed } from '../../../utils/booking/rescheduleAppointment'; import { BookingRetiredError, CheckingYourBooking, isBookingRetired, isBookingUnconfirmed, useBookingOperation, } from '../../../components/booking/CheckingYourBooking'; import { BookingStorageUnavailableError, type BookingOperationEntry } from '../../../utils/booking/bookingOperation'; interface ReceptionistBookAppointmentProps { appointment?: Appointment; initialSchedule?: Schedule; /** * A time range to start from on a NEW booking - the staff calendar's drag-select supplies the * practitioner's schedule plus the exact slot the user dragged, and losing that would make booking * from the calendar pointless. ISO instants. Seeded ONCE and never re-applied, so it is a starting * point rather than something that fights the user. */ initialStart?: string; initialEnd?: string; /** * The patient whose chart the booking was opened from. A chart is an explicit choice, so it starts * picked (deck practitioner#s-appointments "Booking") and the pencil still changes it. */ initialPatient?: Patient; assignedTasks?: Task[]; assignedTasksLoading?: boolean; assignedTasksError?: string; onAppointmentCreated?: (appointment: Appointment) => void; onAppointmentUpdated?: (appointment: Appointment) => void; onTasksChanged?: () => void; onBooked?: () => void; onUpdated?: () => void; onSuccess?: (message: string) => void; } const formatDateOnly = (date: Date | null) => date ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` : ''; const addMonths = (date: Date, months: number) => { const nextDate = new Date(date); nextDate.setMonth(nextDate.getMonth() + months); return nextDate; }; const getMonthKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; const getMonthLabel = (date: Date) => date.toLocaleDateString('en-US', { month: 'long', year: 'numeric', }); const getStartOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1); // Radix throws on an empty-string value, so the "All..." option uses this sentinel // (mapped back to '' - the no-filter state - in the change handlers). const ALL_FILTER_VALUE = '__all__'; const getReferenceIdFromString = (reference?: string) => { if (!reference) { return undefined; } const [, id] = reference.split('/'); return id || reference; }; // `zoneLabel` is supplied ONLY when the viewer's clock differs from the practice's (see // practiceZoneNotice in the component). Times on this screen are on the PRACTICE's clock, and a time // that does not say which clock it is on is misread - a remote receptionist reading "9:00 AM" takes // it for their own 9:00. const formatTimeForDisplay = (time24: string, zoneLabel?: string | null) => { const [hours, minutes] = time24.split(':').map(Number); const period = hours >= 12 ? 'PM' : 'AM'; const displayHours = hours % 12 || 12; return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}${zoneLabel ? ` ${zoneLabel}` : ''}`; }; const getEndTime = (startTime: string, durationMinutes: number = 30) => { const [hours, minutes] = startTime.split(':').map(Number); const startDate = new Date(); startDate.setHours(hours, minutes, 0, 0); startDate.setMinutes(startDate.getMinutes() + durationMinutes); return `${startDate.getHours().toString().padStart(2, '0')}:${startDate .getMinutes() .toString() .padStart(2, '0')}`; }; const formatAppointmentDateInput = (value?: string) => { if (!value) { return ''; } const date = new Date(value); return Number.isNaN(date.getTime()) ? '' : formatDateOnly(date); }; const formatAppointmentTimeInput = (value?: string) => { if (!value) { return ''; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return ''; } return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; }; const getAppointmentParticipantReference = ( appointment: Appointment, resourceTypes: string[], ) => appointment.participant?.find((participant) => resourceTypes.some((resourceType) => participant.actor?.reference?.startsWith(`${resourceType}/`), ), )?.actor; const getAppointmentTypeValue = (appointment?: Appointment) => appointment?.appointmentType?.coding?.find((coding) => coding.code?.trim())?.code?.trim() || appointment?.appointmentType?.text?.trim() || ''; const getAppointmentTypeDisplay = (appointment?: Appointment) => appointment?.appointmentType?.text?.trim() || appointment?.appointmentType?.coding?.find((coding) => coding.display?.trim())?.display?.trim() || getAppointmentTypeValue(appointment); const getAppointmentSlotId = (appointment?: Appointment) => { const slotReference = appointment?.slot?.find((slot) => slot.reference?.startsWith('Slot/'))?.reference; return getReferenceIdFromString(slotReference); }; const getErrorMessage = (error: unknown) => relayServerFailure(error, 'Please try again.'); const getPatientSecondaryText = (patient: Patient) => { const phone = patient.telecom?.find((contact) => contact.system === 'phone')?.value; const email = patient.telecom?.find((contact) => contact.system === 'email')?.value; return [ patient.birthDate ? `DOB: ${patient.birthDate}` : undefined, phone, email, ] .filter(Boolean) .join(' • ') || 'No demographic/contact summary available'; }; type AvailabilityEvent = { start: string | Date; end: string | Date; }; const isTimeSlotAvailable = ( slotTime: string, dateStr: string, availabilityEvents: AvailabilityEvent[], durationMinutes: number = 30, timeZone?: string | null, ) => { if (!dateStr || availabilityEvents.length === 0) { return false; } const now = new Date(); const slotDateTime = timeZone ? zonedWallClockToInstant(dateStr, slotTime, timeZone) : new Date(`${dateStr}T${slotTime}:00`); const slotEndDateTime = new Date(slotDateTime.getTime() + durationMinutes * 60000); // The whole appointment [start, start+duration) must fit inside a single available window. return availabilityEvents.some((event) => { const eventStart = new Date(event.start); const eventEnd = new Date(event.end); return slotDateTime >= eventStart && slotEndDateTime <= eventEnd && slotDateTime >= now; }); }; type PractitionerWithRoleShell = { practitioner: Practitioner; roles: PractitionerRole[]; }; type PreparedQuestionnaireTask = { id: string; kind: 'questionnaire'; questionnaire: Questionnaire; priority: TaskPriority; }; type PreparedCustomTask = { id: string; kind: 'custom'; taskInput: CustomTaskAssignmentDraft; }; type PreparedTask = PreparedQuestionnaireTask | PreparedCustomTask; type ActivePrepForm = 'questionnaire' | 'custom' | null; type ExistingTaskDisplayStatus = 'complete' | 'unable' | 'incomplete'; const getQuestionnaireIdFromTask = (task: Task): string | undefined => { const reference = task.focus?.reference; return reference?.startsWith('Questionnaire/') ? getReferenceIdFromString(reference) : undefined; }; const getExistingTaskTitle = (task: Task): string => { const kind = getAkashicTaskKind(task); const title = customTaskTitle(task); if (title) return title; if (kind === 'questionnaire') { return task.focus?.display?.trim() || task.description?.replace(/^Complete questionnaire:\s*/i, '').trim() || 'Questionnaire'; } return task.description?.trim() || task.code?.text?.trim() || 'Assigned task'; }; const getExistingTaskTypeLabel = (task: Task): string => { const kind = getAkashicTaskKind(task); if (kind === 'questionnaire') { return 'Questionnaire'; } if (kind === 'custom') { return 'Custom Task'; } return 'Task'; }; const getExistingTaskDisplayStatus = (task: Task): ExistingTaskDisplayStatus => { if (task.status === 'completed') { return 'complete'; } if (task.status === 'failed') { return 'unable'; } return 'incomplete'; }; const getExistingTaskStatusLabel = (task: Task): string => { if (task.status === 'completed') { return 'Complete'; } if (task.status === 'failed') { return 'Unable to complete'; } if (task.status === 'in-progress') { return 'In progress'; } if (task.status === 'on-hold') { return 'On hold'; } if (task.status === 'cancelled') { return 'Cancelled'; } return 'Incomplete'; }; const formatTaskDateForInput = (value?: string): string | undefined => { if (!value) { return undefined; } if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { return value; } const date = new Date(value); return Number.isNaN(date.getTime()) ? undefined : formatDateOnly(date); }; const getExistingCustomTaskDraft = ( task: Task, appointment?: Appointment, clock?: AppointmentClock, ): CustomTaskAssignmentDraft => { const dueMode = getCustomTaskDueMode(task); const appointmentId = getTaskAppointmentId(task); const taskAppointment = appointment?.id && appointment.id === appointmentId ? appointment : undefined; return { title: taskName(task), instructions: task.description?.trim() || '', dueMode, dueDate: dueMode === 'date' || dueMode === 'recurring' ? formatTaskDateForInput(task.restriction?.period?.end) : undefined, recurrenceFrequency: getCustomTaskRecurrenceFrequency(task), appointment: taskAppointment, appointmentSource: dueMode === 'this-appointment' ? 'current' : taskAppointment ? 'existing' : undefined, appointmentDisplay: taskAppointment ? formatCustomTaskAppointmentDisplay(taskAppointment, clock) : undefined, documentRequest: getTaskDocumentRequest(task)?.label, priority: task.priority, }; }; const getExistingTaskDueSummary = ( task: Task, appointment?: Appointment, ): string => { if (getAkashicTaskKind(task) !== 'custom') { return 'Due at this appointment'; } const dueMode = getCustomTaskDueMode(task); const resolvedAppointment = dueMode === 'this-appointment' || dueMode === 'next-appointment' || dueMode === 'specific-appointment' ? appointment : undefined; return getCustomTaskDueDisplay(task, resolvedAppointment); }; const getQuestionnaireLabel = (questionnaire: Questionnaire): string => questionnaire.title?.trim() || questionnaire.name?.trim() || 'Untitled questionnaire'; const formatTaskDate = (value?: string): string => { if (!value) { return ''; } const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value); const date = isDateOnly ? (() => { const [year, month, day] = value.split('-').map(Number); return new Date(year, month - 1, day); })() : new Date(value); if (Number.isNaN(date.getTime())) { return value; } return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', }); }; const recurrenceLabels: Record, string> = { daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly', annually: 'Annually', }; const getPreparedCustomTaskDueSummary = (taskInput: CustomTaskAssignmentDraft): string => { if (taskInput.dueMode === 'recurring') { const frequency = taskInput.recurrenceFrequency ? recurrenceLabels[taskInput.recurrenceFrequency] : 'Recurring'; return taskInput.dueDate ? `${frequency} starting ${formatTaskDate(taskInput.dueDate)}` : frequency; } if (taskInput.dueMode === 'date') { return taskInput.dueDate ? `Due ${formatTaskDate(taskInput.dueDate)}` : 'Due date not set'; } if (taskInput.dueMode === 'this-appointment') { return taskInput.appointmentDisplay ? `This appointment · ${taskInput.appointmentDisplay}` : 'Due at this appointment'; } if (taskInput.dueMode === 'specific-appointment') { return taskInput.appointmentDisplay ? `Specific appointment · ${taskInput.appointmentDisplay}` : 'Specific appointment not selected'; } if (taskInput.dueMode === 'next-appointment') { if (!taskInput.appointmentDisplay) { return 'Soonest upcoming appointment · Not scheduled yet'; } return `Soonest upcoming appointment · ${taskInput.appointmentDisplay}${ taskInput.appointmentSource === 'current' ? ' (this appointment)' : '' }`; } return 'Due date not set'; }; /** * The two blocks a practitioner can hold at ONE site read identically once the location has been * used up, so the list decides what to add rather than this function - only the rows that collide * need it, and only the list can see a collision. */ const getSchedulePractitionerDisplay = ( schedule: Schedule, practitionersList: PractitionerWithRoleShell[], extractReferenceType: (reference?: Reference) => string | undefined, locationsList: Location[] = [], ) => { const practitionerActor = schedule.actor?.find((actor) => extractReferenceType(actor) === 'Practitioner'); const roleActor = schedule.actor?.find((actor) => extractReferenceType(actor) === 'PractitionerRole'); const roleId = getReferenceIdFromString(roleActor?.reference); const practitionerWithRole = practitionersList.find((item) => item.roles.some((role) => role.id === roleId), ); // Resolve the practitioner's name live from the loaded practitioner record; only fall back to the // denormalized actor/role display when no live name is available. Preferring the stored display // leaves the selector showing a stale name after a practitioner is renamed. const name = formatFhirHumanName(practitionerWithRole?.practitioner.name, { emptyText: practitionerActor?.display?.trim() || roleActor?.display || 'Unknown Practitioner', }); // A practitioner working at multiple locations has one Schedule per location, which otherwise // renders as identical rows in the "Available Practitioners" selector - disambiguate with the // schedule's location (falling back to the role's location for pre-migration schedules). const role = practitionerWithRole?.roles.find((item) => item.id === roleId); const locationRef = getScheduleLocationReference(schedule) || role?.location?.[0]; let locationName = locationRef?.display?.trim(); if (!locationName && locationRef?.reference) { const locId = getReferenceIdFromString(locationRef.reference); locationName = locationsList.find((loc) => loc.id === locId)?.name?.trim(); } return `${name} — ${locationName || 'All locations'}`; }; const ReceptionistBookAppointment = ({ appointment: shownAppointment, initialSchedule, initialStart, initialEnd, initialPatient, assignedTasks = [], assignedTasksLoading = false, assignedTasksError, onAppointmentCreated, onAppointmentUpdated, onTasksChanged, onBooked, onUpdated, onSuccess, }: ReceptionistBookAppointmentProps) => { // A reloaded appointment, read by id; an older copy shown later never replaces it. const [reopened, setReopened] = useState(); const appointment = newerAppointment(reopened, shownAppointment); const { fetchPatient, searchPatients, } = usePatientServiceContext(); const openVisitPatient = useOpenVisitPatient(); const { transformFromFhirPractitionerRole } = usePractitionerRoleServiceContext(); const { practitionersList } = usePractitionerServiceContext(); const { schedulesList, fetchSlots, fetchHeldTimes, createSlot, releaseBookingHold, transformToFhirSlot, getScheduleAvailabilityEvents, } = useScheduleServiceContext(); const { unpackBundle, filterAndUnpackBundle, extractReferenceType, extractReferenceId, createReferenceObject, } = useFhirServiceContext(); const { createAppointment, updateAppointment, fetchAppointment } = useAppointmentServiceContext(); // - renamed on destructure because this file already has a `getStatus`-shaped vocabulary for // appointments, and two "status" readers one line apart is how the wrong one gets called. const { getConsentStatuses } = useOrgAuthorizationServiceContext(); const { createCustomTask, createQuestionnaireTask, updateTask, deleteTask, } = useTaskServiceContext(); const isEditMode = Boolean(appointment?.id); const fieldIdBase = useId(); const fieldId = (name: string) => `${fieldIdBase}-${name}`; const patientFieldId = fieldId('patient'); const reasonFieldId = fieldId('reason'); const typeFieldId = fieldId('type'); const serviceFieldId = fieldId('service'); const locationFilterFieldId = fieldId('location-filter'); const roleFilterFieldId = fieldId('role-filter'); const practitionerFieldId = fieldId('practitioner'); const statusFieldId = fieldId('status'); const descriptionFieldId = fieldId('description'); const monthFieldId = fieldId('month'); const dateFieldId = fieldId('date'); const durationFieldId = fieldId('duration'); const timeFieldId = fieldId('time'); const [registerOpen, setRegisterOpen] = useState(false); /** * PLACING IS NOT EDITING, AND THE DIALOG HAS TO SAY WHICH ONE THIS IS. * * A time-window request reaches the desk carrying a reason and the patient's general availability * and no practitioner at all, because deciding who they see is the practice's half of that mode. * The details dialog already calls the action `Place appointment`; this form called itself * `Edit Appointment` and offered `Save Changes`, so one item wore two words a click apart. */ const isPlacement = useMemo(() => isAppointmentPlacement(appointment), [appointment]); /** The only instruction the patient gave. It must stay on screen while the desk chooses for them. */ const statedAvailability = useMemo( () => formatPreferredAvailability(appointment), [appointment], ); // All three agreements in one round trip, read for the SUBJECT patient and never the signed-in caller. const readSubjectConsent = useCallback(async (patientId: string) => { try { return await getConsentStatuses(patientId); } catch (error) { // Fails closed: "we could not find out" must never read as "yes", and the banner says the check failed. console.error('Failed to read the clinical-authorization status for the patient being booked:', error); throw error; } }, [getConsentStatuses]); const [patientSearch, setPatientSearch] = useState(''); const [isPatientSearchOpen, setIsPatientSearchOpen] = useState(false); // The box as last settled; the search runs for this term, never for each keystroke. const [patientSearchTerm, setPatientSearchTerm] = useState(''); const [selectedPatient, setSelectedPatient] = useState(null); // - THE CLINICAL AUTHORIZATION FOR THE PATIENT BEING BOOKED // // MEASURED, not inferred: this file contained ZERO occurrences of `authoriz`, `consent` or `attest`. // The patient self-booking flow asks; this one - a separate, parallel implementation, and the one a // practice actually lives in because the front desk books most appointments - never asked at all. // // AND THE FAILURE LANDS ON SOMEONE ELSE, LATER. The appointment exists; the practitioner discovers // at the visit that they cannot open the clinical record. That is the exact report this whole // programme started from - a refusal surfacing to a clinician for a reason nobody put in front of the // patient. // // READ FOR THE SUBJECT PATIENT, NEVER THE CALLER. `getStatus(patientId)` sends `?patient=`, which the // server answers for that patient after checking they are affiliated with this organization. Reading // it for the signed-in receptionist would gate on the RECEPTIONIST's own record - wrong in both // directions at once: it would block every booking for a staff member who has no authorization of their // own, and wave through every patient once one staff member had one. // THE WHOLE PICTURE, BESIDE THE ONE STATUS THE GATE READS: the gate and its refusal sentences stay // keyed on the HIPAA Authorization while the notice is keyed on all three. // Keyed on the patient id: another patient's answer reads as pending here, never as theirs. const [subjectReadAttempt, setSubjectReadAttempt] = useState(0); const subjectRead = useKeyedRead(selectedPatient?.id, readSubjectConsent, subjectReadAttempt); const subjectPending = subjectRead.status === 'pending'; const subjectConsent: 'unknown' | OrgConsentStatusesResponse = subjectRead.status === 'ready' ? subjectRead.value : 'unknown'; const subjectAuthorization: 'unknown' | OrgAuthorizationStatusValue = subjectRead.status === 'ready' ? subjectRead.value.instruments[RECORDS_INSTRUMENT] : 'unknown'; const [selectedSchedule, setSelectedSchedule] = useState(null); /** * The SITE the desk chose in the practitioner picker, which is not the same thing as the site of * the block currently selected - the date effect below re-points that one. * * It is held rather than derived for a reason that bites: deriving it from `selectedSchedule` would * let the re-point change the narrowing and the narrowing change the re-point, which oscillates once * an "all locations" block is in the mix. Set in `handleScheduleChange`, which every manual and * recommended pick already funnels through, and cleared wherever the practitioner is cleared. * `undefined` narrows nothing, so an edit-mode booking seeded straight into `selectedSchedule` * behaves exactly as it did before. */ const [chosenSiteId, setChosenSiteId] = useState(undefined); const [selectedPractitioner, setSelectedPractitioner] = useState<{ name: string; role: string; } | null>(null); const [practitionerRole, setPractitionerRole] = useState(null); const [practitioner, setPractitioner] = useState(); // Defaults to Routine - the ~90% case for a receptionist booking; they only change it for walk-in/emergency. const [selectedAppointmentType, setSelectedAppointmentType] = useState('ROUTINE'); // Holds the selected HealthcareService id (a service offered by the chosen schedule block). const [selectedVisitService, setSelectedVisitService] = useState(''); // Whether somebody picked a service here; until they do, an edit keeps the visit's own. const [serviceTouched, setServiceTouched] = useState(false); /** * THE REASON-FOR-VISIT ASSIST AT THE FRONT DESK. * * Spencer, 2026-08-26, on being told it was patient-self-booking only: *"Damn, I thought we'd * added that in for the receptionist too. Yes, I want that added for the receptionist as well."* * * THE DESK IS THE SURFACE THAT WANTED IT MOST. Turning a layperson's words into a service is * what a receptionist does on the phone all day, from the caller's own sentence - and unlike the * patient they are doing it for somebody else, at speed, while the caller waits. * * SUGGEST-ONLY, AND OPTIONAL HERE UNLIKE THE PATIENT FLOW. The patient's version is a required * CLICK inside a step machine, because a patient who does not know the catalogue has nowhere * else to start. The desk does know it, has no step machine, and is fast - so this offers itself * and never blocks. A tap routes through `handleVisitServiceChange`, the same funnel the manual * picker uses, so nothing downstream can tell which way the service was chosen. */ const { matchService, matchPractitioner, checkIntent } = useBookingMatchService(); const [serviceSuggestions, setServiceSuggestions] = useState([]); const [suggestLoading, setSuggestLoading] = useState(false); const [suggestTried, setSuggestTried] = useState(false); const [practitionerRankings, setPractitionerRankings] = useState([]); const [practitionerSuggestLoading, setPractitionerSuggestLoading] = useState(false); const [practitionerSuggestTried, setPractitionerSuggestTried] = useState(false); const [selectedStatus, setSelectedStatus] = useState(() => { // Placing/editing a pending appointment defaults its status to "booked" - saving confirms it. const status = appointment ? getReceptionistAppointmentStatus(appointment) : 'booked'; return status === 'pending' ? 'booked' : status; }); const [description, setDescription] = useState(appointment?.description || ''); const [reasonForVisit, setReasonForVisit] = useState(appointment?.reasonCode?.[0]?.text || ''); // The modality they stated in their own words, for the reminder beside the Where control. Local and // deterministic - no model, no call that can fail open, and the quote is the matched text so it is // verbatim by construction rather than by a rule somebody has to keep. const statedModalityHint = useMemo(() => statedModality(reasonForVisit), [reasonForVisit]); const [selectedMonth, setSelectedMonth] = useState(getMonthKey(new Date())); const [selectedDate, setSelectedDate] = useState(''); const { wpOrganization, currentOrganization, currentUser, userRole, USER_ROLES } = useAuthServiceContext(); // A new booking's operation, per patient: a registered patient whose booking is unknown keeps this record, // and reopening the desk for them checks it before another time can be booked. const deskBooking = useBookingOperation({ opening: 'desk-booking', practice: currentOrganization?.id, patient: selectedPatient?.id, }); deskBooking.onBooked((recovered) => { setBookedAppointment(recovered); onAppointmentCreated?.(recovered); onSuccess?.('Appointment created successfully.'); }); // Which hat the person is wearing right now, not which groups they belong to. The own-schedule seed // below is for a clinician booking onto their own calendar, and that is a question about the active // workspace - somebody who is both a clinician and an admin is not one while they are in Admin. const { activeWorkspace } = useStaffPermissionServiceContext(); const { readPatientReferralQueue } = useReferralWorkbenchService(); // Booking advances this patient's open inbound referral and retires its front-desk row. That is the // design; what was missing is that the person doing it was never told, so a referral left a // colleague's queue with nobody having decided to close it. // // Keyed on the id alone for exactly the reason spelled out above this: a dependency on the patient // OBJECT turns an open dialog into a fetch per render while somebody is on the phone. const [referralsToClose, setReferralsToClose] = useState([]); // The referral read failed, so whether booking closes one is unknown and is said so. const [referralsUnknown, setReferralsUnknown] = useState(false); // The queue was not read in full, so a referral this booking closes may be missing from the list. const [referralsIncomplete, setReferralsIncomplete] = useState(false); // The patient and practice the referral answer belongs to. Any other reads as still checking, so one // patient's referrals are never shown while another's are being read. const [referralsScope, setReferralsScope] = useState(); const referralScope = selectedPatient?.id ? `${currentOrganization?.id ?? ''}/${selectedPatient.id}` : undefined; const referralsChecking = Boolean(referralScope) && referralsScope !== referralScope; // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the id ON PURPOSE, as above. useEffect(() => { let cancelled = false; const patientId = selectedPatient?.id; setReferralsUnknown(false); setReferralsIncomplete(false); setReferralsToClose([]); if (!patientId) { return () => { cancelled = true; }; } const organizationId = currentOrganization?.id; const scope = `${organizationId ?? ''}/${patientId}`; // No practice to scope the read to: unknown, rather than a queue read under whatever practice // the transport happens to hold. if (!organizationId) { setReferralsUnknown(true); setReferralsScope(scope); return () => { cancelled = true; }; } void (async () => { try { const { rows, incomplete } = await readPatientReferralQueue(patientId, organizationId); if (!cancelled) { setReferralsToClose(referralsClosedByBooking(rows, organizationId)); setReferralsIncomplete(incomplete); setReferralsScope(scope); } } catch { if (!cancelled) { setReferralsToClose([]); setReferralsUnknown(true); setReferralsScope(scope); } } })(); return () => { cancelled = true; }; }, [selectedPatient?.id, currentOrganization?.id]); // Editing can begin care, so those statuses wait on the server's `mayTreat` here as at check-in. const { agreements: careAgreements, recheck: recheckCareAgreements } = useTreatmentAgreements( isEditMode && appointment ? appointmentPatientId(appointment) : undefined, wpOrganization.current?.id, isEditMode, ); const [selectedTime, setSelectedTime] = useState(''); const [selectedEndTime, setSelectedEndTime] = useState(''); const [selectedDurationMinutes, setSelectedDurationMinutes] = useState(60); const [availabilityEvents, setAvailabilityEvents] = useState([]); const [slotReadAttempt, setSlotReadAttempt] = useState(0); const [submitting, setSubmitting] = useState(false); const [submissionLabel, setSubmissionLabel] = useState(isEditMode ? 'Updating...' : 'Booking...'); /** * WHAT THE SERVER SAID WHEN IT REFUSED THE BOOKING, ON THE SCREEN RATHER THAN IN THE CONSOLE. * * MEASURED three times at the desk: a booking the server declines answers 403 with copy it wrote * for the reader - *"This service cannot be delivered by video. Please book it in person, or * contact the practice."* - and the dialog did not change. The handler calls `toast.error`, and * nothing arrives: sonner's container is mounted and stays empty, so the only trace is two console * lines. A receptionist with a patient on the phone presses Book again, and I did exactly that. * * THE TOAST STAYS BELOW and this does not replace it. Two reasons. It is not this card's business * to fix whatever is wrong with the toaster, and if that is repaired the toast is still the right * thing for the SUCCESS path. But a refusal the desk has to act on does not belong in a corner for * four seconds - it belongs beside the button they pressed, and staying until they have read it. * * It carries the SERVER'S sentence through `relayServerFailure`, never a re-written guess: the * server knows which rule refused and this dialog does not. */ const [bookingRefusal, setBookingRefusal] = useState(null); const [taskAssignmentAction, setTaskAssignmentAction] = useState(''); const [activePrepForm, setActivePrepForm] = useState(null); const [preparedTasks, setPreparedTasks] = useState([]); const [bookedAppointment, setBookedAppointment] = useState(null); const [taskAssignmentError, setTaskAssignmentError] = useState(); /** * THE ONE THING THE ASSIST MAY SAY ABOUT A SELECTION THAT CONTRADICTS THE REASON GIVEN. * * The reason text is consumed once, to suggest a service, and then discarded. Everything chosen * after it is narrowed against constraints that know nothing about the sentence that started it - so * a booking can be completely valid and completely wrong, with every field legal on its own and * nothing anywhere disagreeing when a stated video visit quietly becomes an in-person one. * * Measured at this desk 2026-08-29: reason "she wants a video visit from home", selection Physical * examination in person, and the appointment was created with no request made and nothing said. * * IT IS A CONFIRM, NOT A GATE. The first press shows what was noticed and does not book; the second * books. `acknowledgedSelection` is what makes the second press mean "yes, I know" - keyed on the * SELECTION, so changing any part of it asks again rather than carrying an old acknowledgement onto * a booking nobody looked at. */ const [intentConflict, setIntentConflict] = useState(); const [acknowledgedSelection, setAcknowledgedSelection] = useState(); const [existingTasks, setExistingTasks] = useState(assignedTasks); const [editingExistingTask, setEditingExistingTask] = useState(); const [taskPendingDeletion, setTaskPendingDeletion] = useState(); const [taskMutationId, setTaskMutationId] = useState(); const [taskMutationError, setTaskMutationError] = useState(); const [initializingEdit, setInitializingEdit] = useState(isEditMode); const [editInitializationError, setEditInitializationError] = useState(); // The appointment as this edit opened on it; a newer one saved elsewhere marks it stale, never refills it. const editBasis = useAppointmentEditBasis(appointment); // An edit's save belongs to this appointment and the person signed in; another of either retires it, and its // busy state goes with it. // A new booking belongs to the patient it was asked for: choosing another, or another person signing in, retires it. const editScope = useSaveScope(appointment?.id ?? (selectedPatient?.id ? `new-booking:${selectedPatient.id}` : undefined), () => { setSubmitting(false); setSubmissionLabel(appointment ? 'Updating...' : 'Booking...'); }); // A save saved with something left unfinished on the schedule. const [savedWithFollowUps, setSavedWithFollowUps] = useState([]); // Why nothing more is saved from this opening until it is reloaded: a refused or unconfirmed save. const [staleOpening, setStaleOpening] = useState(null); // What a save that did not go through did to the visit's own record, said beside the failure until the next save. const [failedVisitRecordLine, setFailedVisitRecordLine] = useState(); const [reloading, setReloading] = useState(false); const [reloadError, setReloadError] = useState(); const [selectedRoleFilter, setSelectedRoleFilter] = useState(''); const [selectedLocationFilter, setSelectedLocationFilter] = useState(''); // An edit carries the visit's Location forward unless someone changes Where. const [whereTouched, setWhereTouched] = useState(false); const scrollAnchorRef = useRef(null); /** * - THE SCROLL CONTAINER IS A TAB STOP ONLY WHILE IT ACTUALLY SCROLLS. * * A `
` with `overflow-y: auto` is scrollable with a wheel and, in Chromium, NOT with a keyboard: * it takes no focus, so arrows/PageDown/End go nowhere. At a 720px-tall viewport the authorization * gate sits below the fold, and a keyboard-only receptionist could not bring it into view at all. * That gate records the patient's authorization to treat, so what does not get made is the * practice's legal record of consent - and it fails silently, because the screen has no reason to say * "scroll down" to a mouse user who can already see it. * * WHY IT IS CONDITIONAL RATHER THAN A PLAIN `tabIndex={0}`, which is the obvious fix: a permanent * tab stop appears on every viewport, including the tall ones where the dialog does not scroll at all * and there is nothing to reach. That trades one accessibility defect for a stray stop everybody * meets the container is focusable exactly when focusing it would DO something. * * Observed rather than reasoned: `scrollHeight 745 / clientHeight 589` at 720px (scrollable), and no * overflow at 900px. The ResizeObserver watches the container AND its content, because the dialog * grows as the form is filled - a one-shot measurement on mount is wrong the moment a service is * chosen and the practitioner list appears. */ const bodyRef = useRef(null); const [bodyScrolls, setBodyScrolls] = useState(false); useEffect(() => { const el = bodyRef.current; if (!el || typeof ResizeObserver === 'undefined') return; const measure = () => setBodyScrolls(el.scrollHeight > el.clientHeight + 1); measure(); const observer = new ResizeObserver(measure); observer.observe(el); // The content box, not just the viewport box: filling the form is what makes it overflow. for (const child of Array.from(el.children)) observer.observe(child); return () => observer.disconnect(); }); const prepTaskSequenceRef = useRef(0); const initializedEditAppointmentIdRef = useRef(); const initialScheduleIdRef = useRef(); const { error: schedulesLoadError } = useFhirSchedules(); useFhirScheduleBroadcast(); const { error: practitionersLoadError } = useFhirPractitioners(); useFhirPractitionerRoleBroadcast(); const { locationsList } = useFhirLocations(); const { healthcareServicesList } = useFhirHealthcareServices(); const appointmentTypeOptions = useMemo(() => { const currentTypeValue = getAppointmentTypeValue(appointment); const currentTypeDisplay = getAppointmentTypeDisplay(appointment); if ( !currentTypeValue || appointmentTypes.some((type) => type.code === currentTypeValue) ) { return appointmentTypes; } return [ ...appointmentTypes, { code: currentTypeValue, display: currentTypeDisplay || currentTypeValue, }, ]; }, [appointment]); // Visit service ("what is performed") is gated to the services the selected schedule offers. // If a schedule declares none, fall back to the full catalog rather than blocking. // The services this schedule block offers = its Schedule.actor HealthcareService refs, resolved against the // org's HealthcareService list. Falls back to all org services if the block declares none. // Service-first: Visit Service is a PRIMARY filter (chosen before the practitioner). Options = the org's // services, narrowed to the selected location when one is chosen; picking a service narrows the practitioner // list below (see filteredSchedules). // The edited visit's own current service, which it may keep after the practice switches it off. const currentVisitServiceId = useMemo( () => (isEditMode ? visitServiceIdFor(appointment, healthcareServicesList) || undefined : undefined), [appointment, healthcareServicesList, isEditMode], ); const visitServiceOptions = useMemo(() => { const choosable = deskSelectableServices(healthcareServicesList, currentVisitServiceId); if (!selectedLocationFilter) return choosable; return choosable.filter((service) => { const locs = service.location ?? []; return ( locs.length === 0 || locs.some((loc) => loc.reference?.split('/').pop() === selectedLocationFilter) ); }); }, [healthcareServicesList, selectedLocationFilter, currentVisitServiceId]); const visitServiceOptionIds = useMemo(() => new Set(visitServiceOptions.map((service) => service.id as string)), [visitServiceOptions]); /** * TWO SERVICES THAT READ THE SAME ARE A CHOICE NOBODY CAN MAKE - the same rule the practitioner * rows already follow, one field earlier in the same dialog. * * MEASURED at Great River: **Consultation appears twice**, and the two are not equivalent. One is * scoped to two hospitals and offers four practitioners; the other is scoped to nothing and is * answered with *"Nobody at this practice currently offers Consultation"*. A receptionist picking * from that list is guessing, and half the time they guess into a dead end. * * Only the rows that collide gain the detail, which is the helper's whole point: a suffix on every * row costs a scannable list to say something six of the seven rows did not need said. */ const visitServiceLabels = useMemo( () => distinguishDuplicateOptions(visitServiceOptions, { key: (service) => service.id as string, label: (service) => service.name ?? '', detail: (service) => serviceLocationSummary(service, locationsList), }), [visitServiceOptions, locationsList], ); const selectedService = healthcareServicesList.find((service) => service.id === selectedVisitService); // Spencer, 2026-08-03: "Service shouldn't be optional in the booking flow, it's what determines which // location and practitioners the appointment can be booked with." The patient flow already worked this // way (bookingFlowSteps orders service before location/practitioner); this is the receptionist half. // Edit is exempt - see the helper's header for why stranding legacy appointments is the worse failure. const serviceGateSatisfied = bookingServiceGateSatisfied({ selectedServiceId: selectedVisitService, isEditMode, }); // - may this booking proceed at all? // // BOOKING NO LONGER WAITS ON AN UNANSWERED AGREEMENT. A ruling superseded the answer this // predicate used to encode: the receptionist books over the phone, and an agreement nobody has // answered yet no longer refuses the booking - only a DECLINE or a WITHDRAWAL does. Check-in is // where an unanswered agreement still has to be resolved before care, and the receptionist // triage list already carries that status. // // WHY A DECLINE OR WITHDRAWAL STILL REFUSES, UNCHANGED BY THAT RULING. Spencer, in his own // words: "A patient can not be treated without all 3 things agreed to. Declining any of them // would prevent treatment. This means on decline any appointments need cancelled with that // org." A booking made into a decline is one the same rule requires to be cancelled the // moment it exists, so creating it is not a kindness to anybody. // // THE HALF THAT MAKES THIS SAFE, WHICH MUST NEVER BE STATED WITHOUT: the desk can always // record an answer for somebody who cannot or will not use the portal - the incapacitated, // the elderly, the tech-averse. They sign the paper form in the office and the receptionist // attests it at check-in. // // THE CAPTURE IS NOT ON THIS SCREEN, DELIBERATELY, and that is the seam rather than an // omission. Booking is the PHONE moment: the patient is on the other end of a line or not // involved at all, and attesting for somebody who is not in the room is the one thing an // attestation must never be. So the notice above NAMES the path instead of offering the // control - see `bookingAuthorizationNotice`. // // A REFUSAL AND A WITHDRAWAL ARE TOLD APART, where one predicate used to cover both. The // desk can act on neither, but for opposite reasons a receptionist has to hear: a decline is // a decision the patient made about care, a withdrawal is a permission they took back. One // sentence for two states sent the same person to do the same wrong thing about either. // // AND THE GATE IS ON THE SERVER TOO, which is the half that was missing entirely - see // `enforceConsentAnswered` in the api's appointment-booking-flags. This screen stopping is a // courtesy to the receptionist; the server refusing is the boundary. // // EDIT MODE IS EXEMPT, deliberately and unchanged - an appointment that already exists has // already been created, and blocking a receptionist from moving its time would punish the // patient for a gap in our flow rather than close it. // // 'unknown' IS THE READ FAILING, not a patient who has answered nothing, and it fails closed // for its own reason: "we could not find out" must never read as "no decline". const declinedInstrument = subjectConsent !== 'unknown' && CONSENT_INSTRUMENTS.some((id) => subjectConsent.instruments[id] === 'declined'); const withdrawnInstrument = subjectConsent !== 'unknown' && CONSENT_INSTRUMENTS.some((id) => subjectConsent.instruments[id] === 'withdrawn'); const authorizationSatisfied = isEditMode || (subjectConsent !== 'unknown' && !declinedInstrument && !withdrawnInstrument); // The patient's own name, because the panel's lines are sentences about a person rather than // labels: "Marcus Reed declined Consent to Treat" is what a desk can act on. The fallback keeps // them grammatical for the moment before a patient is chosen. const bookingNotice = useMemo( () => subjectPending ? null : bookingAuthorizationNotice( subjectConsent, formatFhirHumanName(selectedPatient?.name, { emptyText: 'This patient' }), !selectedPatient || patientHasLogin(selectedPatient), ), [subjectPending, subjectConsent, selectedPatient], ); const canShowDateAndTime = Boolean(serviceGateSatisfied && selectedSchedule && selectedAppointmentType); const canShowPrepItems = Boolean(selectedPatient && serviceGateSatisfied && selectedSchedule && selectedAppointmentType && selectedDate && selectedTime); // Effective appointment duration for the chosen service: per-service override org default 1 hour. const orgDefaultDurationMinutes = getOrganizationPolicyFlags( wpOrganization.current ?? undefined, ).defaultAppointmentDurationMinutes; const effectiveDurationMinutes = resolveAppointmentDurationMinutes(selectedService, orgDefaultDurationMinutes); // When the chosen service (or org default) changes, reset the duration to the new effective default; the // receptionist can still override it below. Keep the end time in sync with the chosen start + duration. useEffect(() => { setSelectedDurationMinutes(effectiveDurationMinutes); }, [effectiveDurationMinutes]); useEffect(() => { if (selectedTime) { setSelectedEndTime(getEndTime(selectedTime, selectedDurationMinutes)); } }, [selectedTime, selectedDurationMinutes]); const preparedQuestionnaireIds = useMemo( () => preparedTasks.flatMap((preparedTask) => preparedTask.kind === 'questionnaire' && preparedTask.questionnaire.id ? [preparedTask.questionnaire.id] : [], ), [preparedTasks], ); const existingQuestionnaireIds = useMemo( () => existingTasks.flatMap((task) => { if (getAkashicTaskKind(task) !== 'questionnaire') { return []; } const questionnaireId = getQuestionnaireIdFromTask(task); return questionnaireId ? [questionnaireId] : []; }), [existingTasks], ); const editingQuestionnaireId = editingExistingTask && getAkashicTaskKind(editingExistingTask) === 'questionnaire' ? getQuestionnaireIdFromTask(editingExistingTask) : undefined; const excludedQuestionnaireIds = useMemo( () => Array.from(new Set([ ...preparedQuestionnaireIds, ...existingQuestionnaireIds.filter( (questionnaireId) => questionnaireId !== editingQuestionnaireId, ), ])), [editingQuestionnaireId, existingQuestionnaireIds, preparedQuestionnaireIds], ); useEffect(() => { if (isEditMode) { setExistingTasks(assignedTasks); } }, [assignedTasks, isEditMode]); useEffect(() => { setEditingExistingTask(undefined); setTaskPendingDeletion(undefined); setTaskMutationError(undefined); }, [appointment?.id]); const monthOptions = useMemo( () => Array.from({ length: 12 }).map((_, index) => { const date = addMonths(getStartOfMonth(new Date()), index); return { value: getMonthKey(date), label: getMonthLabel(date), }; }), [], ); const dateOptions = useMemo(() => { const [year, month] = selectedMonth.split('-').map(Number); const today = new Date(); today.setHours(0, 0, 0, 0); const monthStart = new Date(year, month - 1, 1); const monthEnd = new Date(year, month, 0); const startDate = monthStart.getFullYear() === today.getFullYear() && monthStart.getMonth() === today.getMonth() ? today : monthStart; const daysRemaining = Math.max(0, monthEnd.getDate() - startDate.getDate() + 1); return Array.from({ length: daysRemaining }).map((_, index) => { const date = new Date(startDate); date.setDate(startDate.getDate() + index); return { value: formatDateOnly(date), label: date.toLocaleDateString('en-US', { weekday: 'short' }), day: date.getDate().toString(), }; }); }, [selectedMonth]); // (B41) - which of this month's dates the CHOSEN practitioner can actually be booked on. // The availability windows are already in hand the moment a practitioner is selected; the dialog only // ever consulted them one date at a time, AFTER the date had been picked and the slots fetched. // This answers "does this practitioner work that day", not "is there a free time" - a fully-booked // day stays enabled and the existing message explains it. See the module for why that direction. const selectableSchedules = useMemo( () => schedulesList.filter((schedule): schedule is Schedule & { id: string } => Boolean(schedule.id)), [schedulesList], ); const getScheduleRole = useCallback((schedule: Schedule): PractitionerRole | undefined => { const roleRef = schedule.actor?.find((a) => extractReferenceType(a) === 'PractitionerRole'); const roleId = getReferenceIdFromString(roleRef?.reference); if (!roleId) return undefined; for (const item of practitionersList) { const role = item.roles.find((r) => r.id === roleId); if (role) return role; } return undefined; }, [extractReferenceType, practitionersList]); const seatStanding = useSeatStanding(); const bookableSchedules = useMemo( () => selectableSchedules.filter((s) => { if (!getScheduleBookingFlags(s).allowBooking) return false; // Exclude schedules owned by a deactivated practitioner (PractitionerRole.active === false). The // schedule is preserved and reappears automatically once they're reactivated. const role = getScheduleRole(s); if (role && role.active === false) return false; // AND EXCLUDE ANYBODY WITHOUT A CLINICIAN SEAT. Until this line the only eligibility test was // "does this schedule list this service?", so attaching a clinical service to a receptionist's // schedule made them bookable - offered, selected, written, and never refused. return canBeBookedAsClinician(s, role, seatStanding); }), [selectableSchedules, getScheduleRole, seatStanding], ); const roleFilterOptions = useMemo(() => { const seen = new Set(); const options: { code: string; display: string }[] = []; for (const schedule of bookableSchedules) { const role = getScheduleRole(schedule); if (!role) continue; for (const coding of getPractitionerRoleOrganizationCodings(role)) { if (!coding.code || seen.has(coding.code)) continue; if (!isBookableServiceRole({ code: coding.code })) continue; seen.add(coding.code); options.push({ code: coding.code, display: coding.display || coding.code }); } } return options; }, [bookableSchedules, getScheduleRole]); // Location a schedule is for: the Schedule's own Location actor (new model), falling back to the role's // location for schedules saved before location moved onto the Schedule. // - reads EVERY Location actor, not just the first. This was a SECOND copy of the same // `.find(...)` bug fixed in PatientBookAppointment: a schedule can now carry the physical site AND // the org's virtual Location when the block offers video, so taking only the first meant the video // option never reached this picker at all. Two hand-kept copies of one rule is exactly how the // receptionist and the patient come to offer different things. const getScheduleLocationRefs = useCallback((schedule: Schedule, role?: PractitionerRole) => { const scheduleLocationRefs = getScheduleLocationReferences(schedule); return scheduleLocationRefs.length > 0 ? scheduleLocationRefs : (role?.location ?? []); }, []); const locationFilterOptions = useMemo(() => { const roleFiltered = selectedRoleFilter ? bookableSchedules.filter((s) => { const role = getScheduleRole(s); return role && getPractitionerRoleOrganizationCodings(role).some((c) => c.code === selectedRoleFilter); }) : bookableSchedules; const seen = new Set(); const options: { id: string; display: string }[] = []; let hasAllLocationsRole = false; for (const schedule of roleFiltered) { const role = getScheduleRole(schedule); if (!role) continue; const roleLocations = getScheduleLocationRefs(schedule, role); // No explicit site refs anywhere (neither the schedule nor the role) - bookable at the org's // full location list below, but ONLY if the role actually says it covers every site. Card // 256: offering a practitioner at every site because their record is incomplete is the // booking-surface form of that fail-open. Mirrors PatientBookAppointment's copy of this // block, and is behaviour-identical until the backfill lands. if (roleLocations.length === 0) { if (isOrgWideRole(role)) hasAllLocationsRole = true; continue; } for (const locRef of roleLocations) { const locId = getReferenceIdFromString(locRef.reference); if (!locId || seen.has(locId)) continue; seen.add(locId); options.push({ id: locId, display: locRef.display || locId }); } } if (hasAllLocationsRole) { for (const loc of onlySites(locationsList)) { if (!loc.id || seen.has(loc.id)) continue; seen.add(loc.id); options.push({ id: loc.id, display: loc.name || loc.id }); } } // - a practice that does not offer video should not be offered "Video visit" and then // refused. Shared with the patient picker so the two cannot drift. // // - and neither should a service that cannot be DELIVERED by video. Resolved per // candidate role, not from the org default alone: lets a practitioner override // eligibility for a service their practice defaults off, and keying on the default would hide // video from the one practitioner who explicitly enabled it. `some` is the honest quantifier - // this list is a UNION over the candidate schedules, so the option belongs if ANY of them can // deliver it that way; the block and practitioner narrowing happens further down the flow. const serviceAllowsVideo = selectedService !== undefined && roleFiltered.some((schedule) => resolveServiceTelehealthEligible(selectedService, getScheduleRole(schedule)), ); return offerableLocationOptions( options, onlyVirtual(locationsList)[0]?.id, resolveOrgAppointmentPolicy(wpOrganization.current ?? undefined).telehealthEnabled, serviceAllowsVideo, ); }, [bookableSchedules, getScheduleRole, getScheduleLocationRefs, selectedRoleFilter, locationsList, wpOrganization, selectedService]); /** * WHERE THE CHOSEN BLOCK CAN HAPPEN - which, on a block that also offers video, is a MODALITY choice. * * The row above is a FILTER: it narrows the practitioner list, so it correctly disappears once a * practitioner is chosen. The Location control was inside it, and that is the defect - it is not * only a filter. Telehealth is a Location here, so it is also the single place anybody can say a * visit happens by video, and `resolveBookingLocationRef` reads exactly this value. With it hidden, * a block carrying both actors falls back to actor order, which is the PHYSICAL site. * * The person the own-schedule seed is CORRECT for - a clinician booking their own visit - was the * one person who could never say "video", and the booking succeeded silently as in-person. * An earlier fix stopped the seed firing for the wrong person; it did not give the right person the control back. * * SCOPED TO THE SELECTED BLOCK, not the union above, because that is what the question means once a * practitioner is chosen: not "which sites could I filter by" but "where does THIS block happen". * A block with one location yields one option and nothing renders, so an in-person booking is still * one step and no new field appears - that is control 2. * * Video eligibility is resolved against THIS block's own role rather than `some` across candidates, * which is the stricter and more accurate question now that there is exactly one candidate. */ const chosenBlockLocationOptions = useMemo(() => { if (!selectedSchedule) return []; const role = getScheduleRole(selectedSchedule); const seen = new Set(); const options: { id: string; display: string }[] = []; for (const ref of getScheduleLocationRefs(selectedSchedule, role)) { const id = getReferenceIdFromString(ref.reference); if (!id || seen.has(id)) continue; seen.add(id); options.push({ id, display: ref.display || locationsList.find((loc) => loc.id === id)?.name || id, }); } return offerableLocationOptions( options, onlyVirtual(locationsList)[0]?.id, resolveOrgAppointmentPolicy(wpOrganization.current ?? undefined).telehealthEnabled, selectedService !== undefined && resolveServiceTelehealthEligible(selectedService, role), ); }, [ selectedSchedule, getScheduleRole, getScheduleLocationRefs, locationsList, wpOrganization, selectedService, ]); /** * Drop a location choice ONLY once the filters stop offering it - and never merely because some * other filter moved. * * WHY THIS IS NOT COSMETIC: a location choice is a MODALITY choice here. Telehealth is a Location * on this project, so "Video Visit" in the location control is the only way a front desk can say * a visit happens by video - `resolveBookingLocationRef` reads exactly this value, and with it * empty a block carrying both actors falls back to actor order, which is the PHYSICAL site. * * `handleRoleFilterChange` used to clear it unconditionally. So: choose Video Visit, then narrow * by role to find the right clinician, and the visit silently became in person - no warning, no * changed label, and the location control reading "All locations" again several steps later. * The clearing was defensible in intent (a location the new filter no longer offers must not * linger) and wrong in reach, because narrowing by role usually leaves the location perfectly * valid. * * Keyed on `locationFilterOptions` rather than re-deriving the rule, deliberately. That memo * already IS the answer to "what locations are on offer right now" - it accounts for the role * filter, the org telehealth policy, the service's video eligibility and the org-wide-role * fallback. A second copy in the handler would have to reproduce all four and would drift. */ useEffect(() => { if (!selectedLocationFilter) return; if (locationFilterOptions.some((option) => option.id === selectedLocationFilter)) return; if (chosenBlockLocationOptions.some((option) => option.id === selectedLocationFilter)) return; setSelectedLocationFilter(''); }, [locationFilterOptions, chosenBlockLocationOptions, selectedLocationFilter]); // An edit shows where the visit is until someone changes Where. The video option appears only once the // visit's service is known, so a seed taken earlier is cleared above; this puts it back. useEffect(() => { if (!isEditMode || whereTouched || !appointment) return; const booked = getReferenceIdFromString( getAppointmentParticipantReference(appointment, ['Location'])?.reference, ); if (booked && booked !== selectedLocationFilter && chosenBlockLocationOptions.some((option) => option.id === booked)) { setSelectedLocationFilter(booked); } }, [isEditMode, whereTouched, appointment, chosenBlockLocationOptions, selectedLocationFilter]); /** * ONE PREDICATE, TAKEN AS PARAMETERS RATHER THAN CLOSING OVER THE SELECTED STATE. * * It reads exactly as it did when it was inline; the only change is that the three narrowings * arrive as arguments. That is what lets the empty-picker explanation below re-run THIS function * with one of them dropped, instead of asking the matcher a parallel question and hoping the two * agree. A remedy the desk is offered has therefore already been proved against the list the desk * is about to see. */ const scheduleMatchesSelection = useCallback( (schedule: Schedule, selection: FrontDeskSelection): boolean => { const role = getScheduleRole(schedule); if (selection.role) { if (!role) return false; if (!getPractitionerRoleOrganizationCodings(role).some((c) => c.code === selection.role)) return false; } if (selection.location) { if (!role) return false; const roleLocations = getScheduleLocationRefs(schedule, role); // A role scoped to "All locations" (empty) practices everywhere, so it matches any chosen // location; a role with explicit sites must list the selected one. if ( roleLocations.length > 0 && !roleLocations.some((loc) => getReferenceIdFromString(loc.reference) === selection.location) ) { return false; } } // Service-first: only schedules that OFFER the selected service (its HealthcareService actor ref). // The service is REQUIRED for a new booking (Spencer, 2026-08-03) - it is what determines which // locations and practitioners are bookable, so an unnarrowed list is the wrong starting point, // not a permissive one. Mirrors the patient flow, where `bookablePractitioners` returns [] until // a service is chosen. On EDIT the schedule is seeded directly rather than picked from here, so // an empty option list never strands an existing appointment. if (selection.service) { const offersService = (schedule.actor ?? []).some( (ref) => ref.reference === `HealthcareService/${selection.service}`, ); if (!offersService) return false; } return true; }, [getScheduleRole, getScheduleLocationRefs], ); const deskSelection = useMemo( () => ({ role: selectedRoleFilter || undefined, location: selectedLocationFilter || undefined, service: selectedVisitService || undefined, }), [selectedRoleFilter, selectedLocationFilter, selectedVisitService], ); const filteredSchedules = useMemo(() => { // No service chosen on a NEW booking => no practitioners to offer yet. The service is the first // narrowing, not an optional one. if (!serviceGateSatisfied) return []; return bookableSchedules.filter((schedule) => scheduleMatchesSelection(schedule, deskSelection)); }, [bookableSchedules, scheduleMatchesSelection, deskSelection, serviceGateSatisfied]); // A seeded practitioner the narrowed list does not hold is still listed, so the field can name them. const practitionerPickerSchedules = useMemo( () => (selectedSchedule?.id && !filteredSchedules.some((schedule) => schedule.id === selectedSchedule.id) ? [selectedSchedule, ...filteredSchedules] : filteredSchedules), [filteredSchedules, selectedSchedule], ); /** * ONE LABEL PER ROW, AND THE EXTRA DETAIL ONLY WHERE TWO ROWS WOULD READ THE SAME. * * The label is already disambiguated by location, which covers a practitioner working at two * sites. It does not cover TWO BLOCKS AT ONE SITE - measured at the desk, where * `Dr. Practitioner One - West Burlington Hospital` was offered twice with nothing to choose * between, and a receptionist cannot know which diary they are booking into. * * The block's own dates are the distinguishing fact, and they are the answer to the question the * reader actually has. A row that is already unique keeps the label it has, so the list stays * scannable and the suffix appears next to its twin, where it explains itself. */ const practitionerOptionLabels = useMemo( () => distinguishDuplicateOptions(filteredSchedules, { key: (schedule) => schedule.id as string, label: (schedule) => getSchedulePractitionerDisplay( schedule, practitionersList, extractReferenceType, locationsList, ), detail: (schedule) => formatDateRange( schedule.planningHorizon?.start, schedule.planningHorizon?.end, ) || undefined, }), [filteredSchedules, practitionersList, extractReferenceType, locationsList], ); /** * WHY A ROW IN THIS LIST CANNOT BE BOOKED, on the row, instead of after the form is filled in. * * The desk chose a follow-up consultation, was offered one practitioner - "Dr. Practitioner One - * Video Visit" - and pressed Book with a patient on the phone, and the server answered *"This * service cannot be delivered by video."* It was the only option, so the service was unbookable at * that moment and the dialog never said so. * * THE SERVER GATE IS RIGHT AND IS NOT TOUCHED. What is wrong is a list that offers what the gate * will refuse. The rule is read from the same resolver the Location control uses, per candidate * role rather than from the org default, so a practitioner who enabled video for a service their * practice defaults off still appears. * * DISABLED WITH THE REASON RATHER THAN HIDDEN, which is the standing pattern here (a practitioner * declining patient messages is shown disabled and says why). Hiding the only practitioner who * covers a service teaches the desk that nobody offers it, which is a different and worse untruth. */ const scheduleVideoRefusals = useMemo(() => { const refusals = new Map(); const virtualLocationId = onlyVirtual(locationsList)[0]?.id; if (!selectedService || !virtualLocationId) return refusals; const orgOffersTelehealth = resolveOrgAppointmentPolicy( wpOrganization.current ?? undefined, ).telehealthEnabled; for (const schedule of filteredSchedules) { const role = getScheduleRole(schedule); const locationIds = getScheduleLocationRefs(schedule, role).map((ref) => getReferenceIdFromString(ref.reference), ); if (!blockIsVideoOnly(locationIds, virtualLocationId)) continue; if (!orgOffersTelehealth) { refusals.set(schedule.id as string, VIDEO_ONLY_PRACTICE_DISABLED); } else if (!resolveServiceTelehealthEligible(selectedService, role)) { refusals.set(schedule.id as string, VIDEO_ONLY_SERVICE_INELIGIBLE); } } return refusals; }, [filteredSchedules, getScheduleRole, getScheduleLocationRefs, locationsList, selectedService, wpOrganization]); /** * The one sentence the FIELD can state, for the case the card was raised on: every practitioner * offered is one this booking cannot use. Stating it on the trigger costs no click to read, which * is the same reason the "choose a visit service first" placeholder is there. * * Only when they all agree on why. Two rows refused for different reasons is not one sentence, and * inventing a summary for it would say less than the rows do. */ const everyPractitionerRefused = useMemo(() => { if (filteredSchedules.length === 0) return undefined; if (scheduleVideoRefusals.size !== filteredSchedules.length) return undefined; const reasons = new Set(scheduleVideoRefusals.values()); return reasons.size === 1 ? [...reasons][0] : undefined; }, [filteredSchedules, scheduleVideoRefusals]); /** * WHAT TO TRY WHEN THE PICKER IS EMPTY - and nothing at all the rest of the time. * * Gated on `serviceGateSatisfied` because an unchosen service is not a dead end: the list is empty * because the question has not been narrowed yet, and the picker already says so. Speaking there * would be the alert fatigue this card was raised against. */ const deskRemedy = useMemo(() => { if (!serviceGateSatisfied) return undefined; const countMatching = (selection: FrontDeskSelection) => bookableSchedules.filter((s) => scheduleMatchesSelection(s, selection)).length; /* * A CATALOGUE GAP AND A BUSY DIARY ARE DIFFERENT PROBLEMS AND USED TO SHARE ONE SENTENCE. * * "Try a different visit service" is the right remedy when other services work here. It is the * wrong sentence entirely when NOBODY offers the chosen one anywhere, because it asks a * receptionist to work around a setup gap as though it were an afternoon that happens to be full - * and no amount of changing the time or the location will open it. * * Measured at the main test practice: seven services in the catalogue, two attached to any schedule * block. Five of seven empty pickers were the chain working correctly, and the desk could not say * so - which is why a partly-wired catalogue reads as a working one until somebody picks the * service nobody wired. * * Asked FIRST, because it is the more specific answer: when it is true the relaxation list has * nothing useful to add, and offering both would put a remedy that cannot work underneath one that * explains why. */ if (serviceIsOfferedByNobody(deskSelection, countMatching)) { return serviceOfferedByNobodyText( selectedService?.name?.trim() || 'this visit service', ); } return frontDeskRemedyText(frontDeskRelaxations(deskSelection, countMatching)); }, [ serviceGateSatisfied, deskSelection, bookableSchedules, scheduleMatchesSelection, selectedService, ]); useEffect(() => { const loadError = schedulesLoadError || practitionersLoadError; if (!isEditMode || !loadError) { return; } setInitializingEdit(false); setEditInitializationError(loadError); }, [isEditMode, practitionersLoadError, schedulesLoadError]); useEffect(() => { if (!appointment?.id) { setInitializingEdit(false); return; } if ( initializedEditAppointmentIdRef.current === appointment.id || schedulesList.length === 0 || practitionersList.length === 0 ) { return; } let active = true; const initializeEditForm = async () => { setInitializingEdit(true); setEditInitializationError(undefined); try { const patientReference = getAppointmentParticipantReference( appointment, ['Patient'], ); const patientId = getReferenceIdFromString(patientReference?.reference); if (!patientId) { throw new Error('The appointment does not include a patient reference.'); } const nextPatient = await fetchPatient(patientId); const practitionerReference = getAppointmentParticipantReference( appointment, ['Practitioner', 'PractitionerRole'], ); const practitionerReferenceType = practitionerReference ? extractReferenceType(practitionerReference) : undefined; const practitionerReferenceId = getReferenceIdFromString( practitionerReference?.reference, ); /* * A TIME-WINDOW REQUEST NAMES NOBODY, AND THAT ABSENCE IS THE FEATURE. * * A request-only practice picks one of two variants. Preferred-slot carries a * practitioner and a held slot, so everything below resolves and the desk approves what * the patient chose. Preferred days & times carries a reason and the patient's general * availability and nothing else, precisely because the PRACTICE is the one that decides * who they see and when. * * This form used to treat that as a fault: with no practitioner on the appointment it * threw "The appointment practitioner is not available for scheduling." and left the desk * a dialog with no fields and one Close button. So the one variant whose whole purpose is * that the practice chooses was the one variant with no way to choose. * * Placing seeds what the request DOES say - the patient, the reason, the service - and * leaves practitioner, date and time unset, so the ordinary choosing cascade renders and * a person picks them. Nothing is auto-filled: seeding the first available practitioner * would make the screen work and delete the reason the practice chose this mode. * * A practitioner who IS named and cannot be found is still an error. That is a * deactivated or unschedulable person, which is a different thing from nobody at all. * * `isPlacement` is the component-scope memo over the same appointment, so the form's * wording and this bootstrap can never disagree about which of the two this is. */ const resolveScheduling = () => { const practitionerWithRoles = practitionersList.find((item) => { if (practitionerReferenceType === 'Practitioner') { return item.practitioner.id === practitionerReferenceId; } if (practitionerReferenceType === 'PractitionerRole') { return item.roles.some((role) => role.id === practitionerReferenceId); } return false; }); if (!practitionerWithRoles) { throw new Error('The appointment practitioner is not available for scheduling.'); } const roleIds = new Set( practitionerWithRoles.roles .map((role) => role.id) .filter((roleId): roleId is string => Boolean(roleId)), ); const nextSchedule = initialSchedule || schedulesList.find((schedule) => schedule.actor?.some((actor) => { const actorType = extractReferenceType(actor); const actorId = getReferenceIdFromString(actor.reference); if (actorType === 'Practitioner') { return actorId === practitionerWithRoles.practitioner.id; } return actorType === 'PractitionerRole' && Boolean(actorId && roleIds.has(actorId)); }), ); if (!nextSchedule) { throw new Error('A schedule could not be found for the appointment practitioner.'); } const scheduleRoleReference = nextSchedule.actor?.find( (actor) => extractReferenceType(actor) === 'PractitionerRole', ); const scheduleRoleId = getReferenceIdFromString( scheduleRoleReference?.reference, ); const nextPractitionerRole = practitionerWithRoles.roles.find( (role) => role.id === scheduleRoleId, ) || practitionerWithRoles.roles[0]; if (!nextPractitionerRole) { throw new Error('The appointment practitioner role is unavailable.'); } const nextDate = formatAppointmentDateInput(appointment.start); const nextStartTime = formatAppointmentTimeInput(appointment.start); const nextEndTime = formatAppointmentTimeInput(appointment.end) || getEndTime(nextStartTime); if (!nextDate || !nextStartTime) { throw new Error('The appointment date and time are invalid.'); } return { practitionerWithRoles, nextSchedule, nextPractitionerRole, scheduleRoleReference, nextDate, nextStartTime, nextEndTime, }; }; const scheduling = isPlacement ? undefined : resolveScheduling(); if (!active) { return; } initializedEditAppointmentIdRef.current = appointment.id; editBasis.open(appointment); setSelectedPatient(nextPatient); setPatientSearch(formatFhirHumanName(nextPatient.name, { emptyText: 'Selected Patient' })); if (scheduling) { setSelectedSchedule(scheduling.nextSchedule); setPractitionerRole(scheduling.nextPractitionerRole); setPractitioner(scheduling.practitionerWithRoles.practitioner); setSelectedPractitioner({ name: formatFhirHumanName( scheduling.practitionerWithRoles.practitioner.name, { emptyText: practitionerReference?.display || 'Unknown Practitioner' }, ), role: scheduling.scheduleRoleReference?.display || scheduling.nextSchedule.specialty?.[0]?.text || 'Practitioner', }); } setSelectedAppointmentType(getAppointmentTypeValue(appointment)); setSelectedVisitService(visitServiceIdFor(appointment, healthcareServicesList)); const resolvedStatus = getReceptionistAppointmentStatus(appointment); setSelectedStatus(resolvedStatus === 'pending' ? 'booked' : resolvedStatus); setDescription(appointment.description || ''); setReasonForVisit(appointment.reasonCode?.[0]?.text || ''); setSelectedLocationFilter( getReferenceIdFromString( getAppointmentParticipantReference(appointment, ['Location'])?.reference, ) ?? '', ); if (scheduling) { setSelectedMonth(getMonthKey(new Date(appointment.start as string))); setSelectedDate(scheduling.nextDate); setSelectedTime(scheduling.nextStartTime); setSelectedEndTime(scheduling.nextEndTime); appointmentSeedRef.current = { start: appointment.start as string, end: appointment.end }; initialScheduleIdRef.current = scheduling.nextSchedule.id; } } catch (error) { console.error('Failed to initialize receptionist appointment editor:', error); if (active) { setEditInitializationError(getErrorMessage(error)); } } finally { if (active) { setInitializingEdit(false); } } }; void initializeEditForm(); return () => { active = false; }; }, [ appointment, extractReferenceType, fetchPatient, initialSchedule, practitionersList, schedulesList, ]); // The catalogue can arrive after the edit form was filled; map the visit's service once it does. useEffect(() => { if (!isEditMode || serviceTouched || selectedVisitService || healthcareServicesList.length === 0) return; const mapped = visitServiceIdFor(appointment, healthcareServicesList); if (mapped) setSelectedVisitService(mapped); }, [appointment, healthcareServicesList, isEditMode, selectedVisitService, serviceTouched]); // Seed a NEW booking from a calendar drag-select: the practitioner's schedule plus the exact time the // user dragged. Runs at most once - a re-seed would overwrite a deliberate change and make the form // feel possessed. The edit path has its own initialisation above and is deliberately excluded. // // The SERVICE is intentionally NOT seeded: the calendar knows the practitioner and the time, not what // the visit is for, and that is the one answer this flow now requires. If the service the user then // picks is not offered by the dragged schedule, handleVisitServiceChange clears the practitioner - // correct behaviour, because the service is what decides who can be booked. /** * SEED THE BOOKER'S OWN ANSWERS: themselves, and the patient they are in a visit with. * * Spencer, 2026-08-25: *"if we keep the option it should default to that practitioner and the * patient theyre in an encounter with if in an encounter."* * * What it replaces: the dialog opened with NOTHING chosen and offered every practitioner in the * practice, with nothing marking which one was you - while the page behind it was already filtered * to your own calendar. So a clinician standing on their own day had to find themselves in a list * of colleagues, and the likeliest slip was booking onto somebody else's calendar while the page * above still said their name. * * SimplePractice is the comparison Spencer asked about and it supports keeping the control: a * clinician there lands on their OWN calendar and books from it, because the product is built for * practices where the clinician is often the scheduler. The capability is right; the blank field * was the defect. * * SEEDS, NEVER OVERRIDES - the same rule as the drag-select seed below it and the appointments * page's own-schedule seed. It runs once, only while the field is still empty, so a re-render, a * re-fetch or a WebSocket update cannot yank the form off a deliberate choice. * A receptionist or admin has no own-practitioner to find and is left exactly as before: the * picker is their normal path and must not be disturbed. * Editing an existing appointment is excluded, like every other seed here. */ /** * THE PATIENT HALF of Spencer's ruling - seeded immediately, because it depends on nothing else. * * Only while a VISIT IS OPEN, and only the visit's own Encounter.subject: a chart on screen * is not a visit, and with no visit open nothing is guessed. */ const seededOwnPatientRef = useRef(false); // Which seed chose the patient, while it stands: a chart's own patient first, else the open visit's. const [seededFrom, setSeededFrom] = useState<'chart' | 'open-visit' | undefined>(); useEffect(() => { if (isEditMode || seededOwnPatientRef.current) return; const seed = bookingPatientSeed(initialPatient, openVisitPatient); if (!seed) return; seededOwnPatientRef.current = true; if (!selectedPatient) { setSelectedPatient(seed.patient); setSeededFrom(seed.from); } }, [initialPatient, openVisitPatient, isEditMode]); const seededInitialRangeRef = useRef(false); // The instants a seeded booking opened on, re-read on the practice clock once that clock is known. const appointmentSeedRef = useRef<{ start: string; end?: string; shown?: string } | null>(null); useEffect(() => { if (isEditMode || seededInitialRangeRef.current || !initialStart) { return; } seededInitialRangeRef.current = true; if (initialSchedule) { setSelectedSchedule(initialSchedule); } const startDate = formatAppointmentDateInput(initialStart); const startTime = formatAppointmentTimeInput(initialStart); setSelectedMonth(getMonthKey(new Date(initialStart))); setSelectedDate(startDate); setSelectedTime(startTime); setSelectedEndTime( (initialEnd ? formatAppointmentTimeInput(initialEnd) : '') || getEndTime(startTime), ); appointmentSeedRef.current = { start: initialStart, end: initialEnd }; }, [isEditMode, initialStart, initialEnd, initialSchedule]); useEffect(() => { const timeout = window.setTimeout(() => setPatientSearchTerm(patientSearch.trim()), 250); return () => window.clearTimeout(timeout); }, [patientSearch]); // searchPatients hides inactive records and says how many, so a hidden match is never "not found". const readPatientSearch = useCallback(async (term: string) => { try { const bundle: Bundle = await searchPatients(term); return { patients: unpackBundle(bundle) as Patient[], excludedInactiveCount: (bundle as Partial).excludedInactiveCount ?? 0, excludedMergedCount: (bundle as Partial).excludedMergedCount ?? 0, }; } catch (error) { console.error('Failed to load patients for appointment booking:', error); throw error; } }, [searchPatients, unpackBundle]); // Keyed on the settled term: an older query answering late is dropped, never listed under a newer one. const patientSearchRead = useKeyedRead( !isEditMode && !selectedPatient && patientSearchTerm ? patientSearchTerm : undefined, readPatientSearch, ); const patientSearchPending = patientSearchTerm !== patientSearch.trim() || patientSearchRead.status === 'pending'; const patientSearchResults = patientSearchRead.status === 'ready' ? patientSearchRead.value.patients : []; const excludedInactiveCount = patientSearchRead.status === 'ready' ? patientSearchRead.value.excludedInactiveCount : 0; const excludedMergedCount = patientSearchRead.status === 'ready' ? patientSearchRead.value.excludedMergedCount : 0; const patientError = patientSearchRead.status === 'failed' ? 'Could not load patients. Try again.' : undefined; useEffect(() => { if (!selectedPatient || isEditMode) { return; } window.requestAnimationFrame(() => { scrollAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end', }); }); // Only the task card grows under the reader now; the rest of the form is drawn from the start. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ activePrepForm, preparedTasks.length, taskAssignmentError, ]); /** * EVERY BLOCK THIS PRACTITIONER WORKS, each with its own windows. * * THE DEFECT THIS REPLACES, measured in a browser on 2026-08-27. This effect used to read the ONE * `selectedSchedule`, so a clinician with two periods had one of them invisible here: * * Schedules page, Thursday "9:00am - 11:00pm - Scheduled - Consultation" * this picker "Thu, 27 - not working" aria-disabled="true" * * The control was in the same list - Monday, from the FIRST period, was offered, and Saturday, covered * by nothing, was correctly refused. So availability was not broken; it was reading one period. * Extra hours could be saved and never booked, and the practice finds that out from a patient rather * than from the product. * * The patient flow already unioned its blocks (`bookingAvailabilityUnion.ts`). This is the same * question asked the same way on the staff side, so the two halves cannot answer it differently. * * THE SITE IS RESOLVED PER BLOCK, which the single-schedule version could not do. The clock a * block's hours are written in is its own physical site's, and a practitioner working two sites has * two clocks - taking the selected schedule's site would have written the other block's hours in the * wrong zone. */ const practitionerSchedulesWithEvents = useMemo(() => { const practitionerId = practitioner?.id; if (!practitionerId) return []; const mine = schedulesForPractitioner(filteredSchedules, practitionerId, (schedule) => getReferenceIdFromString(getScheduleRole(schedule)?.practitioner?.reference), ); const perBlock = mine.map((schedule) => { const role = getScheduleRole(schedule); if (!role) return { schedule, siteId: undefined, events: [] as Array }; // `onlySites`, never `[0]`: a video-enabled schedule carries TWO Location actors, the // physical site and the org's virtual one, and a virtual Location has no clock of its own. // Taking the first would hand a telehealth schedule the virtual Location and fall through to // the org zone - right by luck for a single-site practice, wrong for a multi-site one. const scheduleSite = firstPhysicalSite(getScheduleLocationRefs(schedule, role), locationsList); return { schedule, siteId: scheduleSite?.id, events: getScheduleAvailabilityEvents( schedule, transformFromFhirPractitionerRole(role), scheduleSiteFor(schedule, role, locationsList), ) as Array, }; }); // AND THE UNION STOPS AT THE SITE THE DESK CHOSE. // // The picker lists one row per BLOCK and names the site on it. Unioning every block this person // works offered days that only their OTHER hospital's block covers, then offered that block's // hours under the row the desk had picked - and selecting such a day removed the Where field // rather than correcting it, so the booking was confirmed from a summary naming no site at all. // The server accepts it: nothing there compares a time to the schedule it is booked on. // // Narrowing by SITE rather than to the chosen block is what keeps the defect this union exists // for fixed - two periods at ONE site still union, which is the 2026-08-27 case. return blocksAtChosenSite(perBlock, chosenSiteId); }, [filteredSchedules, practitioner, getScheduleRole, getScheduleAvailabilityEvents, transformFromFhirPractitionerRole, getScheduleLocationRefs, locationsList, chosenSiteId]); useEffect(() => { if (!selectedSchedule || !practitionerRole) { setAvailabilityEvents([]); return; } // Only true availability windows are bookable. getScheduleAvailabilityEvents also emits time-off // ('not-available') marker events into the same array; without dropping them, isTimeSlotAvailable // treats a time-off block as a bookable window and offers slots inside declared time off. That // filter lives in `unionAvailability`, shared with the bookability bands so the two cannot drift. setAvailabilityEvents(unionAvailability(practitionerSchedulesWithEvents)); }, [practitionerSchedulesWithEvents, practitionerRole, selectedSchedule]); /** * THE DAY IS FILED AGAINST THE BLOCK THAT ACTUALLY COVERS IT. * * The union above answers *"can this day be booked"*. Everything downstream is still a question about * ONE schedule - the slot fetch, the timezone the hours are written in, and the block the appointment * is filed against. Choosing a Thursday from the union and leaving `selectedSchedule` on the Mon/Wed * block would file the booking against a block that does not contain it, which is the same defect one * level down. * * It re-points ONLY when a different block owns the date. Setting it unconditionally would fight * the user's own choice on a day two blocks share, and re-pointing to the same value every render is * how an effect loops. */ useEffect(() => { if (!selectedDate || practitionerSchedulesWithEvents.length < 2) return; const owner = scheduleCoveringDate(practitionerSchedulesWithEvents, selectedDate); if (!owner || owner.id === selectedSchedule?.id) return; setSelectedSchedule(owner); const ownerRole = getScheduleRole(owner); if (ownerRole && ownerRole.id !== practitionerRole?.id) { setPractitionerRole(ownerRole); } }, [selectedDate, practitionerSchedulesWithEvents, selectedSchedule, practitionerRole, getScheduleRole]); /** * THE CLOCK THE PRACTICE MEANS, resolved once and used both to explain a time and to store one. * * It was derived inside the viewer-zone notice and used only for a label, while the write built its * instant from the BROWSER's clock - so a receptionist in Denver booking "2:00 PM" at an Iowa * practice stored 21:00Z where the practice meant 19:00Z. The format was right and the moment was * wrong, and nothing on screen was red. Meanwhile the slots being offered were already generated in * this zone by `ScheduleService`, so the list and the booking disagreed exactly when it mattered. * * From the SITE of the block being booked, falling back to the organization - Spencer's rule, 2026-08-10. * `firstPhysicalSite` rather than `location[0]`, because a video-enabled block carries the org's * virtual Location too and a virtual Location has no clock of its own. * * `null` is a real answer and must never be papered over with the viewer's zone: that fallback is * the defect. Callers refuse. */ const practiceZone = useMemo(() => { if (!selectedSchedule) return null; const site = scheduleSiteFor(selectedSchedule, practitionerRole ?? undefined, locationsList); return resolveBookingTimeZone({ site, organization: wpOrganization.current ?? undefined, }); }, [selectedSchedule, practitionerRole, locationsList, wpOrganization]); const newerThanEdit = editBasis.newer; // A newer version saved elsewhere while this is open marks it changed, as a refused save does. const staleReason: StaleReason | null = staleOpening ?? (newerThanEdit ? 'changed' : null); // Reload record: the appointment as saved now, read by id, opens a new edit of it and discards this one's changes. const reloadEditedAppointment = async () => { const appointmentId = appointment?.id; if (!appointmentId) return; editScope.renew(); const reading = editScope.start(); setReloading(true); setReloadError(undefined); try { const fresh = await reading.step(() => fetchAppointment(appointmentId, true, reading.transport)); if (fresh?.resourceType !== 'Appointment' || fresh.id !== appointmentId) throw new Error('Not this appointment'); initializedEditAppointmentIdRef.current = undefined; setStaleOpening(null); setSavedWithFollowUps([]); setFailedVisitRecordLine(undefined); setReopened(fresh); onAppointmentUpdated?.(fresh); } catch (error) { if (isSaveRetired(error) || !reading.belongs()) return; setReloadError('The appointment could not be read. Try Reload record again.'); } finally { if (reading.belongs()) setReloading(false); } }; // Is whoever is looking on a different clock from the practice? Then every time names the practice's clock. // // A RECEPTIONIST IS USUALLY AT THE PRACTICE - AND "USUALLY" IS THE PROBLEM. A remote or // travelling receptionist reading "9:00 AM" has no way to know it is the practice's 11:00, and they // are booking on a PATIENT's behalf, so the error is handed straight to someone who was never shown // a timezone at all. Compared by OFFSET at a real instant rather than by zone name, so this stays // silent for the ordinary case instead of becoming a banner everyone learns to ignore. const practiceZoneNotice = useMemo(() => { if (!selectedSchedule || !practitionerRole || !selectedDate) return null; if (!practiceZone) return null; const noon = new Date(`${selectedDate}T12:00:00`); if (!viewerDiffersFromPractice(noon, practiceZone)) return null; return new Intl.DateTimeFormat('en-US', { timeZone: practiceZone, timeZoneName: 'short' }) .formatToParts(noon).find((p) => p.type === 'timeZoneName')?.value ?? practiceZone; }, [selectedSchedule, practitionerRole, selectedDate, practiceZone]); // A task's visit is read on the same clock as the slots beside it, named when the slots are. const appointmentClock = useMemo( () => practiceZone ? { timeZone: practiceZone, named: Boolean(practiceZoneNotice) } : undefined, [practiceZone, practiceZoneNotice], ); const editingCustomTaskDraft = useMemo( () => editingExistingTask && getAkashicTaskKind(editingExistingTask) === 'custom' ? getExistingCustomTaskDraft(editingExistingTask, appointment, appointmentClock) : undefined, [appointment, editingExistingTask, appointmentClock], ); // The clock the dates and times are drawn on, and the one the write reads the choice back on. With no // practice clock the write is refused; the viewer's clock only gives that refusal something to sit beside. const gridZone = practiceZone ?? viewerTimeZone(); // The selected day's busy times, read on the clock the grid draws that day on. Keyed on the schedule, the // date and that clock, so one clinician's busy times never gate another's and a changed clock reads again. const slotKey = selectedSchedule?.id && selectedDate ? `${selectedSchedule.id}|${selectedDate}|${gridZone}` : undefined; const readSlots = useCallback(async (key: string) => { const [, date, zone] = key.split('|'); const { start, end } = slotReadWindow(date, [zone]); try { // The practitioner's held time anywhere rides with this Schedule's Slots: either failing is not free. return await readBusyTimes([selectedSchedule as Schedule], start, end, { fetchSlots, fetchHeldTimes, unpack: (bundle) => filterAndUnpackBundle(bundle, 'Slot') as Slot[], }); } catch (error) { console.error('Failed to load slots:', error); throw error; } }, [fetchSlots, fetchHeldTimes, filterAndUnpackBundle, selectedSchedule]); // Failed is not free. const slotRead = useKeyedRead(slotKey, readSlots, slotReadAttempt); const slots = useMemo(() => (slotRead.status === 'ready' ? slotRead.value.slots : []), [slotRead]); const heldTimes = useMemo(() => (slotRead.status === 'ready' ? slotRead.value.held : []), [slotRead]); const slotsFailed = slotRead.status === 'failed'; const loadingSlots = slotRead.status === 'pending'; const bookableDates = useMemo( () => monthAvailability( dateOptions.map((option) => option.value), availabilityEvents, { now: new Date(), durationMinutes: selectedDurationMinutes, timeZone: gridZone }, ), [dateOptions, availabilityEvents, selectedDurationMinutes, gridZone], ); // A booking that opened on an existing time shows it on the practice clock once that clock is known, // and again if it resolves differently, until someone picks another date or time. useEffect(() => { const seed = appointmentSeedRef.current; if (!seed || !practiceZone) return; if (seed.shown && seed.shown !== `${selectedDate} ${selectedTime}`) { appointmentSeedRef.current = null; return; } const start = new Date(seed.start); const date = calendarDateInZone(start, practiceZone); const time = wallClockInZone(start, practiceZone); seed.shown = `${date} ${time}`; setSelectedMonth(date.slice(0, 7)); setSelectedDate(date); setSelectedTime(time); setSelectedEndTime(seed.end ? wallClockInZone(new Date(seed.end), practiceZone) : getEndTime(time)); }, [practiceZone, selectedDate, selectedTime]); const selectedPatientLine = selectedPatient ? [formatFhirHumanName(selectedPatient.name, { emptyText: 'Selected Patient' }), patientSearchResultLine(selectedPatient)] .filter(Boolean).join(' · ') : ''; const canShowPatientSearchDropdown = isPatientSearchOpen && patientSearch.trim().length > 0 && !selectedPatient; const hasPatientSearchResults = patientSearchResults.length > 0; // This Schedule's busy Slots and the practitioner's held time anywhere; a visit being moved keeps its own time // while its own host is the practitioner being booked, on whichever of their Schedules it moves to. const busyRanges = useMemo(() => deskBusyRanges({ slots, held: heldTimes, appointment, originalSlotId: getAppointmentSlotId(appointment), practitionerId: practitioner?.id, selectedDate, dayStartMs: selectedDate ? zonedWallClockToInstant(selectedDate, '00:00', gridZone).getTime() : undefined, }), [appointment, gridZone, heldTimes, practitioner?.id, selectedDate, slots]); /** * THE DESK'S CANDIDATE STARTS COME FROM THE BLOCK, THE SAME WAY THE PATIENT'S DO. * * This walked a hand-typed 96-entry ladder of quarter-hours across the whole day and kept whatever * survived the availability check. That was invisible while every grid was a fixed step - but the * increment is now a choice the practice makes on the schedule block, 10, 15, 20 or 30, and the * patient's grid reads it. A fixed ladder here means the two surfaces DISAGREE ABOUT WHICH START * TIMES EXIST: on a block set to 30 the desk would still offer 9:15, book it, and the patient would * never have been able to choose that time for themselves. * * ONE INCREMENT, FROM `selectedSchedule`, because that is the block the appointment is filed against - * the effect above re-points it to whichever block actually covers the chosen date. The patient's * flow derives one per WINDOW instead, because an "any location" date there can span two blocks that * chose differently; here the day already belongs to one. * * THE FILTERS BELOW ARE UNCHANGED. Generating a start says only that the block offers it; whether the * service fits before the window closes and whether the time is already taken are the same two * questions asked the same way. */ const blockStartTimes = useMemo(() => { const increment = getScheduleSlotIncrement(selectedSchedule); const seen = new Set(); const wallClock = (value: string | Date) => wallClockInZone(new Date(value), gridZone); const dateOf = (value: string | Date) => calendarDateInZone(new Date(value), gridZone); for (const event of availabilityEvents) { if (dateOf(event.start) !== selectedDate) continue; // The end goes through windowEndOnDay so an all-day window, which closes at the NEXT // midnight, is not reduced to the same 00:00 it opened at. const endOnDay = windowEndOnDay( selectedDate, dateOf(event.end), wallClock(event.end), ); for (const time of slotStartTimes(wallClock(event.start), endOnDay, increment)) { // Two windows can overlap on one day; the same start offered twice is one choice. seen.add(time); } } return Array.from(seen).sort(); }, [availabilityEvents, gridZone, selectedDate, selectedSchedule]); const availableTimeOptions = useMemo( () => blockStartTimes .filter((time) => { const [h, m] = time.split(':').map(Number); const candStart = h * 60 + m; const candEnd = candStart + selectedDurationMinutes; // Exclude any grid start whose [start, start+duration) overlaps a busy range. return !busyRanges.some((r) => candStart < r.endMin && r.startMin < candEnd); }) .filter((time) => isTimeSlotAvailable(time, selectedDate, availabilityEvents, selectedDurationMinutes, gridZone)) .map((time) => ({ value: time, label: formatTimeForDisplay(time, practiceZoneNotice), end: getEndTime(time, selectedDurationMinutes), })), [availabilityEvents, blockStartTimes, busyRanges, gridZone, selectedDate, selectedDurationMinutes, practiceZoneNotice], ); const resetPreparedTaskState = () => { setTaskAssignmentAction(''); setActivePrepForm(null); setPreparedTasks([]); setBookedAppointment(null); setTaskAssignmentError(undefined); }; const getNextPrepTaskId = (kind: PreparedTask['kind']) => { prepTaskSequenceRef.current += 1; return `${kind}-${Date.now()}-${prepTaskSequenceRef.current}`; }; const handleTaskAssignmentAction = (value: string) => { setTaskAssignmentAction(''); setTaskAssignmentError(undefined); setTaskMutationError(undefined); setEditingExistingTask(undefined); if (value === 'questionnaire') { setActivePrepForm('questionnaire'); } if (value === 'custom') { setActivePrepForm('custom'); } }; const handlePrepareQuestionnaire = (questionnaire: Questionnaire, priority: TaskPriority) => { const alreadyPrepared = preparedTasks.some( item => item.kind === 'questionnaire' && item.questionnaire.id === questionnaire.id, ); if (alreadyPrepared) { toast.info('That questionnaire is already included.', { position: 'top-right', }); return; } setPreparedTasks(current => [ ...current, { id: getNextPrepTaskId('questionnaire'), kind: 'questionnaire', questionnaire, priority, }, ]); }; const handlePrepareCustomTask = (taskInput: CustomTaskAssignmentDraft) => { setPreparedTasks(current => [ ...current, { id: getNextPrepTaskId('custom'), kind: 'custom', taskInput, }, ]); }; const removePreparedTask = (preparedTaskId: string) => { setPreparedTasks(current => current.filter(item => item.id !== preparedTaskId)); setTaskAssignmentError(undefined); }; const replaceExistingTask = (updatedTask: Task) => { setExistingTasks((currentTasks) => currentTasks.map((task) => task.id === updatedTask.id ? updatedTask : task, ), ); }; const handleEditExistingTask = (task: Task) => { if (task.status === 'completed' || !task.id) { return; } if (getAkashicTaskKind(task) === 'legacy') { toast.info('This task can be removed here, but must be edited from the patient Tasks view.', { position: 'top-right', }); return; } setActivePrepForm(null); setTaskAssignmentAction(''); setTaskAssignmentError(undefined); setTaskMutationError(undefined); setEditingExistingTask(task); }; const handleUpdateExistingCustomTask = async ( taskInput: CustomTaskAssignmentDraft, ) => { const task = editingExistingTask; if (!task?.id || getAkashicTaskKind(task) !== 'custom') { throw new Error('The Custom Task could not be loaded for editing.'); } if (task.status === 'completed') { throw new Error('Completed tasks are read-only.'); } // As on create: a next-appointment task stores its mode alone, so the resolver follows // whichever visit is soonest. Pinning it here froze it to one visit. const pinsAnAppointment = taskDueModePinsAnAppointment(taskInput.dueMode); const taskAppointment = !pinsAnAppointment ? undefined : taskInput.appointmentSource === 'current' || (taskInput.appointment?.id && taskInput.appointment.id === appointment?.id) ? appointment : taskInput.appointment; const requiresAppointment = taskInput.dueMode === 'this-appointment' || taskInput.dueMode === 'specific-appointment'; if (requiresAppointment && !taskAppointment?.id) { throw new Error('Select the appointment this task belongs to.'); } const appointmentReference = taskAppointment?.id ? createReferenceObject( 'Appointment', taskAppointment.id, formatCustomTaskAppointmentDisplay(taskAppointment), ) : undefined; const preservedExtensions = (task.extension || []).filter( (extension) => extension.url !== AKASHIC_TASK_DUE_MODE_EXTENSION && extension.url !== AKASHIC_TASK_RECURRENCE_FREQUENCY_EXTENSION && extension.url !== AKASHIC_TASK_APPOINTMENT_EXTENSION, ); const nextExtensions: NonNullable = withDocumentRequestExtension([ ...preservedExtensions, { url: AKASHIC_TASK_DUE_MODE_EXTENSION, valueCode: taskInput.dueMode, }, ...(taskInput.dueMode === 'recurring' && taskInput.recurrenceFrequency ? [ { url: AKASHIC_TASK_RECURRENCE_FREQUENCY_EXTENSION, valueCode: taskInput.recurrenceFrequency, }, ] : []), ...(appointmentReference ? [ { url: AKASHIC_TASK_APPOINTMENT_EXTENSION, valueReference: appointmentReference, }, ] : []), ], taskInput.documentRequest); const nextBasedOn = [ ...(task.basedOn || []).filter( (reference) => !reference.reference?.startsWith('Appointment/'), ), ...(appointmentReference ? [appointmentReference] : []), ]; const resolvedDueDate = taskAppointment?.start || (taskInput.dueMode === 'date' || taskInput.dueMode === 'recurring' ? taskInput.dueDate : undefined); const nextRestriction: NonNullable = { ...(task.restriction || {}), ...(task.restriction?.period ? { period: { ...task.restriction.period } } : {}), }; if (resolvedDueDate) { nextRestriction.period = { ...(nextRestriction.period || {}), end: resolvedDueDate, }; } else if (nextRestriction.period) { const remainingPeriod = { ...nextRestriction.period }; delete remainingPeriod.end; nextRestriction.period = Object.keys(remainingPeriod).length > 0 ? remainingPeriod : undefined; } const hasRestriction = Boolean( nextRestriction.repetitions || nextRestriction.recipient?.length || nextRestriction.period?.start || nextRestriction.period?.end, ); setTaskMutationId(task.id); setTaskMutationError(undefined); try { const updatedTask = await updateTask(applyCustomTaskEdit(task, { title: taskInput.title, instructions: taskInput.instructions, priority: taskInput.priority, extension: nextExtensions, basedOn: nextBasedOn.length > 0 ? nextBasedOn : undefined, restriction: hasRestriction ? nextRestriction : undefined, lastModified: new Date().toISOString(), })); if (appointment?.id && (taskAppointment?.id === appointment.id || !pinsAnAppointment)) { replaceExistingTask(updatedTask); } else { setExistingTasks((currentTasks) => currentTasks.filter((currentTask) => currentTask.id !== updatedTask.id), ); } toast.success('Task updated successfully.', { position: 'top-right', }); onTasksChanged?.(); } catch (error) { const message = `Failed to update task: ${getErrorMessage(error)}`; setTaskMutationError(message); throw new Error(message); } finally { setTaskMutationId(undefined); } }; const handleUpdateExistingQuestionnaireTask = async ( questionnaire: Questionnaire, priority: TaskPriority, ) => { const task = editingExistingTask; if (!task?.id || getAkashicTaskKind(task) !== 'questionnaire') { throw new Error('The Questionnaire task could not be loaded for editing.'); } if (task.status === 'completed') { throw new Error('Completed tasks are read-only.'); } if (!questionnaire.id) { throw new Error('Select a saved questionnaire.'); } const questionnaireTitle = getQuestionnaireLabel(questionnaire); setTaskMutationId(task.id); setTaskMutationError(undefined); try { const updatedTask = await updateTask({ ...task, description: `Complete questionnaire: ${questionnaireTitle}`, priority, focus: createReferenceObject( 'Questionnaire', questionnaire.id, questionnaireTitle, ), lastModified: new Date().toISOString(), }); replaceExistingTask(updatedTask); toast.success('Questionnaire task updated successfully.', { position: 'top-right', }); onTasksChanged?.(); } catch (error) { const message = `Failed to update Questionnaire task: ${getErrorMessage(error)}`; setTaskMutationError(message); throw new Error(message); } finally { setTaskMutationId(undefined); } }; const handleDeleteExistingTask = async () => { const task = taskPendingDeletion; if (!task?.id || task.status === 'completed') { setTaskPendingDeletion(undefined); return; } setTaskMutationId(task.id); setTaskMutationError(undefined); try { await deleteTask(task); setExistingTasks((currentTasks) => currentTasks.filter((currentTask) => currentTask.id !== task.id), ); if (editingExistingTask?.id === task.id) { setEditingExistingTask(undefined); } setTaskPendingDeletion(undefined); toast.success('Task deleted successfully.', { position: 'top-right', }); onTasksChanged?.(); } catch (error) { const message = `Failed to delete task: ${getErrorMessage(error)}`; setTaskMutationError(message); toast.error(message, { position: 'top-right', }); } finally { setTaskMutationId(undefined); } }; // The first patient keeps the visit already filled in; a different patient is a new booking. const handleSelectPatient = (patient: Patient) => { if (selectedPatient && selectedPatient.id !== patient.id) { setSelectedRoleFilter(''); setSelectedLocationFilter(''); handleClearSelectedPractitioner(); } resetPreparedTaskState(); setSeededFrom(undefined); setSelectedPatient(patient); setPatientSearch(formatFhirHumanName(patient.name, { emptyText: 'Selected Patient' })); setIsPatientSearchOpen(false); }; const handleClearSelectedPatient = () => { if (bookedAppointment || isEditMode) { return; } resetPreparedTaskState(); setSeededFrom(undefined); setSelectedPatient(null); setPatientSearch(''); setIsPatientSearchOpen(false); setSelectedRoleFilter(''); setSelectedLocationFilter(''); handleClearSelectedPractitioner(); }; function handleClearSelectedPractitioner() { if (bookedAppointment) { return; } setSelectedSchedule(null); setChosenSiteId(undefined); setSelectedPractitioner(null); setPractitionerRole(null); setPractitioner(undefined); setSelectedAppointmentType(''); setSelectedVisitService(''); setSelectedMonth(getMonthKey(new Date())); setSelectedDate(''); setSelectedTime(''); setSelectedEndTime(''); setAvailabilityEvents([]); } /** * Change WHO, keeping WHY and WHAT - because the service is upstream of the practitioner, not * downstream of them. * * `handleClearSelectedPractitioner` above resets the whole visit, which is right when the PATIENT * changes: a new patient is a new booking. It is wrong for changing the clinician, where the desk * has already answered the two questions that decide who is even offered. Clearing them sends the * booker back to the start of the form for correcting one field. * * IT ALSO FIXED A SECOND-ORDER BUG. `handleVisitServiceChange` sets the new service and then, when * the selected block does not offer it, called the full reset - which cleared the service that had * just been chosen. So picking a service the current practitioner does not provide silently emptied * the field instead of changing who was available. * * Date, time and slots DO go: they belong to the block being abandoned, and a time from somebody * else's calendar is the one thing here that must never survive. */ function handleChangeSelectedPractitioner() { if (bookedAppointment) { return; } setSelectedSchedule(null); setChosenSiteId(undefined); setSelectedPractitioner(null); setPractitionerRole(null); setPractitioner(undefined); setSelectedMonth(getMonthKey(new Date())); setSelectedDate(''); setSelectedTime(''); setSelectedEndTime(''); setAvailabilityEvents([]); } // Changing the service can invalidate an already-chosen practitioner - the service is what decides // which practitioners are bookable, so a schedule that does not offer the new service must not stay // selected underneath it. Mirrors handleRoleFilterChange directly below, which solved the identical // problem for the role filter. Without this, seeding a practitioner (the calendar drag path) and then // choosing a service would leave a selection that `filteredSchedules` no longer contains. /** * THE CANDIDATE SET IS THE DESK'S OWN PICKER LIST, AND THAT IS THE WHOLE OF CONTROL ②. * * The receptionist and a patient do not see the same services - self-booking is a per-service * tri-state and the desk can book things a patient cannot. An assistant fed the wrong list would * turn a suggestion into a privilege escalation wearing a helpful hat. * * `mapSuggestionsToBookableServices` honours ONLY ids present in the options it is handed and * takes the DISPLAY from that option rather than from the model - so passing * `visitServiceOptions`, the list the manual Select renders, makes it structurally impossible * for this to surface something the front desk could not have chosen by hand. It is the same * property that drops a hallucinated or stale id. * * NO ERROR BRANCH, DELIBERATELY. `matchService` fails open to `[]` on anything - an unreachable * model, a refusal, a timeout - and the desk simply picks a service the way it always did. Being * told that an optional convenience failed is worse than not noticing it did. */ const runServiceSuggest = useCallback(async () => { const reason = reasonForVisit.trim(); if (!reason) return; setSuggestLoading(true); setSuggestTried(true); const api = await matchService(reason); setServiceSuggestions(mapSuggestionsToBookableServices( api.suggestions, visitServiceOptions.map((service) => ({ id: service.id as string, display: service.name ?? (service.id as string), })), { minConfidence: SUGGESTION_CONFIDENCE_FLOOR, maxSuggestions: 4 }, )); setSuggestLoading(false); }, [reasonForVisit, matchService, visitServiceOptions]); /** * The practitioners this desk could book for the chosen service, ORG-WIDE. * * DELIBERATELY NOT `filteredSchedules`, and this is the one judgement in this half. Location and * role are the receptionist's own NARROWING, not a permission - they can be cleared with one click. * `bookableSchedules` is the desk's authority on who may be booked at all (it already carries the * clinician-seat gate), so ranking over it means the assist can never surface somebody the front * desk could not have chosen by hand, which is control 2. Every id the model returns is then * grounded back against this same list by `mapPractitionerRankings`, so a hallucinated or stale one * is dropped rather than rendered. * * Location-invariant on purpose, mirroring the patient flow: a location toggle must not re-rank. * It reframes the DISPLAY site (`preferLocationId`) and flags a suggestion as cross-location, and * that is all. */ const bookablePractitionersForRank = useMemo(() => { if (!selectedVisitService) return []; const locationNameById = new Map(); for (const loc of locationsList) { if (loc.id) locationNameById.set(loc.id, loc.name || loc.id); } const candidates: BookablePractitionerCandidate[] = bookableSchedules .filter((schedule) => (schedule.actor ?? []).some( (ref) => ref.reference === `HealthcareService/${selectedVisitService}`, ), ) .map((schedule) => { const role = getScheduleRole(schedule); const practitionerActorRef = schedule.actor?.find( (ref) => extractReferenceType(ref) === 'Practitioner', ); return { practitionerRoleId: role?.id, // The same source the manual practitioner Select renders. display: practitionerActorRef?.display || 'Unknown Practitioner', specialty: role?.specialty, locationRefs: getScheduleLocationRefs(schedule, role), }; }); return buildBookablePractitioners(candidates, { locationNameById, preferLocationId: selectedLocationFilter || undefined, }); }, [ selectedVisitService, selectedLocationFilter, bookableSchedules, getScheduleRole, getScheduleLocationRefs, extractReferenceType, locationsList, ]); /** * ASKED FOR, NEVER AUTOMATIC - and that is the deliberate difference from the patient flow. * * The patient's version re-fetches whenever the typed reason changes, because a patient who does * not know the practice needs the help volunteered. A receptionist types fast with a caller on the * line, so the same wiring would put a Bedrock round trip behind every keystroke. This is one call * per press, and it matches the service half in the same dialog rather than introducing a second * idiom two fields apart. * * `qualifiesForRecommendation` still guards it: with fewer than two candidates there is nothing to * disambiguate, and with one specialty and no name in the reason there is no question to ask. * * NO ERROR BRANCH, same as the service half. `matchPractitioner` fails open to `[]`, so an * unreachable model and a genuine no-match arrive identically and neither is actionable at a desk. */ const runPractitionerSuggest = useCallback(async () => { const reason = reasonForVisit.trim(); if (!reason || !selectedVisitService) return; setPractitionerSuggestLoading(true); setPractitionerSuggestTried(true); if (!qualifiesForRecommendation(bookablePractitionersForRank, reason)) { setPractitionerRankings([]); setPractitionerSuggestLoading(false); return; } const rankings = await matchPractitioner(reason, selectedVisitService); setPractitionerRankings(rankings); setPractitionerSuggestLoading(false); }, [reasonForVisit, selectedVisitService, bookablePractitionersForRank, matchPractitioner]); // Pure and location-dependent, so switching the location filter re-marks what is cross-location // WITHOUT re-ranking. Display, specialty and site always come from the bookable list, never the API. const practitionerRecommendations = useMemo( () => mapPractitionerRankings(practitionerRankings, bookablePractitionersForRank, { selectedLocationId: selectedLocationFilter || undefined, }), [practitionerRankings, bookablePractitionersForRank, selectedLocationFilter], ); const handleVisitServiceChange = (nextVisitService: string) => { setServiceTouched(true); setSelectedVisitService(nextVisitService); // A new service invalidates the ranking - it was computed for the old one, and leaving it on // screen would offer practitioners against a service they may not provide. setPractitionerRankings([]); setPractitionerSuggestTried(false); if (nextVisitService && selectedSchedule) { const offersService = (selectedSchedule.actor ?? []).some( (ref) => ref.reference === `HealthcareService/${nextVisitService}`, ); if (!offersService) { handleChangeSelectedPractitioner(); } } }; const handleRoleFilterChange = (roleCode: string) => { setSelectedRoleFilter(roleCode); // The location is NOT cleared here - see the effect above locationFilterOptions. Clearing it // unconditionally silently turned a video booking into an in-person one. if (roleCode && selectedSchedule) { const role = getScheduleRole(selectedSchedule); if (!role || !getPractitionerRoleOrganizationCodings(role).some((c) => c.code === roleCode)) { handleClearSelectedPractitioner(); } } }; const handleLocationFilterChange = (locationId: string) => { setSelectedLocationFilter(locationId); if (locationId && selectedSchedule) { const role = getScheduleRole(selectedSchedule); // "All locations" (no Location on the schedule) practices everywhere, so it stays selected for // any chosen site; only clear when the schedule has an explicit site that doesn't match. const roleLocations = getScheduleLocationRefs(selectedSchedule, role); const matchesLocation = Boolean(role) && (roleLocations.length === 0 || roleLocations.some((loc) => getReferenceIdFromString(loc.reference) === locationId)); if (!matchesLocation) { handleClearSelectedPractitioner(); } } }; const handleScheduleChange = (scheduleId: string) => { const schedule = schedulesList.find((item) => item.id === scheduleId); if (!schedule) { toast.error('Schedule not found.', { position: 'top-right' }); return; } const practitionerRoleRef: Reference | undefined = schedule.actor?.find( (reference) => extractReferenceType(reference) === 'PractitionerRole', ); const practitionerRef: Reference | undefined = schedule.actor?.find( (reference) => extractReferenceType(reference) === 'Practitioner', ); const extractedPractitionerRoleId = practitionerRoleRef ? extractReferenceId(practitionerRoleRef) : undefined; const practitionerRoleId = Array.isArray(extractedPractitionerRoleId) ? extractedPractitionerRoleId[0] : extractedPractitionerRoleId; const practitionerId = getReferenceIdFromString(practitionerRef?.reference); const practitionerWithRoles = practitionersList.find((item) => practitionerRoleId ? item.roles.some((role) => role.id === practitionerRoleId) : item.practitioner.id === practitionerId, ); if (!practitionerWithRoles) { toast.error('Practitioner not found for this schedule.', { position: 'top-right' }); return; } const nextPractitionerRole = practitionerWithRoles.roles.find( (role) => role.id === practitionerRoleId, ) || practitionerWithRoles.roles[0]; if (!nextPractitionerRole) { toast.error('Practitioner role not found.', { position: 'top-right' }); return; } const nextPractitioner = practitionerWithRoles.practitioner; const practitionerName = formatFhirHumanName(nextPractitioner.name, { emptyText: practitionerRoleRef?.display || practitionerRef?.display || 'Unknown Practitioner', }); setSelectedSchedule(schedule); setChosenSiteId( firstPhysicalSite(getScheduleLocationRefs(schedule, nextPractitionerRole), locationsList)?.id, ); setPractitionerRole(nextPractitionerRole); setPractitioner(nextPractitioner); setSelectedPractitioner({ name: practitionerName, role: practitionerRoleRef?.display || schedule.specialty?.[0]?.text || 'Practitioner', }); setSelectedDate(''); setSelectedTime(''); setSelectedEndTime(''); }; /** * Taking a suggestion routes through the SAME handlers a manual pick uses, never a raw setter, so * nothing downstream can tell which way the practitioner was chosen - the same property the service * half has. * * The cross-location branch is why this is not a one-liner. The candidate set is org-wide, so a * suggestion can name somebody the current location filter excludes; selecting them without moving * the filter would leave the dialog showing a practitioner its own picker no longer lists. Moving * the filter first goes through `handleLocationFilterChange`, which reconciles the rest. */ const handlePractitionerRecommendationSelect = (rec: PractitionerRecommendation) => { if (rec.crossLocation && rec.locationId) { handleLocationFilterChange(rec.locationId); } // Prefer a block at the suggested site; fall back to any bookable block for that role, because an // org-wide role carries no site of its own and must still be selectable. const scheduleForRec = bookableSchedules.find( (schedule) => getScheduleRole(schedule)?.id === rec.practitionerRoleId && (schedule.actor ?? []).some( (ref) => ref.reference === `HealthcareService/${selectedVisitService}`, ) && (!rec.locationId || getScheduleLocationRefs(schedule, getScheduleRole(schedule)).some( (loc) => getReferenceIdFromString(loc.reference) === rec.locationId, )), ) ?? bookableSchedules.find( (schedule) => getScheduleRole(schedule)?.id === rec.practitionerRoleId, ); if (scheduleForRec?.id) { handleScheduleChange(scheduleForRec.id); } }; /** * THE PRACTITIONER HALF of Spencer's ruling - *"it should default to that practitioner"*. * * IT HAS TO WAIT FOR THE SERVICE, and that is why this is not one seed with the patient. The * Practitioner control is `value={selectedSchedule?.id}` and is DISABLED until `serviceGateSatisfied` * - the funnel is service, then location, then who. Seeding `practitionerRole` alone renders * nothing at all; a first version of this did exactly that and would have shipped as a no-op. * So it seeds the SCHEDULE, through the same handler the picker uses, at the moment the options * first exist. * * What it fixes: the list is every practitioner in the practice - one entry per role and location, * with nothing marking which is you - while the page behind the dialog is already filtered to your * own calendar. A clinician had to find themselves among colleagues, and the likeliest slip was * booking onto somebody else's day while the page above still said their name. * * SEEDS, NEVER OVERRIDES. Once only, and only while the field is empty, so choosing a colleague * deliberately sticks. * If the chosen service is not one this clinician offers, they are simply not in * `filteredSchedules` and nothing is seeded. That is correct: the service decides who can be booked. * * "A RECEPTIONIST OR ADMIN MATCHES NOBODY" WAS AN ASSUMPTION, AND IT IS FALSE. That sentence used * to stand where the workspace check below is, and it was the whole guard. Receptionists and admins * DO have Practitioner records and DO have schedules - the Schedules page says so in its own subtitle, * "operational coverage for practitioners, receptionists, and administrators" - so a receptionist * booking a Consultation matched themselves and the dialog filled in "Spencer Fraise - Receptionist" * as the clinician. Observed in a browser 2026-08-27. * * It seeded the wrong person AND hid the location control, because the Location/Role row renders only * while `!selectedPractitioner`. On a block offered by video the virtual Location was sitting in that * list, correct and unreachable - so no video visit could be booked from the front desk at all. * Two defects, one line. * * The guard is now what the paragraph above always meant: seed only for somebody ACTING AS a * clinician. `activeWorkspace ?? userRole` is the established way to ask that - the same expression * `DashboardPage` calls `activeUserRole` - so a clinician who is also an admin is not seeded while * they are wearing the admin hat, which is the case a Cognito-group check would get wrong. */ const seededOwnPractitionerRef = useRef(false); useEffect(() => { if (isEditMode || seededOwnPractitionerRef.current) return; if ((activeWorkspace ?? userRole) !== USER_ROLES.PRACTITIONER) return; if (!serviceGateSatisfied || selectedSchedule) return; if (filteredSchedules.length === 0 || !currentUser?.fhir?.id) return; const mine = filteredSchedules.find((schedule) => { const actor = schedule.actor?.find( (reference) => extractReferenceType(reference) === 'Practitioner', ); return getReferenceIdFromString(actor?.reference) === currentUser.fhir?.id; }); seededOwnPractitionerRef.current = true; if (mine?.id) handleScheduleChange(mine.id); }, [serviceGateSatisfied, filteredSchedules, selectedSchedule, currentUser, isEditMode, activeWorkspace, userRole, USER_ROLES]); // Said beside the label whenever the chosen practitioner is the one booking, however it was chosen // (deck practitioner#s-appointments "Booking": "said out loud rather than silently prefilled"). const practitionerIsYou = Boolean(currentUser?.fhir?.id) && getReferenceIdFromString( selectedSchedule?.actor?.find((reference) => extractReferenceType(reference) === 'Practitioner')?.reference, ) === currentUser?.fhir?.id; const handleTimeChange = (time: string) => { setSelectedTime(time); // selectedEndTime is derived from the start + duration by the sync effect above. }; const createAppointmentPayload = (): Appointment | undefined => { if (!selectedPatient || !practitioner || !selectedAppointmentType || !selectedDate || !selectedTime || !selectedEndTime || !reasonForVisit.trim()) { return undefined; } // No clock, no instant. This is called from a render and from a preview as well as from the // write, so it answers `undefined` and the submit path states the refusal. if (!practiceZone) { return undefined; } const selectedType = appointmentTypeOptions.find((type) => type.code === selectedAppointmentType); const typeDisplay = selectedType?.display || selectedAppointmentType; const start = zonedWallClockToInstant(selectedDate, selectedTime, practiceZone).toISOString(); const end = zonedWallClockToInstant(intervalEndDate(selectedDate, selectedTime, selectedEndTime), selectedEndTime, practiceZone).toISOString(); const durationMinutes = Math.max( 1, Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000), ); const appointmentType: CodeableConcept = { coding: [ { system: 'http://terminology.hl7.org/CodeSystem/v2-0276', code: selectedAppointmentType, display: typeDisplay, }, ], text: typeDisplay, }; // A changed Where replaces the visit's Location; kept beside it, readers would still see the old one. const replacesLocation = isEditMode && whereTouched; const preservedParticipants = appointment?.participant?.filter((participant) => { const reference = participant.actor?.reference; return !(replacesLocation && reference?.startsWith('Location/')) && !reference?.startsWith('Patient/') && !reference?.startsWith('Practitioner/') && !reference?.startsWith('PractitionerRole/'); }) || []; const participants: AppointmentParticipant[] = [ ...preservedParticipants, { actor: createReferenceObject( 'Practitioner', practitioner.id as string, formatFhirHumanName(practitioner.name), ), status: 'accepted', required: 'required', }, { actor: createReferenceObject( 'Patient', selectedPatient.id as string, formatFhirHumanName(selectedPatient.name), ), status: 'accepted', required: 'required', }, ]; // Record the organization-scoped PractitionerRole alongside the Practitioner so the booking is // queryable via actor=PractitionerRole/... - the server's established-patient detection // (require-new-patient-exam-first) relies on it; a bare Practitioner participant is invisible to it. if (practitionerRole?.id) { participants.push({ actor: createReferenceObject( 'PractitionerRole', practitionerRole.id, getPractitionerRolePrimaryDisplay(practitionerRole), ), status: 'accepted', required: 'required', }); } // Record the Location (the block the slot came from, else the receptionist's chosen site, else // the role's primary location) so a new booking is queryable by site - and, because telehealth IS // a Location here, so that it records the right MODALITY. On edit we intentionally preserve the // appointment's existing Location participant (via preservedParticipants above) - // selectedLocationFilter is not re-derived on edit, so rebuilding here would overwrite it. // // A PLACEMENT HAS NOTHING TO PRESERVE. A time-window request carries no Location, because the // patient named no practitioner and therefore no site; the desk chooses both here. So placing // takes the new-booking path and derives the Location from the block it just picked - without // this the placed appointment would be the only booked visit in the practice with no site on it, // and telehealth is a Location, so it would also lose its modality. if (!isEditMode || isPlacement || whereTouched) { // For an "All locations" role (no explicit refs on the role) the chosen site comes from the // org location list instead, so the booking still records a queryable Location participant. const chosenOrgLocation = selectedLocationFilter ? locationsList.find((loc) => loc.id === selectedLocationFilter) : undefined; // - THE BLOCK THE SLOT CAME FROM DECIDES, and it must be consulted FIRST. // Until this line existed the chain below never looked at the Schedule at all, so booking // through the "Dr. X - Video Visit" practitioner option (a block whose only Location actor is // the org's virtual one) set no location filter, fell all the way through to // `practitionerRole.location[0]`, and recorded the org's primary PHYSICAL site. Telehealth IS // a Location here, so that booking was not mislabelled - it WAS an in-person visit // to everything downstream, which is why no "video" appeared on the clinician queue card. // The comment below used to claim this chain "matches the patient booking path". It did // not: `PatientBookAppointment` has led with the block's own ref since // (`block?.locationRef ??`). This is that missing candidate, and the claim is now true. // The rule is shared rather than copied a third time - see bookingAppointmentLocation.ts // for why actor order is the tie-break and why an explicit choice only wins against THIS // block's actors. // WHY THIS CANNOT CHANGE AN ALREADY-CORRECT BOOKING, traced rather than assumed. For a // legacy block with no Location actor `getScheduleLocationRefs` falls back to the role's own // locations, so this resolves what the old chain resolved (plus a display backfill). The one // shape it would decide differently - a role WITH explicit sites while the filter names a site // that is not one of them - cannot be reached: `filteredSchedules` already drops such a // schedule from the picker, so no slot of it can be selected. And an "All locations" role has // no refs at all, so this yields undefined and the org-location branch below still decides. const blockLocation = withLocationDisplay( resolveBookingLocationRef( selectedSchedule ? getScheduleLocationRefs(selectedSchedule, practitionerRole ?? undefined) : [], selectedLocationFilter || undefined, ), locationsList, ); const appointmentLocation = blockLocation ?? (selectedLocationFilter ? practitionerRole?.location?.find( (location) => getReferenceIdFromString(location.reference) === selectedLocationFilter, ) : undefined) ?? (chosenOrgLocation?.id ? { reference: `Location/${chosenOrgLocation.id}`, display: chosenOrgLocation.name || chosenOrgLocation.id, } : undefined) ?? practitionerRole?.location?.[0]; if (appointmentLocation?.reference) { participants.push({ actor: appointmentLocation, status: 'accepted', }); } } const baseAppointmentPayload: Appointment = { ...(appointment || {}), resourceType: 'Appointment', status: appointment?.status || 'booked', appointmentType, // Visit service ("what is performed"): the chosen HealthcareService's type + category, or on // an edit where nobody changed it, the visit's own - even one the catalogue cannot match. ...serviceFieldsToSave({ selectedService, appointment, isEditMode, serviceTouched }), start, end, ...(!isEditMode && { created: new Date().toISOString() }), participant: participants, minutesDuration: durationMinutes, }; // A new booking or a changed service names the catalogue service chosen; an unchanged edit keeps what the visit has. const servicePayload = selectedService?.id && (!isEditMode || serviceTouched) ? withSelectedServiceRef(baseAppointmentPayload, selectedService.id) : baseAppointmentPayload; const appointmentPayload: Appointment = isEditMode ? applyReceptionistAppointmentStatus(servicePayload, selectedStatus) : { ...servicePayload, status: 'booked' }; const trimmedDescription = description.trim(); if (trimmedDescription) { appointmentPayload.description = trimmedDescription; } else { delete appointmentPayload.description; } const trimmedReason = reasonForVisit.trim(); if (trimmedReason) { // Preserve any existing coded reason (e.g. from the patient's original request) and // set/refresh the free-text. The patient flow supplies the coded part via service cards. const existingCoding = appointmentPayload.reasonCode?.[0]?.coding; appointmentPayload.reasonCode = [ { ...(existingCoding ? { coding: existingCoding } : {}), text: trimmedReason, }, ]; } else { delete appointmentPayload.reasonCode; } return appointmentPayload; }; // `operation`: the save these tasks follow. Each task is admitted before it is sent, so a task after the editor // closed or the person changed is never sent; one already sent still answers into the result. const assignPreparedTasks = async ( appointment: Appointment, taskItems: PreparedTask[], operation?: SaveOperation, ): Promise => { if (!selectedPatient) { return taskItems; } const failedItems: PreparedTask[] = []; for (const [index, taskItem] of taskItems.entries()) { if (operation && !operation.belongs()) { // Not sent: still to do, for whoever opens this visit next. failedItems.push(...taskItems.slice(index)); break; } try { if (taskItem.kind === 'questionnaire') { await createQuestionnaireTask({ patient: selectedPatient, questionnaire: taskItem.questionnaire, appointment, priority: taskItem.priority, transport: operation?.transport, }); } else { const customTaskInput = taskItem.taskInput; const taskAppointment = customTaskInput.appointmentSource === 'current' || (customTaskInput.appointment?.id && customTaskInput.appointment.id === appointment.id) ? appointment : customTaskInput.appointment; await createCustomTask({ patient: selectedPatient, title: customTaskInput.title, instructions: customTaskInput.instructions, dueMode: customTaskInput.dueMode, dueDate: customTaskInput.dueDate, recurrenceFrequency: customTaskInput.recurrenceFrequency, appointment: taskAppointment, documentRequest: customTaskInput.documentRequest, priority: customTaskInput.priority, transport: operation?.transport, }); } } catch (taskError) { console.error(`Failed to assign ${taskItem.kind} task after booking:`, taskError); failedItems.push(taskItem); } } return failedItems; }; const handleBookAppointment = async () => { if (bookedAppointment) { return; } if (activePrepForm) { toast.error('Add or cancel the open task before booking the appointment.', { position: 'top-right', }); return; } if (!selectedPatient || !selectedSchedule || !practitioner || !selectedDate || !selectedTime || !selectedEndTime) { toast.error('Missing required appointment information.', { position: 'top-right', }); return; } // A service the practice switched off is booked only as an edited visit's own; the server refuses it too. const inactiveRefusal = inactiveSelectionRefusal({ selectedService, currentServiceId: currentVisitServiceId, isEditMode }); if (inactiveRefusal) { toast.error(inactiveRefusal, { position: 'top-right' }); return; } // - THE GATE. Its POSITION is the answer to "what is released": nothing is taken. // // It refuses above the slot creation below, so a refused booking never holds a slot at all. // Gating after the slot exists would leave one held for a patient who then declined, and lean on // the orphan sweep to free it - correct eventually, and wrong at the desk while it lasts. // // THIS IS A UI GATE AND IT IS NOT THE WHOLE ENFORCEMENT. Stated plainly rather than implied: the // server RECORDS the authorization (attest is a real, audited write) but does not today refuse an // Appointment for an unauthorized organization. Making it do so is a server ruling with its own // consequences for the book-now-agree-at-the-visit flow, and it is not this card. if (!authorizationSatisfied) { toast.error( subjectPending ? 'This patient’s agreements are still being checked, so the appointment was not booked.' : subjectAuthorization === 'unknown' ? 'We could not check this patient’s authorization, so the appointment was not booked.' /* The old sentence here - "has not authorized... Record it below" - named a control this dialog no longer has. TWO non-unknown states reach this branch: a document nobody has answered yet, and a withdrawal, which is the patient's own decision and cannot be re-captured by the desk. A decline reaches it too, because a patient who refused care cannot be booked - the same decline cancels every appointment with this practice, so a booking would create a row whose only future is to be cancelled. This comment claimed the withdrawal was the only one, which was true for a day before the predicate above changed and the prose did not. It says "an agreement" rather than "this practice's access" because the gate reads all three now: withdrawing Consent to Treat reaches this branch too, and naming access would describe the wrong document. The panel above names which one. */ /* IT NAMES THE CAPTURE PATH, AND THAT IS THE RULING RATHER THAN A KINDNESS. Blocking is only safe because the desk can record an answer for somebody who cannot or will not use the portal - so a refusal that says only "no" has lost half the design and leaves the front desk with nothing to do. */ : 'This patient declined or withdrew one of this practice’s agreements, so the appointment was not booked. They can change their answer in their portal, or have it recorded at the desk.', { position: 'top-right' }, ); return; } // WITHOUT THE PRACTICE'S CLOCK THERE IS NO SUCH THING AS "2:00 PM", so this refuses rather // than guessing - the quiet-hours precedent, and the opposite of what this dialog used to do, // which was to silently use the browser's clock and store the wrong moment. It names what is // missing and where to set it, per the one denial rule. if (!practiceZone) { toast.error(NO_LOCATION_CLOCK, { position: 'top-right' }); return; } // ASKED BEFORE ANYTHING IS TAKEN, for the same reason the authorization gate sits where it does: // no slot is held for a booking the desk is about to reconsider. // // THE SELECTION IS READ OFF THE PAYLOAD THAT IS ABOUT TO BE POSTED, never off the form a second // time. A parallel reading could disagree with what actually gets booked, and then the check // would be checking something nobody is doing. const intentPreview = describeSelectionForIntent(createAppointmentPayload(), locationsList); // The check is the first wait. Held before it and checked after it, so a booking read off this form never // starts under whichever patient or viewer replaced it; the booking keeps its own deadline below. const asked = editScope.start(); try { if (intentPreview) { const key = JSON.stringify(intentPreview); if (key !== acknowledgedSelection) { setSubmitting(true); setSubmissionLabel('Checking...'); const conflict = await checkIntent(reasonForVisit, intentPreview); if (!asked.belongs()) return; setSubmitting(false); // `undefined` is silence and is the commonest correct answer - it is also what every // failure returns, on both sides, so an outage books exactly as it does today. if (conflict) { setIntentConflict(conflict); setAcknowledgedSelection(key); return; } setAcknowledgedSelection(key); } } } finally { asked.retire(); } setIntentConflict(undefined); setSubmitting(true); // Cleared as the attempt STARTS, so a retry that works takes the refusal off the screen and // a reader is never looking at the answer to a question they have already asked again. setBookingRefusal(null); setSubmissionLabel('Booking...'); setTaskAssignmentError(undefined); // Started before the first wait, so a booking whose asker was replaced meanwhile finishes under nobody. const operation = editScope.start(); let begun: BookingOperationEntry | undefined; try { // Kept before the Slot or the booking is sent; an earlier booking for this patient still unknown is checked instead. const bookingOperation = await deskBooking.begin(); begun = bookingOperation; const slotData = { status: 'busy' as const, start: zonedWallClockToInstant(selectedDate, selectedTime, practiceZone).toISOString(), end: zonedWallClockToInstant(intervalEndDate(selectedDate, selectedTime, selectedEndTime), selectedEndTime, practiceZone).toISOString(), comment: 'Slot created for receptionist-booked appointment', overbooked: false, }; const booking = await bookNewAppointment({ operation, hold: () => createSlot(transformToFhirSlot(slotData, selectedSchedule)), create: async (newSlot) => { try { const appointmentPayload = createAppointmentPayload(); if (!appointmentPayload) { throw new Error('Missing appointment payload data.'); } // No slot rollback here: Appointment/post.ts frees the orphaned slot itself // (releaseOrphanedBookingSlots). const sent = { ...appointmentPayload, slot: [{ reference: `Slot/${newSlot.id}` }] }; const result = await deskBooking.send(bookingOperation, (operationId) => createAppointment(sent, undefined, undefined, undefined, operationId)); if (result.kind === 'retired') throw new BookingRetiredError(); return result.appointment; } catch (appointmentError: unknown) { console.error('Appointment creation error:', appointmentError); throw appointmentError; } }, publish: (createdAppointment) => { setBookedAppointment(createdAppointment); onAppointmentCreated?.(createdAppointment); if (preparedTasks.length > 0) setSubmissionLabel('Assigning tasks...'); }, tasks: preparedTasks, assign: assignPreparedTasks, }); // The booking and any task sent stand; nothing more is shown for a dialog or person no longer on screen. if (booking.kind === 'retired') return; if (preparedTasks.length > 0) { const failedTaskItems = booking.failedTasks; if (failedTaskItems.length > 0) { const failureMessage = `The appointment was booked, but ${failedTaskItems.length} ${failedTaskItems.length === 1 ? 'task was' : 'tasks were'} not assigned.`; setPreparedTasks(failedTaskItems); setTaskAssignmentError( `${failureMessage} Retry the remaining task assignments or close and assign them from the patient's Tasks tab.`, ); toast.error(failureMessage, { position: 'top-right', }); return; } setPreparedTasks([]); } const successMessage = preparedTasks.length > 0 ? 'Appointment created and tasks assigned successfully.' : 'Appointment created successfully.'; toast.success(successMessage, { position: 'top-right', }); onSuccess?.(successMessage); onBooked?.(); } catch (error: unknown) { if (isSaveRetired(error) || !operation.belongs()) return; // Not known to be made or refused: the check below stands in for the form until it is. if (isBookingUnconfirmed(error) || isBookingRetired(error)) return; if (error instanceof BookingStorageUnavailableError) { setBookingRefusal(error.message); return; } console.error('Failed to create appointment:', error); // ON THE SCREEN FIRST. See `bookingRefusal`: the toast below has never arrived on this // surface, and this is the half the reader actually sees. setBookingRefusal(getErrorMessage(error)); toast.error(`Failed to create appointment: ${getErrorMessage(error)}`, { position: 'top-right', }); } finally { await deskBooking.abandonIfUnsent(begun); if (operation.belongs()) { setSubmitting(false); setSubmissionLabel('Booking...'); } } }; const handleUpdateExistingAppointment = async () => { if (!appointment || bookedAppointment) { return; } if (activePrepForm || editingExistingTask) { toast.error('Save or cancel the open task editor before updating the appointment.', { position: 'top-right', }); return; } if (taskMutationId) { toast.error('Wait for the task update to finish before saving the appointment.', { position: 'top-right', }); return; } if (!selectedPatient || !selectedSchedule || !practitioner || !selectedDate || !selectedTime || !selectedEndTime) { toast.error('Missing required appointment information.', { position: 'top-right', }); return; } const appointmentPayload = createAppointmentPayload(); if (!appointmentPayload?.id) { toast.error('The appointment could not be prepared for updating.', { position: 'top-right', }); return; } setSubmitting(true); setSubmissionLabel('Updating...'); setFailedVisitRecordLine(undefined); setTaskAssignmentError(undefined); setBookingRefusal(null); const operation = editScope.start(); const basis = editBasis.current() ?? appointment; try { const desiredStart = appointmentPayload.start as string; const desiredEnd = appointmentPayload.end as string; const timingChanged = new Date(basis.start || '').getTime() !== new Date(desiredStart).getTime() || new Date(basis.end || '').getTime() !== new Date(desiredEnd).getTime(); const scheduleChanged = selectedSchedule.id !== initialScheduleIdRef.current; const { saved: updatedAppointment, followUps } = await rescheduleAppointment({ basis, payload: appointmentPayload, newTime: timingChanged || scheduleChanged ? transformToFhirSlot( { status: 'busy' as const, start: desiredStart, end: desiredEnd, comment: 'Slot updated for receptionist-edited appointment', overbooked: false, }, selectedSchedule, ) : undefined, operation, recovery: () => editScope.start(), deps: { createHold: (slot, transport) => createSlot(slot, undefined, transport), releaseBookingHold, updateAppointment, readStored: (id, transport) => fetchAppointment(id, true, transport), }, isDefiniteRefusal: (error) => error instanceof HttpError && error.status < 500, }); if (!operation.belongs()) return; editBasis.open(updatedAppointment); setBookedAppointment(updatedAppointment); onAppointmentUpdated?.(updatedAppointment); // What the save left unfinished is shown beside it at once, so a later task failure never hides it. if (followUps.length > 0) setSavedWithFollowUps(appointmentFollowUpLines(followUps)); if (preparedTasks.length > 0) { setSubmissionLabel('Assigning tasks...'); const failedTaskItems = await assignPreparedTasks( updatedAppointment, preparedTasks, operation, ); // Nothing more is shown or closed for an editor no longer on screen; the save and any task sent stand. if (!operation.belongs()) return; if (failedTaskItems.length > 0) { const failureMessage = `The appointment was updated, but ${failedTaskItems.length} ${failedTaskItems.length === 1 ? 'task was' : 'tasks were'} not assigned.`; setPreparedTasks(failedTaskItems); setTaskAssignmentError( `${failureMessage} Retry the remaining task assignments or close and assign them from the patient's Tasks tab.`, ); toast.error(failureMessage, { position: 'top-right', }); return; } setPreparedTasks([]); } // What the save left unfinished stays in the editor, beside the saved appointment, until it is closed. if (followUps.length > 0) return; const successMessage = preparedTasks.length > 0 ? 'Appointment updated and tasks assigned successfully.' : 'Appointment updated successfully.'; toast.success(successMessage, { position: 'top-right', }); onSuccess?.(successMessage); onUpdated?.(); } catch (error: unknown) { // Nothing is shown for an edit no longer on screen. if (isSaveRetired(error) || !operation.belongs()) return; console.error('Failed to update appointment:', error); setFailedVisitRecordLine(visitEncounterLineOf(error)); // Refused or unconfirmed: said once, and nothing is sent again from this opening until it is reloaded. if (isRecordChanged(error)) { setStaleOpening(error.outOfDate ? 'outOfDate' : 'changed'); return; } if (error instanceof RescheduleUnconfirmed) { setStaleOpening('unconfirmed'); return; } const refusal = treatmentAgreementRefusal(error); if (refusal) { setBookingRefusal(refusal); void recheckCareAgreements(); } toast.error(refusal ?? `Failed to update appointment: ${getErrorMessage(error)}`, { position: 'top-right', }); } finally { // Another opening's busy state is its own. if (operation.belongs()) { setSubmitting(false); setSubmissionLabel('Updating...'); } } }; const handleRetryTaskAssignments = async () => { if (!bookedAppointment || preparedTasks.length === 0) { return; } setSubmitting(true); setSubmissionLabel('Assigning tasks...'); setTaskAssignmentError(undefined); const operation = editScope.start(); try { const failedTaskItems = await assignPreparedTasks( bookedAppointment, preparedTasks, operation, ); if (!operation.belongs()) return; if (failedTaskItems.length > 0) { const failureMessage = `${failedTaskItems.length} ${failedTaskItems.length === 1 ? 'task still needs' : 'tasks still need'} to be assigned.`; setPreparedTasks(failedTaskItems); setTaskAssignmentError( `${failureMessage} You can retry again or close and assign from the patient's Tasks tab.`, ); toast.error(failureMessage, { position: 'top-right', }); return; } setPreparedTasks([]); const successMessage = isEditMode ? 'Appointment updated and tasks assigned successfully.' : 'Appointment created and tasks assigned successfully.'; toast.success(successMessage, { position: 'top-right', }); onSuccess?.(successMessage); if (isEditMode) { onUpdated?.(); } else { onBooked?.(); } } finally { if (operation.belongs()) { setSubmitting(false); setSubmissionLabel(isEditMode ? 'Updating...' : 'Booking...'); } } }; if (!isEditMode && deskBooking.recovery) { return (

Book Appointment

onBooked?.()} />
); } return (

{isPlacement ? 'Place Appointment' : isEditMode ? 'Edit Appointment' : 'Book Appointment'}

{/* Card 705 — see the bodyScrolls note above. `undefined` rather than `-1` when it does not scroll: -1 would still change what `focus()` can target, and the point is that a dialog which fits on screen behaves exactly as it did before. */}
{initializingEdit && (
Loading appointment details…
)} {editInitializationError && ( {editInitializationError} )} {!initializingEdit && !isEditMode && (
Patient
{selectedPatient && seededFrom ? ( ) : ( <>
{/* The chosen patient stays in the field; typing over them searches again. */} { window.setTimeout(() => setIsPatientSearchOpen(false), 150); }} onChange={(event) => { const typed = event.target.value; if (selectedPatient) handleClearSelectedPatient(); setPatientSearch(typed); setIsPatientSearchOpen(true); }} onFocus={(event) => { if (selectedPatient) { event.currentTarget.select(); return; } if (patientSearch.trim()) { setIsPatientSearchOpen(true); } }} /> {canShowPatientSearchDropdown && (
{patientSearchPending ? (
Searching patients...
) : patientError ? (
{patientError}
) : hasPatientSearchResults ? ( patientSearchResults.map((patient) => ( )) ) : excludedInactiveCount > 0 ? ( // The search matched and hid inactive records; only a `replaced-by` link is called a merge.
{excludedMergedCount > 0 ? (excludedMergedCount === 1 ? 'A matching patient record was found, but it was merged into another account. Search for that account instead.' : `${excludedMergedCount} matching patient records were found, but they were merged into other accounts. Search for those accounts instead.`) : (excludedInactiveCount === 1 ? 'A matching patient record was found, but it is inactive and cannot be booked. Ask an administrator to reactivate it.' : `${excludedInactiveCount} matching patient records were found, but they are inactive and cannot be booked. Ask an administrator to reactivate them.`)}
) : (
No matching patients found.
)}
)}
)}
)} {/* Gate before what it gates, and only a real refusal or a heads-up: never on every booking. */} {!isEditMode && selectedPatient?.id && subjectPending && (

Checking this patient’s agreements…

)} {!isEditMode && selectedPatient?.id && bookingNotice && (

{bookingNotice.title}

{bookingNotice.lines.length > 0 && (
    {bookingNotice.lines.map((line) => (
  • {line.heading}
  • ))}
)}

{bookingNotice.consequence}

{subjectConsent === 'unknown' && ( )}
)} {!initializingEdit && selectedPatient && (
Appointment Details
{/* Booking advances this patient's open inbound referral; it says so rather than asking. */} {selectedPatient && (referralsChecking ? (

Checking whether booking this visit closes a referral…

) : ( <> {referralsUnknown && (

Open referrals could not be checked. Booking this visit may close a referral for this patient.

)} {referralsToClose.length > 0 && (

{referralsToClose.length === 1 ? `Booking this visit will close the referral${referralsToClose[0].from ? ` from ${referralsToClose[0].from}` : ''}.` : `Booking this visit will close ${referralsToClose.length} open referrals for this patient.`}

)} {referralsIncomplete && (

{referralsToClose.length > 0 ? 'Not every open referral could be checked. Booking this visit may close others for this patient too.' : 'Not every open referral could be checked. Booking this visit may close a referral for this patient.'}

)} ))}
{isEditMode && selectedPatient && ( )} {/* A time-window request's only instruction stays beside the pickers. */} {statedAvailability && (
Patient is available
{statedAvailability}
)}