import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import type { Appointment, Organization } from "fhir/r4"; import { Dialog, DialogContent, DialogDescription, DialogTitle, } from "../ui/dialog"; import { Alert, AlertDescription } from "../ui/alert"; import { useAuthServiceContext, useOrganizationServiceContext } from "../../services"; import { useMyProxies } from "../../hooks/useMyProxies"; import { describeProxyCannotBook, mayBookAsProxy, proxyStanding } from "../../utils/proxyFormAuthority"; import PatientBookAppointment from "../../pages/DashboardPage/PatientsPage/PatientPage/PatientBookAppointment/PatientBookAppointment"; /** * THE ONE BOOKING SURFACE, HOSTED WHERE EVERY VIEW CAN REACH IT. * * Booking used to exist twice: a `/dashboard/book-appointment` PAGE that the portal header navigated * to, and a dialog that the appointments page opened. Spencer retired the page - "we don't need it, * it's outdated because the book appointment dialog can be fired from any view in the header button." * * WHY A PROVIDER RATHER THAN A SECOND DIALOG IN THE HEADER. The header is in the portal frame and the * home's next-appointment card is several routes down; a dialog mounted in either is unreachable from * the other. Mounting one per entrance is how booking came to exist twice in the first place, so the * host is the frame and everything else asks it to open. * * THE COMPONENT ITSELF NEEDED NO CHANGE TO MOVE. It already took `embedded` and `onClose`; what it * needed was for the confirmation to stop being a property of the standalone route. * * CLOSED MEANS UNMOUNTED, and that is load-bearing rather than an optimisation. Radix does not render * dialog content while closed, so the booking chain - schedules, roles, locations, services and the * availability engine - is not fetched on every patient page merely because the frame can open it. * Gating on `open` as well says so out loud, so a future `forceMount` cannot switch it on by accident. */ export type BookAppointmentOptions = { /** Another practice to book with from this portal; absent books with the portal's own. */ organization?: Organization; }; type BookAppointmentDialogValue = { /** Open the booking dialog from anywhere under the portal frame. */ openBookAppointment: (options?: BookAppointmentOptions) => void; /** Counts bookings made through the dialog, so a list showing visits can read them again. */ bookingsMade: number; /** The last visit booked and its practice, for a list whose search has not caught up with it yet. */ latestBooking?: BookedVisit; }; export type BookedVisit = { organizationId?: string; patientId?: string; appointment?: Appointment }; // Whose visit it is comes from the appointment itself, so a late answer never takes on a later viewer. const bookedPatientId = (appointment?: Appointment) => appointment?.participant ?.map((p) => p.actor?.reference) .find((ref) => ref?.startsWith("Patient/")) ?.slice("Patient/".length); const BookAppointmentDialogContext = createContext( undefined, ); /** * Never throws, and that is deliberate. * * Staff views render under the same frame without this provider, and a control that is only shown to * patients would still be constructed by React while a staff member is on the page. A hook that threw * would take the whole chrome down for a button nobody can see. Absent provider means the opener is a * no-op, which is the behaviour a staff frame wants anyway. */ export const useBookAppointmentDialog = (): BookAppointmentDialogValue => { const value = useContext(BookAppointmentDialogContext); return value ?? NO_DIALOG; }; const NO_DIALOG: BookAppointmentDialogValue = { openBookAppointment: () => undefined, bookingsMade: 0 }; // A helper below the scheduling tier is told before the form, not after the last step. Read only while // open, so the frame does not fetch it on every page. const BookingBody = ({ onClose, onBooked, organization }: { onClose: () => void; onBooked: (visit: BookedVisit) => void; organization?: Organization }) => { const { actingAs, wpOrganization } = useAuthServiceContext(); const { proxies, listAnswered } = useMyProxies(); const { fetchOrganizationAsCaller } = useOrganizationServiceContext(); // Another practice is read in full under its own header first: the patient's list holds only its // public card, without the booking rules or time zone. Keyed by id, so one never books as another. const awayId = organization?.id && organization.id !== wpOrganization.current?.id ? organization.id : undefined; // The practice booked with is fixed when the dialog opens, not read again when the booking lands. const [openedAtOrgId] = useState(() => awayId ?? wpOrganization.current?.id); const [practice, setPractice] = useState<{ id: string; organization?: Organization }>(); useEffect(() => { if (!awayId) return; let live = true; fetchOrganizationAsCaller(awayId, undefined, awayId).then( (read) => { if (live) setPractice({ id: awayId, organization: read }); }, () => { if (live) setPractice({ id: awayId }); }, ); return () => { live = false; }; }, [awayId, fetchOrganizationAsCaller]); const booked = (_message: string, appointment?: Appointment) => onBooked({ organizationId: openedAtOrgId, patientId: bookedPatientId(appointment), appointment }); const proxyTier = actingAs ? proxyStanding(proxies, actingAs.relatedPersonId, listAnswered) : undefined; if (actingAs && !mayBookAsProxy(proxyTier)) { return (
{describeProxyCannotBook(actingAs.subjectName)}
); } if (awayId) { const name = organization?.name?.trim() || "this practice"; if (practice?.id !== awayId) { return

Loading {name}…

; } if (!practice.organization) { return (
We couldn’t load booking for {name}. Please try again.
); } return ; } return ; }; export const BookAppointmentDialogProvider = ({ children }: { children: ReactNode }) => { const [open, setOpen] = useState(false); const [organization, setOrganization] = useState(); // Openers wired straight to onClick hand over the click event, which is not a choice of practice. const openBookAppointment = useCallback((options?: BookAppointmentOptions) => { const chosen = options && typeof options === "object" && "organization" in options ? options.organization : undefined; setOrganization(chosen?.resourceType === "Organization" ? chosen : undefined); setOpen(true); }, []); const close = useCallback(() => { setOpen(false); setOrganization(undefined); }, []); const [bookingsMade, setBookingsMade] = useState(0); const [latestBooking, setLatestBooking] = useState(); const onBooked = useCallback((visit: BookedVisit) => { setLatestBooking(visit); setBookingsMade((count) => count + 1); }, []); const value = useMemo( () => ({ openBookAppointment, bookingsMade, latestBooking }), [openBookAppointment, bookingsMade, latestBooking], ); return ( {children} (next ? setOpen(true) : close())}> {/* 512px at every step (Spencer, 2026-09-09); unsized, it grew to fit its content. */} Book Appointment Select a practitioner, appointment type, date, and time.
{open && }
); }; export default BookAppointmentDialogProvider;