import { useState } from "react"; import { CalendarX, Car } from "lucide-react"; import type { Appointment, Location } from "fhir/r4"; import { useAppointmentServiceContext } from "../../services"; import { cancelledByPatient, cancelledByPractice, patientMayCancel } from "../../utils/appointmentCancellation.ts"; import { useFhirLocations } from "../../hooks"; import { appointmentLocationId, directionsUrl } from "../../utils/appointmentDirections.ts"; import { appointmentOwningPracticeId } from "../../utils/appointmentPractice.ts"; import { appointmentFollowUpLines, appointmentVersion } from "../../utils/appointmentVersion.ts"; import { settleUnknownWrite, writeOutcomeUnknown } from "../../utils/appointmentWriteOutcome.ts"; import { isRecordChanged } from "../../utils/recordChanged.ts"; import { isSaveRetired, useSaveScope } from "../../utils/saveOperation.ts"; import "./PatientAppointmentActions.css"; /** * What a patient can DO about one of their own appointments: cancel it, or get directions to it. * * THERE IS NO RESCHEDULE HERE, AND ITS ABSENCE IS THE FIX RATHER THAN A GAP. The button used to close * this surface and open the NEW-BOOKING flow carrying nothing - and nothing at the other end was * listening, because no reschedule parameter exists anywhere in the product and the booking page never * looks for one. A patient who used it picked a new time, confirmed, and still held the original: two * appointments, one of which nobody attends, in a slot no other patient could take. The practice paid * for that, not the patient. * * A REAL SELF-RESCHEDULE - carry the appointment in, cancel the original only AFTER the new booking * succeeds - is a capability nobody has decided to build. Until somebody does, the honest thing on * screen is to say that changing a time goes through the practice, which is what the sentence below * the actions does. Hiding the button without saying anything would leave the patient guessing. * * THE DEFECT THIS EXISTS FOR. The same appointment offered the address, Reschedule and Directions * when opened from the patient home, and none of them when opened from the Appointments page - two * detail panels built separately, and the one a patient reaches from their own Appointments list was * the poorer of the two. Only visible because claude-4 opened ONE appointment from BOTH surfaces in * the same session; either panel alone looks self-consistent. * * THE FIX IS NOT "ADD THE MISSING BUTTONS TO THE SECOND PANEL", and the card says so: that is two * implementations of the same three actions, which is what produced this card in the first place. This * is the ONE implementation, and both surfaces mount it. * * IT IS THE ACTIONS ONLY, NOT THE WHOLE PANEL, and that is deliberate rather than half a job. The * card proposed the page simply open the home's dialog - but the comparison behind that was * one-directional: the page's panel ALSO carries five things the home's does not (the booking status, * the days-until chip, the practitioner photo and specialty, the visit type, and the practitioner's * instructions). Swapping one panel for the other would have deleted those, which is a reduction in * what the patient can see dressed up as a repair. Converging the ACTIONS satisfies the card's own * done-when - *"offers the same actions"* - and leaves each surface its own content. * * TWO REAL DEFECTS ON THE PAGE ARE CLOSED BY MOUNTING THIS, and neither was on the card: * - its cancel SWALLOWED FAILURES - `await updateAppointment(...)` with no catch and nothing on * screen. A patient who believes they cancelled and did not simply will not turn up, and the * practice holds a slot for someone who is not coming. * - it offered Cancel with NO `patientMayCancel` gate, so a patient who had already arrived and * checked in was still shown the button. At that point cancelling is a conversation with the front * desk, not a control. */ export interface PatientAppointmentActionsProps { appointment: Appointment; /** * Close the surface this is mounted in. * * Called on a SUCCESSFUL cancel and before either navigation - never on a failure. Closing is the * confirmation: the list behind re-reads from the same appointments list, so leaving the panel open * would describe a cancelled appointment in the present tense. Leaving it open on a FAILURE is * equally deliberate, because the failure message is in here. */ onClose: () => void; /** Sites the host already resolved, including another practice's; defaults to this practice's. */ locations?: readonly Location[]; /** Who is cancelling. The staff chart passes "practice"; the patient's own surfaces leave it. */ canceller?: "patient" | "practice"; } export const PatientAppointmentActions = ({ appointment, onClose, locations, canceller = "patient", }: PatientAppointmentActionsProps) => { const { locationsList: practiceLocations } = useFhirLocations(); const locationsList = locations ?? practiceLocations; const { updateAppointment, fetchAppointment } = useAppointmentServiceContext(); const [confirmingCancel, setConfirmingCancel] = useState(false); const [cancelling, setCancelling] = useState(false); const [cancelFailed, setCancelFailed] = useState(false); // What a saved cancellation left unfinished on the practice's schedule; the sheet stays to say so. const [cancelFollowUps, setCancelFollowUps] = useState([]); const [cancelUnresolved, setCancelUnresolved] = useState(false); // The cancel belongs to this appointment and the person signed in; closing, or another of either, retires it. const cancelScope = useSaveScope(appointment.id); // A cancel whose answer never came is settled by reading the visit as stored now, under a recovery of its own: // cancelled means it landed; anything else leaves it unknown, and Cancel is not offered again until it is known. const checkCancelled = async () => { const recovery = cancelScope.start(); setCancelling(true); try { const settled = await settleUnknownWrite( () => recovery.step(() => fetchAppointment(appointment.id as string, true, recovery.transport)), (stored) => stored.status === "cancelled", ); if (settled === "saved") onClose(); else setCancelFailed("unknown"); } catch { // Retired: nothing is said for a sheet no longer on screen. } finally { if (recovery.belongs()) setCancelling(false); } }; const cancelAppointment = async () => { setCancelling(true); setCancelFailed(false); setCancelFollowUps([]); const operation = cancelScope.start(); let followUps: string[] = []; let settling = false; try { // Sent with the practice that owns the visit, so a cancel from the patients site, where no practice is // selected, is not refused for lacking one. A patient's cancel of a visit with no clear owner is not sent: // the portal's practice may not be the visit's. Staff cancel inside their own practice, as before. It names // the version on screen, so a cancel never lands over a change made since it was shown. const cancelled = canceller === "practice" ? cancelledByPractice(appointment) : cancelledByPatient(appointment); const owner = appointmentOwningPracticeId(appointment); if (!owner && canceller !== "practice") { setCancelUnresolved(true); return; } const basedOn = appointmentVersion(appointment); await operation.step(() => updateAppointment(cancelled, { basedOn, ...(owner ? { organizationId: owner } : {}), onFollowUp: (reported) => { followUps = reported; }, transport: operation.transport, }), true); if (followUps.length > 0) { setCancelFollowUps(followUps); return; } onClose(); } catch (error) { if (isSaveRetired(error)) return; // Said on screen, never swallowed. See the note at the top of this file. if (isRecordChanged(error)) setCancelFailed("changed"); else if (writeOutcomeUnknown(error)) settling = true; else setCancelFailed("failed"); } finally { if (operation.belongs() && !settling) setCancelling(false); } if (settling) await checkCancelled(); }; const locationId = appointmentLocationId(appointment); const location = locationId ? locationsList?.find((candidate) => candidate.id === locationId) : undefined; // ONE FACT DECIDES IT: the place's own address. A telehealth Location has none ( - // telehealth IS a Location), so it yields no Directions without anyone asking "is this telehealth?", // and the same rule covers a site nobody has finished configuring. const directions = directionsUrl(location); // ONE PREDICATE DRIVES CANCEL AND THE SENTENCE BELOW IT, deliberately: both are about a visit that // has not happened yet and that the patient is not already standing at the desk for. Telling someone // to ring about moving a visit they attended last month, or one they have just checked in to, is the // same class of wrong as offering them Cancel there. const mayCancel = patientMayCancel(appointment); return ( <> {/* 🚨 A FAILED CANCEL IS SAID OUT LOUD. */} {cancelUnresolved && (

This appointment can't be cancelled here. Please call the practice to cancel it.

)} {cancelFailed === "changed" && (

This appointment was changed while you were looking at it, so it was not cancelled. Close this and open it again to see it as it is now.

)} {cancelFailed === "unknown" && (

We could not confirm whether your appointment was cancelled. Check before you try again.

)} {cancelFollowUps.length > 0 && (

Your appointment is cancelled.

{appointmentFollowUpLines(cancelFollowUps).map((line) =>

{line}

)}
)} {cancelFailed === "failed" && (

Your appointment was not cancelled. Please try again, or call the practice.

)}
{/* 🔒 CANCEL ARRIVED SECOND, ON PURPOSE. R6 asked for it, but `PUT /R4/Appointment/{id}` used to check nobody — a patient token could update ANY appointment, in any org, by id (card 308). The hole predated any button; a Cancel control is what would have made it reachable in one click by design. The patient boundary is merged and deployed, so the button follows. ⚠️ Only where cancelling means something — not on a past visit, and not once the patient has arrived, which is a conversation with the front desk rather than a button. */} {/* A cancel refused as changed, or not yet known, is not offered again until the visit is opened afresh. */} {mayCancel && cancelFailed !== "unknown" && cancelFailed !== "changed" && cancelFollowUps.length === 0 && ( confirmingCancel ? ( /* NEVER ON THE FIRST CLICK. A cancelled appointment cannot be un-cancelled from here - the patient would have to rebook and the slot may be gone. */ {/* NOT `text-black`. The ink scale is four warm steps and pure black is not one of them; `text-foreground` is --ink, the body-copy step, which is what this question is. */} Cancel this appointment? {/* THE TWO ANSWERS TRAVEL TOGETHER. They are one either/or, and on a 390px portal the question plus the red button fill the row on their own - so `flex-wrap` broke the pair and left "Keep it" stranded on a line of its own, below and apart from the choice it belongs to. Measured: the two sat on separate rows, 24px apart. That reads as a destructive act with an afterthought under it, which is the same thing the amber ruling above is guarding against - the safe half should not look lesser than the half that ends the appointment. Wrapping the pair in its own row keeps `flex-wrap` doing its job on the OUTER span, where it moves the question and the answers apart, and never between the two answers. */} {/* The safe half of a destructive confirm is amber: red would put the safe choice in the danger colour and blue would make backing out look like the primary act. Ruled 2026-09-06. */} ) : ( ) )} {directions && !confirmingCancel && ( // A LINK, not an integration - no API, no key, nothing to wrap behind our own endpoint. )}
{/* 🚨 THE SHEET DOES NOT GO QUIET ABOUT THE THING IT NO LONGER OFFERS. Removing Reschedule narrows what a patient can do, so an unexplained absence would leave them to work out on their own that the only route left is the practice. Cancel standing alone with nothing said is the hand-rolled-empty-state defect wearing a different hat. */} {mayCancel && (

{canceller === "practice" ? "Cancelling here records the practice as cancelling it." : "Need a different time? Contact the practice and they can move this appointment for you."}

)} ); }; export default PatientAppointmentActions;