import { Appointment } from "fhir/r4"; import { statusLabel } from "../../../../../utils/fhirStatusVocabulary"; import { bookingReasonOf, pastAppointmentBand } from '../../../../../utils/pastAppointmentBand'; import { useAppointmentServiceContext, useAuthServiceContext, usePatientServiceContext, } from "../../../../../services"; import { useFhirAppointmentBroadcast, useFhirAppointments, useFhirAppointmentTargetedBroadcast, useFhirPractitioners, useFhirTasks, } from "../../../../../hooks"; import { Clock4, House, Video, X, } from "lucide-react"; import { Button } from "../../../../../components/ui/button"; import { EmptyState } from "../../../../../components/ui/empty-state"; import { VisitInvitationPanel } from "../../../../../components/VisitInvitation/VisitInvitationPanel.tsx"; import type { VisitSheetAudience } from "../../../../../utils/visitInvitationWords"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle, } from "../../../../../components/ui/dialog"; import { Skeleton } from "../../../../../components/ui/skeleton"; import { useCallback, useMemo, useState } from "react"; import { categorizePatientAppointments, isAppointmentHappeningNow } from "../../../../../utils/patientAppointmentCategorization"; import { getAppointmentLocationName } from "../../../../../utils/appointmentLocation"; import { appointmentTaskSummary, tasksByAppointmentId, } from "../../../../../utils/appointmentTasks.ts"; import AppointmentTaskList from "../../../../../components/appointments/AppointmentTaskList.tsx"; // ONE implementation of Cancel and Directions, shared with the home's appointment dialog. import PatientAppointmentActions from "../../../../../components/appointments/PatientAppointmentActions.tsx"; import { appointmentLocationId, formatLocationAddress, } from "../../../../../utils/appointmentDirections.ts"; import { isVirtualLocation } from "../../../../../utils/roomLocation"; import { isOrglessPatientsHost } from "../../../../../utils/hostedMount"; import { VideoVisitJoinNotice } from "../../../../../components/appointments/VideoVisitJoinNotice"; import { useFhirLocations } from "../../../../../hooks"; import ReceptionistBookAppointment from "../../../ReceptionistHomePage/ReceptionistBookAppointment"; import { appointmentSubjectLine } from '../../../../../utils/appointmentSubject'; import { appointmentPatientId, resolveAppointmentPractitionerLabel } from '../../../../../utils/appointmentParticipants.ts'; import RecordAuthorizationDialog from '../../../../../components/RecordAuthorizationDialog/RecordAuthorizationDialog.tsx'; import { isAwaitingPractice, AWAITING_PRACTICE_LABEL, AWAITING_PRACTICE_SENTENCE, } from '../../../../../utils/appointmentAwaitingPractice'; import { DECLINED_BY_PRACTICE_LABEL } from '../../../../../utils/appointmentCancellation'; import { useBookAppointmentDialog } from "../../../../../components/BookAppointmentDialog/BookAppointmentDialogProvider.tsx"; import { PracticeMark } from "../../../MyProvidersPage/OrgListCard.tsx"; import { InboxAvatar } from "../../../MessagesPage/InboxRow.tsx"; import { usePhotoUrl } from "../../../../../hooks/usePhotoUrl.ts"; import { getFhirPhotoUrl } from "../../../../../utils/fhirPhoto"; import { viewerZoneLabelFor } from "../../../../../utils/practiceTimezone"; import { useAppointmentPractices } from "../../../../../hooks/useAppointmentPractices"; import { useAppointmentSites } from "../../../../../hooks/useAppointmentSites"; import { appointmentPracticeId } from "../../../../../utils/appointmentPractice"; import { appointmentDetailDate, appointmentDetailTime, appointmentRelativeDay, appointmentRowWhen, appointmentShortDate, } from "../../../../../utils/appointmentWhen"; import "./PatientAppointments.css"; const NOT_RECORDED = "Practitioner not recorded"; const NO_APPOINTMENTS: Appointment[] = []; // Their photograph or their initials; never the stock silhouette, which is nobody's face. const PractitionerMark = ({ name, photo }: { name: string; photo?: import("fhir/r4").Attachment }) => { const { url, loading } = usePhotoUrl(photo, "practitioner"); const real = photo && !loading && url !== getFhirPhotoUrl(null, "practitioner") ? url : undefined; return ( ); }; // `isRoot` marks the patient's own page; the list and panel read the viewer instead. const PatientAppointments = (_props: { isRoot?: boolean }) => { const { userRole, USER_ROLES, actingAs } = useAuthServiceContext(); // Mounted as the staff chart and as the patient's own list: gate on the viewer, never on `isRoot`. const viewerIsPatient = [USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON].includes(userRole as string); // Whom the visit sheet speaks to: a helper reads somebody else's record, the patient their own, staff the chart. const sheetAudience: VisitSheetAudience = actingAs || userRole === USER_ROLES.RELATED_PERSON ? { kind: "helper", subjectName: actingAs?.subjectName } : viewerIsPatient ? { kind: "self" } : { kind: "staff" }; // Booking is practice-bound; the org-less patients address has no practice, so it offers none. const offersBooking = !isOrglessPatientsHost(); const { setAppointmentsList } = useAppointmentServiceContext(); const { loading, error, answered, listIsThisPatients, loadAppointments, appointmentsList } = useFhirAppointments(); // The patient whose chart this is - only once the list on screen is theirs, so a booking never starts // on the previous chart's patient while this one loads. const { patientState } = usePatientServiceContext(); const chartPatient = listIsThisPatients ? patientState : undefined; // Loaded once here rather than per open panel. const { locationsList } = useFhirLocations(); const { practitionersList } = useFhirPractitioners(); const { tasksList } = useFhirTasks(); const { openBookAppointment } = useBookAppointmentDialog(); const [showBookingModal, setShowBookingModal] = useState(false); // An id resolved against the current list, so a list that no longer holds it closes the panel. const [selectedId, setSelectedId] = useState(); // A cached list may be another chart's: nothing is shown or called empty until this patient's answers. const shownAppointments = useMemo( () => (listIsThisPatients ? appointmentsList ?? [] : []), [appointmentsList, listIsThisPatients], ); // A patient's own bookings at other practices bring their sites; a staff chart reads only its own. const practiceIdOf = useCallback( (appointment: Appointment) => appointmentPracticeId(appointment, practitionersList ?? [], locationsList ?? []), [practitionersList, locationsList], ); const sites = useAppointmentSites(viewerIsPatient ? shownAppointments : NO_APPOINTMENTS, locationsList ?? [], practiceIdOf); const practiceOf = useAppointmentPractices(shownAppointments, practitionersList ?? [], sites, { readPatientPractices: viewerIsPatient, }); const categorizedAppointments = useMemo( () => categorizePatientAppointments(shownAppointments), [shownAppointments], ); const selectedAppointment = selectedId ? shownAppointments.find((appointment) => appointment.id === selectedId) : undefined; // One pass over the whole list, from the resolver the home's card and the front desk read. const tasksByAppointment = useMemo( () => tasksByAppointmentId(tasksList ?? [], shownAppointments), [tasksList, shownAppointments], ); // The patient's own page opens the frame's one booking dialog, which also refuses a proxy who may // not book. The staff chart has no frame dialog, so it keeps its own. const openBooking = () => { if (viewerIsPatient) openBookAppointment(); else setShowBookingModal(true); }; useFhirAppointmentBroadcast({ onCreate: (createdAppointment) => { setAppointmentsList((currentAppointments) => { const appointmentExists = currentAppointments.some( (appointment) => appointment.id === createdAppointment.id, ); return appointmentExists ? currentAppointments.map((appointment) => appointment.id === createdAppointment.id ? createdAppointment : appointment, ) : [...currentAppointments, createdAppointment]; }); }, onUpdate: (updatedAppointment) => { setAppointmentsList((currentAppointments) => { const appointmentExists = currentAppointments.some( (appointment) => appointment.id === updatedAppointment.id, ); return appointmentExists ? currentAppointments.map((appointment) => appointment.id === updatedAppointment.id ? updatedAppointment : appointment, ) : [...currentAppointments, updatedAppointment]; }); }, onDelete: (deletedAppointment) => { setAppointmentsList((currentAppointments) => currentAppointments.filter( (appointment) => appointment.id !== deletedAppointment.id, ), ); }, }); useFhirAppointmentTargetedBroadcast({ appointment: selectedAppointment, isOpen: Boolean(selectedAppointment), onUpdate: (updatedAppointment) => { setAppointmentsList((currentAppointments) => currentAppointments.map((appointment) => appointment.id === updatedAppointment.id ? updatedAppointment : appointment, ), ); }, onDelete: () => setSelectedId(undefined), }); // Resolved live from the practitioner list, never the name copied into the booking. const getPractitionerName = (appointment: Appointment): string => resolveAppointmentPractitionerLabel(appointment, practitionersList ?? [], "").trim() || NOT_RECORDED; const getPractitionerPhoto = (appointment: Appointment) => { const reference = appointment.participant ?.map((participant) => participant.actor?.reference) .find((ref) => ref?.startsWith("Practitioner/")); const id = reference?.split("/")[1]; return id ? practitionersList?.find((item) => item.practitioner.id === id)?.practitioner.photo?.[0] : undefined; }; // What the visit is for, from the one shared helper. const getAppointmentType = (appointment: Appointment): string => appointmentSubjectLine(appointment); // The appointment's own Location, falling back to its own practice for one that names none. const getLocationName = (appointment: Appointment): string => getAppointmentLocationName(appointment, practiceOf(appointment).organization?.name); // One answer for the glyph and the join sentence; the building when the Location is not loaded. const isVideoVisit = (appointment: Appointment): boolean => { const locationId = appointmentLocationId(appointment); const location = locationId ? sites.find((candidate) => candidate.id === locationId) : undefined; return Boolean(location && isVirtualLocation(location)); }; const LocationGlyph = ({ appointment }: { appointment: Appointment }) => isVideoVisit(appointment) ? (