import { useEffect, useMemo } from "react"; import { onPractitionerRolesChanged } from "../../../utils/practitionerRolesChanged"; import { useNavigate } from "react-router-dom"; import { ChevronDown } from "lucide-react"; import type { Bundle, Location, PractitionerRole } from "fhir/r4"; import { useFhirHealthcareServices, useFhirLocations, useFhirPractitioners, useFhirSchedules } from "../../../hooks"; import { useStaffBookabilityContext } from "../../../hooks/useStaffBookabilityContext"; import { usePhotoUrl } from "../../../hooks/usePhotoUrl"; import { useStaffPermissionServiceContext } from "../../../services/StaffPermissionService"; import type { PractitionerWithRoles } from "../../../services/PractitionerService"; import { resolveScope } from "../../../utils/booking/bookabilityScopes"; import { resolveProviderSetupChain, type ChainLink, type ChainLinkId } from "../../../utils/booking/providerSetupChain"; import { blockerCopy } from "../../../utils/booking/bookabilityCopy"; import { isPatientOnly, type BookabilityBlocker } from "../../../utils/booking/bookabilityReasons"; import { isClinicianEnabled } from "../../../utils/clinicianEnabled"; import { useAuthServiceContext } from "../../../services"; import { locationTimeZoneOf, practiceTimeZoneOf } from "../../../utils/practiceTimezone"; import { formatScheduleDate } from "../../../utils/initialScheduleHorizon"; import { hoursEndingSoon, todayWhereScheduleRuns } from "../../../utils/scheduleExpiry"; import { onlySites } from "../../../utils/roomLocation"; import { isSearchTruncated } from "../../../utils/fetchAllPagesAsBundle"; import { isAsOfIncomplete } from "../../../utils/searchTruncation"; import { mayMissRecentWrite } from "../../../utils/recentWritesUnresolved"; import { formatFhirCodeableConcept } from "../../../utils/fhirCodeableConcept"; import { formatFhirHumanName } from "../../../utils/fhirHumanName"; import { DismissibleAlert } from "../../../components/ui/dismissible-alert"; import { Spinner } from "../../../components/ui/spinner"; type ClinicianReadiness = { entry: PractitionerWithRoles; name: string; roleLabel: string; inPersonReady: boolean; telehealthReady: boolean; telehealthReason?: string; telehealthAction?: ReasonAction; /** Why in-person visits cannot be booked, when no earlier setup step already says so. */ inPersonReason?: string; inPersonAction?: ReasonAction; missingStep?: ChainLink; /** Their only active schedule, so Schedules can open straight to it. */ scheduleId?: string; services: string[]; locations: string[]; scheduleReady: boolean; /** The last day of their hours, when that is within 30 days. */ hoursEnd?: string; }; // Where each reason is fixed. Keyed on the resolver's typed codes, never on the copy. type ReasonAction = { to: "staff"; tab: "workspaces" | "services" } | { to: "schedules" } | { to: "bookingRules" } | { to: "locations" }; // A practice may see patients only by video; this says what is missing without asking it to open a site. const NO_SITE_REASON = "This practice has no in-person location yet, so in-person visits cannot be booked. Add one in Locations if this clinician sees patients in person."; const ONLY_VIDEO_HOURS_REASON = "Their schedule holds hours only at the video location, so there is no in-person time to book."; const STEP_ACTION: Record = { workspace: { to: "staff", tab: "workspaces" }, clinicianSeat: { to: "staff", tab: "workspaces" }, servicesAssigned: { to: "staff", tab: "services" }, scheduleWithHours: { to: "schedules" }, blockOffersServices: { to: "schedules" }, }; const blockerAction = (code: BookabilityBlocker | undefined): ReasonAction | undefined => { switch (code) { case "ORG_TELEHEALTH_DISABLED": return { to: "bookingRules" }; case "ROLE_INACTIVE": case "NO_CLINICIAN_SEAT": return { to: "staff", tab: "workspaces" }; case "PRACTITIONER_NOT_OFFERING_SERVICE": return { to: "staff", tab: "services" }; case "BLOCK_BOOKING_DISABLED": case "BLOCK_DOES_NOT_OFFER_SERVICE": case "NO_SCHEDULE_AT_LOCATION": case "NO_VIRTUAL_LOCATION_ON_BLOCK": case "NEW_PATIENT_EXAM_NO_ELIGIBLE_BLOCK": case "BLOCK_NO_HOURS": case "BLOCK_NO_PLANNING_HORIZON": case "BLOCK_HORIZON_EXPIRED": return { to: "schedules" }; default: return undefined; } }; const seatRole = (entry: PractitionerWithRoles): PractitionerRole | undefined => entry.roles.find((r) => r.active !== false && isClinicianEnabled(r)); const initialsOf = (name: string): string => { const words = name.trim().split(/\s+/).filter(Boolean); if (words.length === 0) return "?"; return words.length === 1 ? words[0].slice(0, 2).toUpperCase() : (words[0][0] + words[words.length - 1][0]).toUpperCase(); }; const ClinicianAvatar = ({ entry, name }: { entry: PractitionerWithRoles; name: string }) => { const photo = entry.practitioner.photo?.[0]; const { url } = usePhotoUrl(photo, "practitioner"); return ( {photo ? : {initialsOf(name)}} ); }; const isWhole = (bundle: Pick) => !isSearchTruncated(bundle) && !isAsOfIncomplete(bundle) && !mayMissRecentWrite(bundle); // Every verdict comes from the booking resolver the patient flow uses: in person and telehealth are // resolveScope's own service x location combinations, split on the practice's video location, and // the reason is the first unmet step of resolveProviderSetupChain. While loading or after a failed // read the rows are not an answer, so callers must check both flags before reading them. export const useClinicianReadiness = () => { const { practitionersList, practitionersBundle, loading: practitionersLoading, error: practitionersError, loadPractitioners } = useFhirPractitioners(); const { schedulesList, schedulesBundle, loading: schedulesLoading, error: schedulesError, loadSchedules } = useFhirSchedules(); const { healthcareServicesList, healthcareServicesBundle, loading: servicesLoading, error: servicesError, loadHealthcareServices } = useFhirHealthcareServices(); const { wpOrganization, currentOrganization } = useAuthServiceContext(); const { locationsList, locationsBundle, loading: locationsLoading, error: locationsError, loadLocations } = useFhirLocations(); // A seat or setup saved elsewhere on the page changes the roles this reads. useEffect(() => onPractitionerRolesChanged(() => void loadPractitioners?.(true)), [loadPractitioners]); const seatHolders = useMemo( () => practitionersList.filter((entry) => seatRole(entry) !== undefined), [practitionersList], ); // Only a whole catalogue proves a service outside it is gone; a partial one says nothing about what it left out. const catalogue = healthcareServicesBundle && isWhole(healthcareServicesBundle) ? healthcareServicesList : undefined; const ctx = useStaffBookabilityContext({ practitioners: seatHolders, healthcareServicesList, healthcareServicesComplete: catalogue !== undefined, locationCount: onlySites(locationsList).length, }); const readiness = useMemo(() => { const locationById = new Map(locationsList.filter((l) => l.id).map((l) => [l.id as string, l])); const serviceById = new Map(healthcareServicesList.filter((s) => s.id).map((s) => [s.id as string, s])); const locationNameById = new Map([...locationById].map(([id, l]) => [id, l.name ?? id])); const practitionerNameById = new Map( seatHolders.map((e) => [e.practitioner.id ?? "", e.practitioner.name?.[0] ? formatFhirHumanName(e.practitioner.name[0]) : ""]), ); const practiceZone = practiceTimeZoneOf(wpOrganization?.current); const virtualId = ctx.telehealth?.virtualLocationId; const siteCount = onlySites(locationsList).length; // An all-locations block covers the practice's physical sites and never its video location (matchSchedules), // so it is in person exactly when there is a site to be in. const isInPerson = (c: { locationId?: string }) => (c.locationId === undefined ? siteCount > 0 : c.locationId !== virtualId); return seatHolders.map((entry) => { const id = entry.practitioner.id ?? ""; const name = practitionerNameById.get(id) || "Unnamed clinician"; const role = seatRole(entry); const { combinations } = resolveScope( { kind: "practitioner", practitionerId: id }, { schedules: schedulesList, ctx, practitionerNameById, locationNameById }, ); const inPersonReady = combinations.some((c) => c.staffBookable && isInPerson(c)); const virtualCombos = virtualId ? combinations.filter((c) => c.locationId === virtualId) : []; const telehealthReady = virtualCombos.some((c) => c.staffBookable); // Readiness is staff bookability, so a reason may only be one that stops staff too. const staffBlockers = (combos: typeof combinations) => combos.flatMap((c) => c.blockers).filter((code) => !isPatientOnly(code)); const firstVirtualBlocker: BookabilityBlocker | undefined = staffBlockers(virtualCombos)[0]; const telehealthBlocker: BookabilityBlocker | undefined = telehealthReady ? undefined : !ctx.telehealth?.orgEnabled ? "ORG_TELEHEALTH_DISABLED" : firstVirtualBlocker ?? "NO_VIRTUAL_LOCATION_ON_BLOCK"; const telehealthReason = telehealthBlocker ? blockerCopy(telehealthBlocker) : undefined; // The same combinations as the verdict, read for their first in-person blocker rather than guessed from the facts below. const inPersonCombos = combinations.filter(isInPerson); const firstInPersonBlocker: BookabilityBlocker | undefined = staffBlockers(inPersonCombos)[0]; const inPerson: { reason: string; action?: ReasonAction } | undefined = inPersonReady ? undefined : firstInPersonBlocker ? { reason: blockerCopy(firstInPersonBlocker), action: blockerAction(firstInPersonBlocker) } : siteCount === 0 ? { reason: NO_SITE_REASON, action: { to: "locations" } } : virtualId && combinations.length > 0 && combinations.every((c) => c.locationId === virtualId) ? { reason: ONLY_VIDEO_HOURS_REASON, action: { to: "schedules" } } : undefined; const activeSchedules = schedulesList.filter((s) => s.active !== false && (s.actor ?? []).some((a) => a.reference === `Practitioner/${id}`)); const chain = resolveProviderSetupChain(entry, schedulesList, ctx, catalogue); const serviceRefs = entry.roles.flatMap((r) => r.healthcareService ?? []); const locationRefs = entry.roles.flatMap((r) => r.location ?? []); return { entry, name, roleLabel: formatFhirCodeableConcept(role?.code?.[0]) || "Clinician", inPersonReady, telehealthReady, telehealthReason, telehealthAction: blockerAction(telehealthBlocker), inPersonReason: inPerson?.reason, inPersonAction: inPerson?.action, missingStep: chain.find((link) => !link.done), scheduleId: activeSchedules.length === 1 ? activeSchedules[0].id : undefined, services: [...new Set(serviceRefs.map((ref) => { const service = serviceById.get(ref.reference?.split("/")[1] ?? ""); const name = service?.name || ref.display; return name ? `${name}${service?.active === false ? " (inactive)" : ""}` : ""; }).filter(Boolean))], locations: [...new Set(locationRefs.map((ref) => { const location = locationById.get(ref.reference?.split("/")[1] ?? ""); if (!location) return ref.display ?? ""; const zone = locationTimeZoneOf(location); return zone ? `${location.name ?? ""} (${zone})` : location.name ?? ""; }).filter(Boolean))], scheduleReady: chain.find((link) => link.id === "scheduleWithHours")?.done ?? false, hoursEnd: hoursEndingSoon(schedulesList, id, (schedule) => todayWhereScheduleRuns(schedule, locationsList, practiceZone)), }; }); }, [seatHolders, schedulesList, ctx, healthcareServicesList, catalogue, locationsList, wpOrganization]); // The telehealth verdict reads the practice's policy from currentOrganization. Until that is this // practice's organization, an absent policy would read as video switched off, so it is still checking. const doorId = wpOrganization?.current?.id; const organizationPending = !currentOrganization?.id || (Boolean(doorId) && currentOrganization.id !== doorId); // A failure counts only while its list is still missing: these lists are shared, and another // reader's retry may already have loaded what this one failed to. const error = Boolean( (practitionersError && !practitionersBundle) || (schedulesError && !schedulesBundle) || (servicesError && !healthcareServicesBundle) || (locationsError && !locationsBundle) || // A list the API could not answer whole would give a settled verdict on what it left out. [practitionersBundle, schedulesBundle, healthcareServicesBundle, locationsBundle].some((bundle) => bundle && !isWhole(bundle)), ); // The list hooks report not-loading before their first fetch starts, so a missing bundle is // still loading too. const loading = !error && (organizationPending || practitionersLoading || schedulesLoading || servicesLoading || locationsLoading || !practitionersBundle || !schedulesBundle || !healthcareServicesBundle || !locationsBundle); // Every list again, forced: the failed one is not named, and a partial answer would be a guess. const retry = () => { void loadPractitioners?.(true); void loadSchedules?.(true); void loadHealthcareServices?.(true); void loadLocations?.(true); }; return { readiness, loading, error, retry }; }; // admin-v2 "Clinician appointment readiness status" (2026-09-18 ruling). A loading or failed read // never renders as zero seats or as ready. // Booking rules is a Practice Dashboard panel, so only a parent that hosts it can open it. const ClinicianReadinessPage = ({ onOpenBookingRules }: { onOpenBookingRules?: () => void } = {}) => { const navigate = useNavigate(); const { canView, canManage, schedulesScope } = useStaffPermissionServiceContext(); const { readiness, loading, error, retry } = useClinicianReadiness(); if (loading) { return (
Checking clinician readiness
); } if (error) { return ( Clinician readiness could not be checked. Nothing here has changed. ); } if (readiness.length === 0) { return (

No active clinician seats

A clinician needs an active seat before patients can book them. Your first clinician seat is free.

); } // Name order inside each group; the read order differed between two loads of the same practice. const byName = (a: ClinicianReadiness, b: ClinicianReadiness) => a.name.localeCompare(b.name); const blocked = readiness.filter((r) => !r.inPersonReady && !r.telehealthReady).sort(byName); const ready = readiness.filter((r) => r.inPersonReady || r.telehealthReady).sort(byName); const included = Math.min(1, readiness.length); const paid = readiness.length - included; const statusClass = (ok: boolean) => (ok ? "text-sage-ink" : "text-warning"); // Each destination keeps its own permission; one the viewer cannot reach becomes guidance, never a link. const mayReach = (action: ReasonAction, r: ClinicianReadiness) => action.to === "staff" ? canView("staff") && Boolean(r.entry.practitioner.id) : action.to === "schedules" ? canView("schedules") && schedulesScope === "all" : action.to === "locations" ? canManage("organization") : Boolean(onOpenBookingRules); const open = (action: ReasonAction, r: ClinicianReadiness) => { if (action.to === "staff") navigate(`/dashboard/staff/${r.entry.practitioner.id}/${action.tab}`); else if (action.to === "locations") navigate("/dashboard/organization/locations"); else if (action.to === "schedules") navigate("/dashboard/schedules", r.scheduleId ? { state: { openScheduleId: r.scheduleId } } : undefined); else onOpenBookingRules?.(); }; const offersSchedules = (r: ClinicianReadiness) => (r.missingStep && STEP_ACTION[r.missingStep.id].to === "schedules") || r.telehealthAction?.to === "schedules" || (!r.missingStep && r.inPersonAction?.to === "schedules"); const actionLabel = (action: ReasonAction) => action.to === "staff" ? (action.tab === "workspaces" ? "Open workspaces" : "Open assigned services") : action.to === "schedules" ? "Open Schedules" : action.to === "locations" ? "Open Locations" : "Open Booking rules"; const renderAction = (action: ReasonAction | undefined, r: ClinicianReadiness) => { if (!action) return null; return mayReach(action, r) ? ( ) : ( A practice administrator needs to update this. ); }; const renderRow = (r: ClinicianReadiness, isBlocked: boolean) => (
{r.name} {r.roleLabel} · clinician seat {/* The modes sit at the card's inset, under the avatar, as the deck draws them. */} In person · {r.inPersonReady ? "ready" : "not ready"} Telehealth · {r.telehealthReady ? "ready" : "not ready"} {r.hoursEnd && ( {`Hours end ${formatScheduleDate(r.hoursEnd)}. Extend them in Schedules so this clinician stays bookable.`} )}
Services
{r.services.length ? r.services.join(", ") : "No services assigned"}
Location
{r.locations.length ? r.locations.join(", ") : "No location assigned"}
Availability
{r.scheduleReady ? "Has a schedule with hours and dates" : "No schedule with hours and dates yet"}
{r.missingStep && (
Next step · {r.missingStep.label}
{r.missingStep.detail} {renderAction(STEP_ACTION[r.missingStep.id], r)}
)} {/* Only when no setup step above already names what to do first. */} {!r.missingStep && !r.inPersonReady && r.inPersonReason && (
In person
{r.inPersonReason} {renderAction(r.inPersonAction, r)}
)} {!r.telehealthReady && r.telehealthReason && (
Telehealth
{r.telehealthReason} {renderAction(r.telehealthAction, r)}
)} {r.hoursEnd && !offersSchedules(r) && (
Hours
{`Hours end ${formatScheduleDate(r.hoursEnd)}.`} {renderAction({ to: "schedules" }, r)}
)} {canView("staff") && r.entry.practitioner.id && (
)}
); return (

{readiness.length} active clinician {readiness.length === 1 ? "seat" : "seats"}

{included} included · {paid} paid

{blocked.length === 0 ? "Every clinician with a seat can take appointments." : `${blocked.length} ${blocked.length === 1 ? "clinician needs" : "clinicians need"} setup. Select a clinician to review their services and schedule.`}

{blocked.length > 0 && (

Cannot take appointments {blocked.length}

{blocked.map((r) => renderRow(r, true))}
)} {ready.length > 0 && (

Can take appointments {ready.length}

{ready.map((r) => renderRow(r, false))}
)}
); }; export default ClinicianReadinessPage;