import { useCallback, useEffect, useMemo, useState } from 'react'; import type { Appointment, Practitioner } from 'fhir/r4'; import { useAppointmentServiceContext } from '../../services/AppointmentService.tsx'; import { useFhirAppointments } from '../../hooks/useFhirAppointments'; import { useVisitClock } from '../../hooks/useVisitClock'; import { HttpError } from '../../services/HttpService'; import { useAuthServiceContext } from '../../services/AuthService.tsx'; import { useCapabilityServiceContext } from '../../services/CapabilityService.tsx'; import type { MyVisitInvitation } from '../../types/visitInvitation'; import { visitWhen, type VisitSheetAudience } from '../../utils/visitInvitationWords'; import { appointmentTeamId, SCHEDULED_ATTENDANCE, teamVisitTab, type TeamVisitTab } from '../../utils/teamVisits'; import { actorReferencesForStaff } from '../../utils/staffAppointmentImpact'; import { VisitInvitationPanel } from '../VisitInvitation/VisitInvitationPanel'; import { VisitAttendeeSummary } from '../VisitInvitation/VisitAttendeeSummary'; /** * A COLLABORATION TEAM'S APPOINTMENTS, from what this viewer may already read, never a team-wide search. * * - `patient`: the patient's own appointments (the patient, or a guardian switched in), kept to those whose invitation * names this team. Opening one shows the visit's invitation sheet the patient already has on their appointments. * - `invitee`: the viewer's own released invitations (a clinician or a related person asked to join), kept to this * team. Opening one shows who hosts it and who else is invited, read-only: answering stays where it already is. * - `staff`: a clinician's own released invitations, and the visits they host: their own appointments (the practice's * ordinary read by their own Practitioner and roles), kept to those that name them as a participant who is not an * invitee and that invite from this team. Opening a visit they host shows its invitation sheet, as their calendar does. * * Being on the team alone shows nothing: only a visit this viewer is part of appears, and a staff role is never read * as being its host. Scheduled visits are with the patient; a case conference without the patient is not a scheduled * visit and never appears here. */ type Source = { kind: 'patient'; audience: VisitSheetAudience } | { kind: 'invitee' } | { kind: 'staff' }; interface Row { id: string; start?: string; end?: string; status?: string; host?: string | null; /** The viewer hosts this visit. */ hosting?: boolean; patient?: string | null; response?: string; invitation?: MyVisitInvitation; } type Read = | { state: 'loading' } | { state: 'refused' } | { state: 'unavailable' } | { state: 'ok'; rows: Row[]; partial: boolean; notes?: string[] }; const TABS: { id: TeamVisitTab; label: string; empty: string }[] = [ { id: 'upcoming', label: 'Upcoming', empty: 'No upcoming visits with this team.' }, { id: 'past', label: 'Past', empty: 'No past visits with this team.' }, { id: 'cancelled', label: 'Cancelled', empty: 'No cancelled visits with this team.' }, ]; const INVITEE_CODE = 'collaboration-team-invitee'; // The host as the appointment itself names them: its practitioner who is not an invitee. const hostOf = (appointment: Appointment): string | null | undefined => { const host = appointment.participant?.find((participant) => /^(Practitioner|PractitionerRole)\//.test(participant.actor?.reference ?? '') && !participant.type?.some((type) => type.coding?.some((coding) => coding.code === INVITEE_CODE))); return host ? host.actor?.display?.trim() || null : undefined; }; const ANSWER: Record = { accepted: 'You accepted', declined: 'You declined' }; // The viewer is this visit's host when the appointment names one of their own references as a participant that is not // an invitee. Being on the team, or staff, is not that. const hostedBy = (appointment: Appointment, actors: string[]): boolean => (appointment.participant ?? []).some((participant) => actors.includes(participant.actor?.reference ?? '') && !participant.type?.some((type) => type.coding?.some((coding) => coding.code === INVITEE_CODE))); const patientOf = (appointment: Appointment): string | null => appointment.participant?.find((participant) => participant.actor?.reference?.startsWith('Patient/'))?.actor?.display?.trim() || null; const inviteeRow = (invitation: MyVisitInvitation): Row => ({ id: invitation.appointmentId, start: invitation.start, end: invitation.end, status: invitation.appointmentStatus, host: invitation.host === undefined ? undefined : invitation.host?.display ?? null, patient: invitation.patient?.display ?? null, response: invitation.response, invitation, }); const refusedRead = (error: unknown) => error instanceof HttpError && (error.status === 401 || error.status === 403); // Who can be asked to join a visit at all, by the invitation list's own rule: the route's answer when known, else the // kinds of person it admits. Anyone else holds no invitations, so their invitee view would only ever be refused. const CAN_HOLD_INVITATIONS = new Set(['Practitioner', 'RelatedPerson']); export const TeamAppointmentsSection = ({ teamId, source }: { teamId: string; source: Source }) => { const { fetchMyVisitInvitationsPage, fetchAppointmentsForActors } = useAppointmentServiceContext(); const { currentUser, practitionerRoles } = useAuthServiceContext(); const { mayCall } = useCapabilityServiceContext(); const viewerType = currentUser?.fhir?.resourceType; const mayHoldInvitations = mayCall('GET', '/my-visit-invitations') ?? Boolean(viewerType && CAN_HOLD_INVITATIONS.has(viewerType)); // The viewer's own references, the ones a visit they host names: their Practitioner and their own roles. const actorKey = viewerType === 'Practitioner' ? actorReferencesForStaff(currentUser?.fhir as Practitioner, practitionerRoles ?? []).join(',') : ''; const patient = useFhirAppointments({ autoLoad: source.kind === 'patient' }); const [inviteeRead, setInviteeRead] = useState({ state: 'loading' }); const [tab, setTab] = useState('upcoming'); const [openId, setOpenId] = useState(null); const loadInvitations = useCallback(async () => { setInviteeRead({ state: 'loading' }); try { const page = await fetchMyVisitInvitationsPage(); setInviteeRead({ state: 'ok', partial: page.truncated, rows: page.invitations.filter((invitation) => invitation.team?.id === teamId).map(inviteeRow), }); } catch (error) { setInviteeRead({ state: refusedRead(error) ? 'refused' : 'unavailable' }); } }, [fetchMyVisitInvitationsPage, teamId]); // A clinician: their invitations and the visits they host, read side by side. A read that fails is said beside what // the other found; both failing is said on its own, refused only when both were refused. const loadStaff = useCallback(async () => { setInviteeRead({ state: 'loading' }); const actors = actorKey ? actorKey.split(',') : []; // Each read settles on its own, even one that throws before it starts, so one failing never hides the other. const [invited, hosted] = await Promise.allSettled([ mayHoldInvitations ? Promise.resolve().then(() => fetchMyVisitInvitationsPage()) : Promise.resolve({ invitations: [], truncated: false }), actors.length > 0 ? Promise.resolve().then(() => fetchAppointmentsForActors(actors)) : Promise.resolve({ appointments: [], truncated: false }), ]); if (invited.status === 'rejected' && hosted.status === 'rejected') { setInviteeRead({ state: refusedRead(invited.reason) && refusedRead(hosted.reason) ? 'refused' : 'unavailable' }); return; } const byId = new Map(); if (hosted.status === 'fulfilled') { for (const appointment of hosted.value.appointments) { if (!appointment.id || appointmentTeamId(appointment) !== teamId || !hostedBy(appointment, actors)) continue; byId.set(appointment.id, { id: appointment.id, start: appointment.start, end: appointment.end, status: appointment.status, hosting: true, patient: patientOf(appointment), }); } } if (invited.status === 'fulfilled') { for (const invitation of invited.value.invitations) { if (invitation.team?.id !== teamId) continue; const row = inviteeRow(invitation); const hostedRow = byId.get(row.id); // A visit they both host and were asked to join is one row: hosted, with their own answer kept. byId.set(row.id, hostedRow ? { ...hostedRow, response: row.response } : row); } } const notes = [ ...(invited.status === 'rejected' ? ['Your invitations could not be read, so this may not be all of them.'] : []), ...(hosted.status === 'rejected' ? ['Visits you host could not be read, so this may not be all of them.'] : []), ]; setInviteeRead({ state: 'ok', rows: [...byId.values()], partial: (invited.status === 'fulfilled' && invited.value.truncated) || (hosted.status === 'fulfilled' && hosted.value.truncated), notes, }); }, [actorKey, mayHoldInvitations, fetchMyVisitInvitationsPage, fetchAppointmentsForActors, teamId]); useEffect(() => { if (source.kind === 'invitee' && mayHoldInvitations) void loadInvitations(); if (source.kind === 'staff' && (mayHoldInvitations || actorKey)) void loadStaff(); }, [source.kind, mayHoldInvitations, actorKey, loadInvitations, loadStaff]); // The patient's own list, as the appointments page reads it: unknown until it has answered, and partial when it was // served only up to a cutoff. const patientRead: Read = useMemo(() => { if (source.kind !== 'patient') return { state: 'loading' }; if (patient.error) return { state: 'unavailable' }; if (!patient.answered || !patient.listIsThisPatients) return { state: 'loading' }; return { state: 'ok', partial: patient.incomplete, rows: (patient.appointmentsList ?? []) .filter((appointment) => appointment.id && appointmentTeamId(appointment) === teamId) .map((appointment) => ({ id: appointment.id!, start: appointment.start, end: appointment.end, status: appointment.status, host: hostOf(appointment), })), }; }, [source.kind, patient.error, patient.answered, patient.listIsThisPatients, patient.incomplete, patient.appointmentsList, teamId]); const read = source.kind === 'patient' ? patientRead : inviteeRead; const rows = read.state === 'ok' ? read.rows : []; const now = useVisitClock(rows); const byTab = (id: TeamVisitTab) => rows.filter((row) => teamVisitTab(row, now) === id) .sort((a, b) => (id === 'upcoming' ? 1 : -1) * (a.start ?? '').localeCompare(b.start ?? '')); const shown = byTab(tab); const retry = source.kind === 'patient' ? () => void patient.loadAppointments() : source.kind === 'staff' ? () => void loadStaff() : () => void loadInvitations(); if (source.kind === 'invitee' && !mayHoldInvitations) return null; if (source.kind === 'staff' && !mayHoldInvitations && !actorKey) return null; return (

Appointments

{read.state === 'loading' &&

Checking this team's visits…

} {read.state === 'refused' &&

You can't see this team's visits.

} {read.state === 'unavailable' && (

This team's visits could not be loaded.{' '}

)} {read.state === 'ok' && ( <>
{TABS.map(({ id, label }) => ( ))}
{read.partial && (

Not every visit could be listed, so this may not be all of them.

)} {(read.notes ?? []).map((note) =>

{note}

)} {shown.length === 0 &&

{TABS.find((t) => t.id === tab)!.empty}

}
    t.id === tab)!.label} visits with this team`}> {shown.map((row) => (
  • {visitWhen(row) ?? 'Time to be confirmed'} {row.patient && With {row.patient}} {SCHEDULED_ATTENDANCE} {row.hosting && You host this visit} {!row.hosting && row.host !== undefined && ( Hosted by {row.host ?? 'a host whose name is not available'} )} {source.kind !== 'patient' && (!row.hosting || row.response) && ( {ANSWER[row.response ?? ''] ?? 'You have not answered'} )}
    {openId === row.id && (
    {source.kind === 'patient' || row.hosting ? : (

    You answer it under Visits you have been asked to join.

    )}
    )}
  • ))}
)}
); }; /** The section folded on a team's card: nothing is read until it is opened. */ export const TeamAppointmentsDisclosure = ({ teamId }: { teamId: string }) => { const [opened, setOpened] = useState(false); return (
setOpened(event.currentTarget.open)}> Appointments {opened &&
}
); }; export default TeamAppointmentsSection;