import { useEffect, useMemo, useState } from 'react'; import { SelectControl } from "../../../../../components/ui/select-control"; import { Card } from '../../../../../components/ui/card'; import { DismissibleAlert } from '../../../../../components/ui/dismissible-alert'; import { Spacer } from '../../../../../components/ui/spacer'; import { TriangleAlert } from 'lucide-react'; import type { HealthcareService, PractitionerRole, Reference } from 'fhir/r4'; import { AppHeader } from '../../../../../components'; import { useAuthServiceContext, usePractitionerRoleServiceContext, usePractitionerServiceContext, useStaffPermissionServiceContext, } from '../../../../../services'; import { useFhirLocations } from '../../../../../hooks'; import { useServiceCatalogueBasis } from '../../../../../hooks/useServiceCatalogueBasis'; import { useFhirSchedules } from '../../../../../hooks/useFhirSchedules'; import { useStaffBookabilityContext } from '../../../../../hooks/useStaffBookabilityContext'; import { useNavigate } from 'react-router-dom'; import { resolveScope } from '../../../../../utils/booking/bookabilityScopes'; import { bucketCombinations } from '../../../../../utils/booking/bookabilityBuckets'; import AddServiceModal from '../../../OrganizationPage/AddServiceModal'; import ManageScheduleModal from '../../../SchedulesPage/ManageScheduleModal/ManageScheduleModal'; import { formatFhirHumanName } from '../../../../../utils/fhirHumanName'; import { STAFF_WORKSPACES } from '../../../../../utils/staffRoleCatalog'; import { getStaffScheduleRecord } from '../../../../../utils/staffScheduleRecord'; import { holdsClinicianSeat } from '../../../../../utils/clinicianEnabled'; import { describeWriteFailure } from '../../../../../utils/fetchFailureMessage'; import { applyRefSelfBookable, choiceToOverride, getRefSelfBookableOverride, overrideToChoice, type SelfBookableChoice, } from '../../../../../config/serviceSelfBookable'; import { applyRefTelehealthEligible, getRefTelehealthEligibleOverride, overrideToTelehealthChoice, telehealthChoiceToOverride, type TelehealthEligibleChoice, } from '../../../../../config/serviceTelehealthEligible'; import { SELF_BOOKABLE_CHOICE_LABELS, TELEHEALTH_CHOICE_LABELS } from './offeredServiceLabels'; const refId = (ref: Reference): string | undefined => ref.reference?.split('/').pop(); // A service this practitioner offers + its two independent per-service overrides, each `undefined` = // inherit the service's org default. // // They are NOT the same question and must not be collapsed: // `override` - may a PATIENT self-book this? (curation; staff book through it regardless) // `telehealthOverride` - may this be delivered BY VIDEO at all? (clinical possibility; binds everyone) type OfferedService = { id: string; override: boolean | undefined; telehealthOverride: boolean | undefined; }; const SELF_BOOKABLE_CHOICES = ['default', 'enabled', 'disabled'] as const; const TELEHEALTH_CHOICES = ['default', 'eligible', 'not-eligible'] as const; const StaffServicesOfferedPage = () => { const { practitionerState, setPractitionerState, setPractitionersList, } = usePractitionerServiceContext(); const { updatePractitionerRole } = usePractitionerRoleServiceContext(); const { currentOrganization } = useAuthServiceContext(); // The same gate the Schedules page puts on its editor: working hours are set by an administrator. // Offering the editor here to anybody else offered writes the server refuses. const { activeWorkspace, canManage: canManageSection } = useStaffPermissionServiceContext(); const canManageSchedules = canManageSection('schedules') && activeWorkspace === STAFF_WORKSPACES.ADMIN; const navigate = useNavigate(); // Only services a whole current catalogue verifies are offered to add; otherwise the page says so. const serviceBasis = useServiceCatalogueBasis(); const healthcareServicesList = serviceBasis.services; const [saving, setSaving] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [showAddService, setShowAddService] = useState(false); const [showScheduleModal, setShowScheduleModal] = useState(false); const roles = useMemo( () => (practitionerState?.roles ?? []).filter( (role): role is PractitionerRole & { id: string } => Boolean(role.id), ), [practitionerState?.roles], ); //: a PractitionerRole means "this person works at THIS organization" - a practitioner // with roles at two orgs has two of them. Offered services are a per-person fact, but scoped PER // ORGANIZATION (different equipment/scope/contract at each practice), so everything below (the // displayed set AND the write) must operate on the roles in the CURRENT org only - never the // union across every org this practitioner belongs to. const currentOrganizationId = currentOrganization?.id; const orgRoles = useMemo( () => roles.filter( (role) => Boolean(role.organization && refId(role.organization) === currentOrganizationId), ), [roles, currentOrganizationId], ); const { schedulesList } = useFhirSchedules(); const scheduleRecords = useMemo( () => { const id = practitionerState?.practitioner?.id; if (!id) return []; return schedulesList .map((s) => getStaffScheduleRecord(s, practitionerState ? [practitionerState] : [])) .filter((r) => r.practitioner?.practitioner.id === id); }, [schedulesList, practitionerState], ); // "Can patients actually book this provider, and if not, why?" - answered on the page where the answer is // configured. Every list here is already loaded except locations, which the staff LIST page also loads. const { locationsList } = useFhirLocations(); const bookabilityCtx = useStaffBookabilityContext({ practitioners: practitionerState ? [practitionerState] : [], serviceBasis, locationCount: locationsList?.length ?? 0, }); const bookabilityScopeInput = useMemo( () => ({ schedules: schedulesList, ctx: bookabilityCtx, practitionerNameById: new Map( practitionerState?.practitioner?.id ? [[ practitionerState.practitioner.id, formatFhirHumanName(practitionerState.practitioner.name) || 'This provider', ] as [string, string]] : [], ), locationNameById: new Map( (locationsList ?? []) .filter((l) => l.id) .map((l) => [l.id as string, l.name ?? (l.id as string)] as [string, string]), ), orgCatalogSize: healthcareServicesList.length, }), [schedulesList, bookabilityCtx, practitionerState, locationsList, healthcareServicesList], ); // One sentence, from the same resolution the Patient Booking tab renders in full. const bookingSummary = useMemo(() => { const { combinations } = resolveScope( practitionerState?.practitioner?.id ? { kind: 'practitioner', practitionerId: practitionerState.practitioner.id } : { kind: 'organization' }, bookabilityScopeInput, ); if (combinations.length === 0) return 'Nothing is set up for patient booking yet.'; // Bucketed and counted as the panel does, so a video-only exclusion is not a gap and a service // offered at several sites is one service. const { gaps, bookable } = bucketCombinations(combinations); if (gaps.length > 0) { return `${gaps.length} ${gaps.length === 1 ? 'thing patients' : 'things patients'} can’t book.`; } const services = new Set(bookable.map((c) => c.serviceId ?? c.serviceName)).size; return `Patients can book ${services} ${services === 1 ? 'service' : 'services'}.`; }, [practitionerState?.practitioner?.id, bookabilityScopeInput]); const catalogNameById = useMemo( () => new Map( healthcareServicesList.map((service: HealthcareService) => [service.id, service.name ?? (service.id as string)]), ), [healthcareServicesList], ); // The service name is denormalized onto each PractitionerRole.healthcareService ref as `display` // (written on save). Use it as a fallback so roles that can't load the org-wide service catalog // (e.g. the PHI-limited receptionist) still see names instead of raw HealthcareService UUIDs. const refDisplayById = useMemo( () => { const byId = new Map(); for (const role of orgRoles) { for (const ref of role.healthcareService ?? []) { const id = refId(ref); if (id && ref.display && !byId.has(id)) byId.set(id, ref.display); } } return byId; }, [orgRoles], ); const serviceName = (id: string): string => catalogNameById.get(id) ?? refDisplayById.get(id) ?? id; // The offered set (with both per-service overrides), unioned across the practitioner's role(s) IN // THIS ORG ONLY. A practitioner may legitimately hold several roles within one org (e.g. two // distinct role types at the same org) - that union is fine. Unioning across OTHER orgs is not: // it would display, and then re-save, another practice's services onto this one. const initialOffered = useMemo(() => { const byId = new Map>(); for (const role of orgRoles) { for (const ref of role.healthcareService ?? []) { const id = refId(ref); if (id && !byId.has(id)) { byId.set(id, { override: getRefSelfBookableOverride(ref), telehealthOverride: getRefTelehealthEligibleOverride(ref), }); } } } return Array.from(byId, ([id, overrides]) => ({ id, ...overrides })); }, [orgRoles]); const [offered, setOffered] = useState(initialOffered); useEffect(() => setOffered(initialOffered), [initialOffered]); // A SERVICE MAY ONLY BE OFFERED BY SOMEBODY WHO CAN BE BOOKED. // // A service on this list is the second link of the booking cascade - the org's coded catalogue, the // subset this practitioner offers, then the schedule blocks - so offering one is the assertion "a // patient may book me for this". Without a seat that person cannot be booked at all, which is what // their own header two lines up already says. // // ACROSS THE PERSON'S ROLES IN THIS ORG, not the role in view. A seat belongs to the human at a // practice and is billed per human, so somebody whose primary hat is Office Manager and who also // holds a seated clinical role here is seated. `holdsClinicianSeat` also drops inactive roles, which // is how the bill counts them. const seated = holdsClinicianSeat(orgRoles); const practitionerName = formatFhirHumanName(practitionerState?.practitioner?.name) || 'This staff member'; // Removing stays available with no seat, and that is deliberate rather than an oversight: a seat can // be revoked from somebody who already offers services, and a tab that refused every write would // leave those services on the record with no way to take them off. The server draws the same line - // it refuses an ADDITION and nothing else. So the only thing there is to save with no seat is a // removal, which is why Save goes dead only when the list is empty as well. const canAddServices = seated; const canSave = orgRoles.length > 0 && (seated || offered.length > 0); const offeredIds = useMemo(() => new Set(offered.map((item) => item.id)), [offered]); const addableServices = useMemo( () => healthcareServicesList.filter((service) => service.id && !offeredIds.has(service.id)), [healthcareServicesList, offeredIds], ); const addService = (id: string) => setOffered((prev) => (prev.some((item) => item.id === id) ? prev : [...prev, { id, override: undefined, telehealthOverride: undefined }])); const removeService = (id: string) => setOffered((prev) => prev.filter((item) => item.id !== id)); const setChoice = (id: string, choice: SelfBookableChoice) => setOffered((prev) => prev.map((item) => (item.id === id ? { ...item, override: choiceToOverride(choice) } : item))); const setTelehealthChoice = (id: string, choice: TelehealthEligibleChoice) => setOffered((prev) => prev.map((item) => (item.id === id ? { ...item, telehealthOverride: telehealthChoiceToOverride(choice) } : item))); const handleSave = async () => { if (roles.length === 0) { setError('Add an organization role before assigning services.'); return; } if (orgRoles.length === 0) { setError('This staff member has no role at the current organization. Assign one before editing services offered here.'); return; } setSaving(true); setSuccess(undefined); setError(undefined); try { //: offered services are a per-person fact, scoped PER ORGANIZATION - a practitioner // can hold roles at more than one org and legitimately offer different services at each // (different equipment, scope, contract). Write to every role this practitioner holds IN THE // CURRENT ORG (there can be more than one) and never touch a role at another organization. const updatedRoles = await Promise.all(orgRoles.map((role) => { // Reuse the existing ref object (preserving any non-self-bookable ref extensions) for services that // stay offered; mint a fresh reference for newly added ones. Then apply the self-bookable override. const existingByRef = new Map( (role.healthcareService ?? []).map((ref) => [refId(ref), ref] as const), ); const refs: Reference[] = offered.map(({ id, override, telehealthOverride }) => { const base = existingByRef.get(id) ?? { reference: `HealthcareService/${id}`, display: catalogNameById.get(id), }; // Chained, and order does not matter: each helper filters-then-appends only its OWN // url and carries every other ref extension through untouched. return applyRefTelehealthEligible(applyRefSelfBookable(base, override), telehealthOverride); }); const updatedRole: PractitionerRole = { ...role }; if (refs.length) { updatedRole.healthcareService = refs; } else { // HealthLake rejects empty arrays - omit the key rather than writing []. delete updatedRole.healthcareService; } return updatePractitionerRole(updatedRole); })); const byId = new Map(updatedRoles.map((role) => [role.id, role])); setPractitionerState((current) => current ? { ...current, roles: current.roles.map((role) => byId.get(role.id) || role) } : current, ); setPractitionersList((current) => current.map((item) => item.practitioner.id === practitionerState?.practitioner.id ? { ...item, roles: item.roles.map((role) => byId.get(role.id) || role) } : item, )); setSuccess('Services offered updated successfully.'); window.setTimeout(() => setSuccess(undefined), 3_000); } catch (saveError) { console.error('Failed to update services offered:', saveError); // A DENIAL RENDERS AS THE DENIAL. The server refuses a service put onto a role with no // clinician seat and says so in a sentence written to be read by the administrator holding // this screen; "Please try again" would replace it with an instruction that can only fail, // and would hide the one fact that makes the refusal actionable - who can grant a seat. // // THIS USED TO READ `body.message` ITSELF and that was wrong in one case: a refusal from // the GATEWAY rather than from one of our routes carries an IAM policy string there, which // would have been printed at an administrator. `describeWriteFailure` knows the difference // and says "You do not have permission to make this change" instead, while still relaying // our own sentences and still saying to try again on a genuine fault. setError(describeWriteFailure(saveError, 'Failed to update services offered')); } finally { setSaving(false); } }; return ( <> {success && ( <> setSuccess(undefined)}> {success} )} {error && ( <> setError(undefined)}> {error} )} {serviceBasis.state !== 'complete' && (
{serviceBasis.state === 'checking' ? 'Checking available services' : 'Available services could not be verified.'} {serviceBasis.state === 'unavailable' && }
)} {/* A SUMMARY only β€” the full report lives on the Patient Booking tab. This page is an editor and should stay one. Both derive from the SAME resolver call, so they can differ in detail but can never disagree on fact. */} {practitionerState?.practitioner?.id && ( <>
{/* Wraps: at 390 a truncated summary lost the reason patients cannot book. */} {bookingSummary}
)} {roles.length === 0 ? ( Add an organization role before assigning services offered to this staff member. ) : orgRoles.length === 0 ? ( //: this practitioner holds roles, just none at the org in view. Services offered // are scoped per organization, so there is nothing here to show or save - say so plainly // rather than silently writing nothing (or, before this fix, another org's services). This staff member is not staff at the current organization. Assign them a role here before editing services offered for this organization. ) : (

Services Offered

Services this person offers at {currentOrganization?.name ?? 'this practice'}.

{/* THE REFUSAL, AND IT STAYS ON SCREEN. Non-dismissible on purpose: it is the state of this person's record rather than the outcome of an action, and a banner the reader can close would let the disabled picker below it go back to being unexplained. It names the PERSON, not the screen - an administrator reading this is looking at somebody else's record - then says what a service IS, because nothing else here explains why a booking concept refuses a catalogue edit, then who can lift it. The last clause is the one that stops an administrator abandoning setup: what is already set is not lost, it starts working the moment a seat exists. */} {!seated && ( <> {practitionerName} does not hold an active clinician seat, so services cannot be added {'\n'} A service is something a patient can book, and only a seated clinician can be booked. An administrator can grant a seat on this record's Workspaces tab, under Clinician seat, and the services set here take effect as soon as one is granted. )} {offered.length === 0 ? ( <>

No services yet β€” add from the organization's catalog below.

{/* The consequence of leaving this empty, which nothing used to say: the services PICKER on every schedule block for this provider falls back to the WHOLE org catalog. Suppressed for a one-service org β€” nothing to widen to. 🚨 Card 416 β€” this used to read "every schedule block for this provider offers all N of the organization's services", which is the picker's behaviour stated as the patient's. A patient picks a service first, and a block is then matched on the services IT lists β€” so a block listing none offers nothing, whatever is set here. Saying "offers all N" gave a practice a reason not to fill either list in, and their provider silently could not be booked. See bookabilityCopy.ts WIDENED_ALL_SERVICES. */} {/* Suppressed with no seat: this warns that an empty list widens the schedule block's picker to the whole catalogue, and a provider who cannot be booked has no booking for that to affect. Beside a refusal it would read as a consequence to go and fix, which it is not. */} {seated && healthcareServicesList.length > 1 && (

{/* Card 285 β€” an icon, never a literal `⚠`: WordPress rewrites that character into a 120px third-party image after we render. */} Until a service is added here, each schedule block for this provider can pick from all {healthcareServicesList.length} of the organization's services β€” but patients can only book a block for the services that block itself lists.

)} ) : (
{offered.map(({ id, override, telehealthOverride }) => (
{/* The name wraps: at 390 a truncated name hid which service Remove removes. */}
{serviceName(id)}
))}
)}

Default uses the service's practice-wide online booking setting. Enabled or Disabled changes it for this practitioner only.

{/* DISABLED AND STILL HERE. Hiding it is the pattern this fix exists to end - an absent control teaches nobody what a seat is for, and it is what makes a missing seat self-concealing on the schedule dialog. The reason is in the banner at the top of this card, which is why this needs no label of its own. */} ({ label: service.name ?? (service.id as string), value: service.id as string, })), ]} onChange={(value) => { if (value && canAddServices) addService(value); }} /> {/* Gated too, and it has to be: this one creates a service in the ORGANIZATION'S catalogue and then puts it on this person, so leaving it live would be the same addition by a longer route. */}

Adding a new service also adds it to the services your organization offers, so it's available to assign to other practitioners too.

{canManageSchedules ? ( ) : (

Working hours are set by an administrator on the practice's schedules.

)}
)} {showAddService && ( setShowAddService(false)} onSaved={(saved) => { // The catalog list is shared context state, so `saved` is already in it; just add it. const newId = saved.id; if (newId) { setOffered((prev) => prev.some((item) => item.id === newId) ? prev : [...prev, { id: newId, override: undefined, telehealthOverride: undefined }]); } }} /> )} {canManageSchedules && showScheduleModal && practitionerState?.practitioner && ( setShowScheduleModal(false)} staffName={formatFhirHumanName(practitionerState.practitioner.name)} practitioner={practitionerState.practitioner} initialRecords={scheduleRecords} //: the ORG-SCOPED roles, not every role this practitioner holds. // ManageScheduleModal picks the schedule's anchoring PractitionerRole out of this // list (`practitionerRoles?.find(active) ?? [0]`) AND scopes which services a // block may offer from that role's `healthcareService`. Handing it a role from // another organization could anchor a Schedule to the wrong org - which the // server's `assertScheduleBelongsToOrganization` then refuses, surfacing as an // inexplicable failure rather than a silent cross-org write. practitionerRoles={orgRoles} /> )} ); }; export default StaffServicesOfferedPage;