import { useState } from "react"; import { Appointment, AppointmentParticipant, Organization } from "fhir/r4"; import { toast } from "sonner"; import "./PatientBookAppointment.css"; import { useAppointmentServiceContext, useAuthServiceContext, useFhirServiceContext, usePatientServiceContext, } from "../../../../../services"; import { formatFhirHumanName } from "../../../../../utils/fhirHumanName.tsx"; import { getCampaignCtaAttribution, clearCampaignCtaAttribution, } from "../../../../../utils/campaignCtaAttribution"; import { Button } from "../../../../../components/ui/button"; import { buildPreferredAvailabilityExtension, describePreferredAvailability, PREFERRED_DAY_OPTIONS, PREFERRED_TIME_OF_DAY_OPTIONS, } from "../../../../../utils/appointmentAvailabilityPreference"; import { CheckingYourBooking, isBookingRetired, isBookingUnconfirmed, useBookingOperation, } from "../../../../../components/booking/CheckingYourBooking"; import { BOOKING_STORAGE_UNAVAILABLE, BookingStorageUnavailableError, type BookingOperationEntry } from "../../../../../utils/booking/bookingOperation"; interface PatientTimeWindowRequestProps { onSuccess?: (message: string, appointment?: Appointment) => void; onClose?: () => void; /** The practice being asked, when it is not the portal's own. */ bookingOrganization?: Organization; } // Slot-less appointment request for orgs on request-only + `time-window`. The patient gives a reason // and their general availability (preferred days + times of day) with NO practitioner or slot; the // receptionist offers concrete options and places the actual appointment. const PatientTimeWindowRequest = ({ onSuccess, onClose, bookingOrganization, }: PatientTimeWindowRequestProps) => { const { wpOrganization } = useAuthServiceContext(); const practice = bookingOrganization ?? wpOrganization.current ?? undefined; const { patientState } = usePatientServiceContext(); const { createReferenceObject } = useFhirServiceContext(); const { createAppointment } = useAppointmentServiceContext(); const [reason, setReason] = useState(""); const [selectedDays, setSelectedDays] = useState([]); const [selectedTimes, setSelectedTimes] = useState([]); const [submitting, setSubmitting] = useState(false); const [sent, setSent] = useState(null); const booking = useBookingOperation({ opening: "time-window-request", practice: practice?.id, patient: patientState?.id, }); const requestedMessage = "Your request has been submitted. The practice will reach out with appointment options."; booking.onBooked((appointment) => onSuccess?.(requestedMessage, appointment)); const toggle = ( list: string[], setList: (next: string[]) => void, code: string, ) => { setList( list.includes(code) ? list.filter((c) => c !== code) : [...list, code], ); }; const canSubmit = Boolean(reason.trim()) && selectedDays.length > 0 && selectedTimes.length > 0 && !submitting; const handleSubmit = async () => { if (!canSubmit) return; if (!patientState?.id) { toast.error("We could not find your patient record. Please try again.", { position: "top-right", }); return; } setSubmitting(true); let operation: BookingOperationEntry | undefined; try { // Kept before anything is sent; a device that cannot keep it does not send. operation = await booking.begin(); const sending = operation; const participants: AppointmentParticipant[] = [ { actor: createReferenceObject( "Patient", patientState.id as string, formatFhirHumanName(patientState.name), ), status: "accepted", required: "required", }, ]; // No practitioner, no slot - a pure request. The server (request-only + time-window) forces // `proposed` and accepts the slot-less appointment; the receptionist places the real time. const payload: Appointment = { resourceType: "Appointment", status: "proposed", created: new Date().toISOString(), participant: participants, reasonCode: [{ text: reason.trim() }], extension: [ buildPreferredAvailabilityExtension(selectedDays, selectedTimes), ], }; // S4e-2, Task 12 - this is the request-only + time-window variant of the SAME patient // self-booking screen (PatientBookAppointment.tsx renders this in place of the slot-based flow // for that org mode), so it carries the stashed CTA attribution exactly like the slot-based // path does. getCampaignCtaAttribution() never throws; clearCampaignCtaAttribution() fires only // after this POST has actually succeeded (consume-once - a failed/retried request must still // attribute). const cta = getCampaignCtaAttribution(); const result = await booking.send(sending, (operationId) => createAppointment(payload, cta, undefined, bookingOrganization?.id, operationId)); // Settled for a view no longer shown: it is not announced, attributed or confirmed here. if (result.kind === "retired") return; const created = result.appointment; clearCampaignCtaAttribution(); const message = requestedMessage; onSuccess?.(message, created); toast.success(message, { position: "top-right" }); // Stays open on what was sent, as the slot-based path does; closing is the patient's Done. setSent(describePreferredAvailability(selectedDays, selectedTimes)); } catch (error) { // Its outcome is unknown: the check below stands in for the form until it is known. if (isBookingUnconfirmed(error) || isBookingRetired(error)) return; console.error("Failed to submit appointment request:", error); if (error instanceof BookingStorageUnavailableError) { toast.error(BOOKING_STORAGE_UNAVAILABLE, { position: "top-right" }); return; } toast.error( "Could not submit your appointment request. Try again.", { position: "top-right" }, ); } finally { await booking.abandonIfUnsent(operation); setSubmitting(false); } }; const organizationName = practice?.name || "the practice"; if (booking.recovery) { return (
); } if (sent !== null) { return (

Request sent

Your request has been submitted. The practice will reach out with appointment options.

{sent}

); } return (
{/* The same header as the booking steps: the title, the practice, the emergency line. */}

Request an appointment

{practice?.name && (

{practice.name}

)}

If emergency, call 911 or go to nearest emergency room.

Tell {organizationName} when you are generally available and why you would like to be seen. The practice will follow up with appointment options that fit.