import { useEffect, useMemo, useRef, useState } from 'react'; import { Spinner } from "../../../components/ui/spinner"; import type { CustomSelectOption } from "../../../components/ui/custom-select-control"; import { useLocation, useSearchParams } from 'react-router-dom'; import { scheduleDrillFrom } from './scheduleDrill'; import { DismissibleAlert } from '../../../components/ui/dismissible-alert'; import { Heading } from '../../../components/ui/heading'; import { Text } from '../../../components/ui/text'; import { Spacer } from '../../../components/ui/spacer'; import dayGridPlugin from '@fullcalendar/daygrid'; import interactionPlugin from '@fullcalendar/interaction'; import listPlugin from '@fullcalendar/list'; import FullCalendar from '@fullcalendar/react'; import timeGridPlugin from '@fullcalendar/timegrid'; import type { DatesSetArg, EventClickArg, EventContentArg, } from '@fullcalendar/core'; import type { Practitioner } from 'fhir/r4'; import { ChevronLeft, ChevronRight, Info } from 'lucide-react'; import SearchSelect from '../../../components/SearchSelect/SearchSelect.tsx'; import { useAuthServiceContext, useStaffPermissionServiceContext } from '../../../services'; import { onlyVirtual } from '../../../utils/roomLocation'; import { useFhirLocations, useFhirPractitionerRoleBroadcast, useFhirPractitioners, useFhirScheduleBroadcast, useFhirSchedules, useRouteAuthorization, } from '../../../hooks'; import { useStaffBookabilityContext } from '../../../hooks/useStaffBookabilityContext'; import { useServiceCatalogueBasis } from '../../../hooks/useServiceCatalogueBasis'; import BookabilityPanel from '../../../components/booking/BookabilityPanel'; import { formatFhirHumanName } from '../../../utils/fhirHumanName.tsx'; import { getRoleLabel, getStaffScheduleRecord } from '../../../utils/staffScheduleRecord'; import { EMPTY_DAY_HINT, PRACTICE_WIDE_EMPTY_SENTENCE, emptyDaySentence, scheduleEntriesLabel, schedulesScopeCopy, } from '../../../utils/schedulesScopeCopy'; import ManageScheduleModal from './ManageScheduleModal/ManageScheduleModal.tsx'; import { buildStaffScheduleEvents, type StaffScheduleEventProps, type StaffScheduleStatus } from './staffScheduleEvents'; import { resolveBookingTimeZone } from '../../../utils/practiceTimezone'; import { resolveScheduleManageTarget } from './scheduleManageTarget'; import { rolesForOrganization } from '../../../utils/practitionerRoleOrgScope'; import { getFullCalendarView, type ScheduleRange, type ScheduleViewMode } from './scheduleView'; import { useIsPhoneWidth } from '../../../hooks/useIsPhoneWidth'; import './SchedulesPage.css'; type StaffScheduleSelectOption = CustomSelectOption & { scheduleId?: string; practitionerId?: string; }; // Search result option for the staff name search - practitionerId/scheduleId identify what selecting it // should activate. A practitioner with no schedule yet has a practitionerId but no scheduleId. type StaffSearchOption = { value: string; label: string; practitionerId?: string; scheduleId?: string; }; const ALL_STAFF_KEY = 'all-staff'; const allStaffOption: StaffScheduleSelectOption = { key: ALL_STAFF_KEY, name: 'All staff', }; // A schedule state is a row state word: working reads good, unavailable reads bad, a break reads plain. const STATUS_TONE: Record = { scheduled: 'good', unavailable: 'bad', break: 'plain' }; // Your own hours (the practitioner deck's "A week of your own hours") offer Day, Week and Month; the practice-wide // boards keep Year. const ALL_RANGES: ScheduleRange[] = ['day', 'week', 'month', 'year']; const OWN_RANGES: ScheduleRange[] = ['day', 'week', 'month']; const SchedulesPage = () => { // USER_ROLES dropped with the workspace-list gate below ( / B45) - this page is now gated on // the `schedules` SECTION, matching its route, so it names no workspace at all. const { currentOrganization, currentUser, // - the signed-in person's OWN roles, which is the one staff list a narrowed reader can // always have. It comes from their own session rather than the staff directory, so it survives the // directory being withheld from them. practitionerRoles: currentUserRoles, USER_ROLES, } = useAuthServiceContext(); const { loading: schedulesLoading, schedulesList, schedulesBundle, error: schedulesError, clearError: clearSchedulesError, /* - WHY THIS PAGE MAY NOT BELIEVE AN EMPTY LIST. Measured as HR at a practice with no configured human-resources profile: `GET /R4/Schedule` returns **200 with zero entries** while the receptionist sees 14, and this page said *"0 schedule entries - No staff schedules found for this period."* at every time scale. THE SERVER IS CORRECT AND MUST NOT BE WIDENED. `resolveSchedulesScope` gives an unconfigured non-front-desk role scope `own` - ruling, so an org that has granted nothing does not hand out everyone's availability. HR then has no schedules of their own, so zero is the honest DATA. The refusal is a 200 with an empty set and never a 403, so nothing downstream can tell "scoped out" from "this practice has none". This flag is the only thing that can, and the whole chain to it already existed - server rule, client mirror, hook - with `UserPage` the only surface consuming it. */ restricted: onlyOwnSchedules, maySeeSchedules, } = useFhirSchedules(); const { loading: practitionersLoading, practitionersList, error: practitionersError, errorIsPermissionDenial: practitionersWithheld, clearError: clearPractitionersError, } = useFhirPractitioners(); const { activeWorkspace, canManage } = useStaffPermissionServiceContext(); // Writes accept the admin role or the schedules manage grant; at scope own the server refuses a // colleague's schedule and manageTarget below never resolves to one. const canManageSchedules = canManage('schedules'); const location = useLocation(); const [searchParams] = useSearchParams(); // Read once: after the page opens, its own controls own the view. const [drill] = useState(() => scheduleDrillFrom(searchParams)); const [selectedScheduleId, setSelectedScheduleId] = useState(null); const [selectedPractitionerId, setSelectedPractitionerId] = useState(null); const [staffSearchQuery, setStaffSearchQuery] = useState(''); const [viewMode, setViewMode] = useState('list'); const phone = useIsPhoneWidth(); const [range, setRange] = useState(drill?.range ?? 'day'); const [calendarTitle, setCalendarTitle] = useState('Today'); const [visibleRange, setVisibleRange] = useState<{ start: Date; end: Date }>(); const [showManageModal, setShowManageModal] = useState(false); // Set once the reader picks a range, so the own-hours default below never takes their choice back. const rangeChosen = useRef(false); const [localError, setLocalError] = useState(); const calendarRef = useRef(null); const spacerMargin = '24px'; const loading = schedulesLoading || practitionersLoading; // A failed read stays unknown until the next load, even once its banner is dismissed. const [schedulesFailed, setSchedulesFailed] = useState(false); useEffect(() => { if (schedulesError) setSchedulesFailed(true); else if (schedulesLoading) setSchedulesFailed(false); }, [schedulesError, schedulesLoading]); const schedulesUnknown = schedulesFailed || (!schedulesBundle && !schedulesError); // (design-pass finding B45) - HR clicked Schedules and landed on Staff. // // This is the THIRD instance of the divergence `useRouteAuthorization`'s own header documents: a // component gating on a fixed WORKSPACE LIST while its route gates on a configurable SECTION. The // route is `RequireStaffPermission section="schedules"` (App.tsx) and the sidebar only offers the // item when `canView('schedules')` - both of which HR passes. This list did not contain HR, so the // route admitted them and the component immediately redirected to '/', i.e. their own landing page. // That is why it looked like a broken nav link rather than a permission denial: it never 404s, it // always lands on another item from the same sidebar. // // Gated on the SECTION, exactly as that header instructs ("prefer `section` alone wherever the // route is section-gated, because a workspace list re-introduces exactly the divergence above"). // This grants nothing new: anyone reaching this component already cleared the same section check at // the route, so the two can no longer disagree by construction. useRouteAuthorization({ section: 'schedules' }); useFhirScheduleBroadcast(); useFhirPractitionerRoleBroadcast(); // MOVED ABOVE THE RECORDS, because the label needs it. Spencer's 2026-09-14 ruling makes a block // whose only location is virtual a VIDEO-ONLY block, so the row's location label is the only place // a reader is told which of the two shapes a block is - and first-actor answers that by accident of // storage order rather than by the rule. const { locationsList } = useFhirLocations(); const virtualLocationId = onlyVirtual(locationsList ?? [])[0]?.id; const staffScheduleRecords = useMemo( () => schedulesList.map((schedule) => getStaffScheduleRecord(schedule, practitionersList, virtualLocationId), ), [practitionersList, schedulesList, virtualLocationId], ); const normalizedStaffSearchTerms = useMemo( () => staffSearchQuery .trim() .toLocaleLowerCase() .split(/\s+/) .filter(Boolean), [staffSearchQuery], ); const selectedRecord = useMemo( () => staffScheduleRecords.find((record) => record.schedule.id === selectedScheduleId), [selectedScheduleId, staffScheduleRecords], ); // sweep - the receptionist overview's "Today's Work Schedule" rows used to send EVERY row // here with no target, so a receptionist had to find the person again. They now carry // `openScheduleId`; this is the half that honours it. // // Deliberately waits for the records to load rather than firing on mount: they arrive // asynchronously, and selecting before they exist would set an id that resolves to nothing and then // never retry. // Applied ONCE, and that is the point of the ref. Without it, a receptionist who clicks away to // another schedule is dragged back to the deep-linked one on the next render - a page that undoes // the user is worse than one that never honoured the link. const deepLinkedScheduleId = (location.state as { openScheduleId?: string } | null)?.openScheduleId; const appliedDeepLink = useRef(false); useEffect(() => { if (appliedDeepLink.current || !deepLinkedScheduleId || staffScheduleRecords.length === 0) return; appliedDeepLink.current = true; const record = staffScheduleRecords.find((candidate) => candidate.schedule.id === deepLinkedScheduleId); if (!record) return; setSelectedScheduleId(deepLinkedScheduleId); setSelectedPractitionerId(record.practitioner?.practitioner.id ?? null); }, [deepLinkedScheduleId, staffScheduleRecords]); // A report row names the staff member by role; selected once, when the staff list has arrived. const appliedDrill = useRef(false); useEffect(() => { if (appliedDrill.current || !drill?.roleId || practitionersList.length === 0) return; appliedDrill.current = true; const person = practitionersList.find((item) => item.roles.some((role) => role.id === drill.roleId)); if (person?.practitioner.id) setSelectedPractitionerId(person.practitioner.id); }, [drill, practitionersList]); // Block-scope bookability. This is one of only two surfaces an HR user can reach (HR defaults to // schedules:manage and staff:manage), and the only one that answers it for a SINGLE schedule block - // "patients can self-book 3 services here" / "nothing, no hours set". // No catalog is passed: service names fall back to the schedule actor's own display, which is written on // save, so this adds no fetch to the page. // The block's verdict stands on the verified catalogue: a deleted service no longer counts as offered. const serviceBasis = useServiceCatalogueBasis(); const bookabilityCtx = useStaffBookabilityContext({ practitioners: practitionersList, locationCount: locationsList?.length ?? 0, serviceBasis, }); const bookabilityScopeInput = useMemo( () => ({ schedules: schedulesList, ctx: bookabilityCtx, practitionerNameById: new Map( practitionersList .filter((item) => item.practitioner.id) .map((item) => [ item.practitioner.id as string, formatFhirHumanName(item.practitioner.name, { emptyText: 'Unknown staff member' }), ] as [string, string]), ), locationNameById: new Map( (locationsList ?? []) .filter((location) => location.id) .map((location) => [ location.id as string, location.name ?? (location.id as string), ] as [string, string]), ), }), [schedulesList, bookabilityCtx, practitionersList, locationsList], ); // Keyed on the person, so a first period created for someone with none, or the rest of their // coverage after one block is deleted, stays in view. const practitionerScheduleRecords = useMemo( () => selectedPractitionerId ? staffScheduleRecords.filter((r) => r.practitioner?.practitioner.id === selectedPractitionerId) : [], [selectedPractitionerId, staffScheduleRecords], ); const visibleRecords = useMemo( () => { if (selectedPractitionerId) return practitionerScheduleRecords; return selectedRecord ? [selectedRecord] : staffScheduleRecords; }, [selectedRecord, selectedPractitionerId, practitionerScheduleRecords, staffScheduleRecords], ); const scheduleEvents = useMemo( // Each schedule counted on its own site's clock (the practice's when the site has none). () => buildStaffScheduleEvents(visibleRecords, visibleRange, (record) => { const siteIds = (record.schedule.actor ?? []) .map((actor) => actor.reference) .filter((reference): reference is string => Boolean(reference?.startsWith('Location/'))) .map((reference) => reference.slice('Location/'.length)); const site = (locationsList ?? []).find((location) => location.id && siteIds.includes(location.id) && location.id !== virtualLocationId); return resolveBookingTimeZone({ site, organization: currentOrganization }); }), [visibleRange, visibleRecords, locationsList, virtualLocationId, currentOrganization], ); const staffOptions = useMemo(() => { // One entry per practitioner (not per schedule) - a practitioner with multiple location blocks // should appear once; selecting it shows their whole coverage. const byPractitioner = new Map(); for (const record of staffScheduleRecords) { if (!record.schedule.id) continue; const practitionerId = record.practitioner?.practitioner.id; const groupKey = practitionerId ?? record.schedule.id; if (byPractitioner.has(groupKey)) continue; byPractitioner.set(groupKey, { key: groupKey, name: `${record.staffName} — ${record.roleLabel}${record.active ? '' : ' (Inactive)'}`, scheduleId: record.schedule.id, practitionerId, }); } // Also list active practitioners who don't have a schedule yet (e.g. newly onboarded), so they can be // selected to create their first one. "Active" = at least one active PractitionerRole. for (const item of practitionersList) { const practitionerId = item.practitioner.id; if (!practitionerId || byPractitioner.has(practitionerId)) continue; const activeRole = item.roles.find((role) => role.active !== false); if (!activeRole) continue; const staffName = formatFhirHumanName(item.practitioner.name, { emptyText: 'Unknown staff member' }); byPractitioner.set(practitionerId, { key: practitionerId, name: `${staffName} — ${getRoleLabel(activeRole)} — No schedule yet`, practitionerId, }); } return [ allStaffOption, ...Array.from(byPractitioner.values()).sort((left, right) => left.name.localeCompare(right.name)), ]; }, [staffScheduleRecords, practitionersList]); const matchingStaffMembers = useMemo( () => normalizedStaffSearchTerms.length === 0 ? [] : practitionersList.filter((practitionerWithRoles) => { const normalizedName = formatFhirHumanName( practitionerWithRoles.practitioner.name, { emptyText: '' }, ).toLocaleLowerCase(); return normalizedStaffSearchTerms.every((term) => normalizedName.includes(term), ); }), [normalizedStaffSearchTerms, practitionersList], ); // Before anything is typed the picker lists everyone after All staff, as the frame's select does. const staffSearchOptions = useMemo(() => { // One result per practitioner, keyed by a representative schedule when one exists (selecting shows // full coverage); unscheduled practitioners are keyed by practitioner id so they're findable too. const byPractitioner = new Map(); for (const record of staffScheduleRecords) { if (!record.schedule.id) continue; const normalizedName = record.staffName.toLocaleLowerCase(); if (!normalizedStaffSearchTerms.every((term) => normalizedName.includes(term))) continue; const practitionerId = record.practitioner?.practitioner.id; const groupKey = practitionerId ?? record.schedule.id; if (byPractitioner.has(groupKey)) continue; byPractitioner.set(groupKey, { value: record.schedule.id, label: `${record.staffName} — ${record.roleLabel}${record.active ? '' : ' (Inactive)'}`, scheduleId: record.schedule.id, practitionerId, }); } for (const item of practitionersList) { const practitionerId = item.practitioner.id; if (!practitionerId || byPractitioner.has(practitionerId)) continue; const activeRole = item.roles.find((role) => role.active !== false); if (!activeRole) continue; const staffName = formatFhirHumanName(item.practitioner.name, { emptyText: '' }); const normalizedName = staffName.toLocaleLowerCase(); if (!normalizedName || !normalizedStaffSearchTerms.every((term) => normalizedName.includes(term))) continue; byPractitioner.set(practitionerId, { value: practitionerId, label: `${staffName} — ${getRoleLabel(activeRole)} — No schedule yet`, practitionerId, }); } const matches = Array.from(byPractitioner.values()) .sort((left, right) => left.label.localeCompare(right.label)); return normalizedStaffSearchTerms.length === 0 ? [{ value: ALL_STAFF_KEY, label: allStaffOption.name }, ...matches] : matches.slice(0, 10); }, [normalizedStaffSearchTerms, staffScheduleRecords, practitionersList]); const staffSearchNoResultsText = matchingStaffMembers.length > 0 ? 'Staff member has no active role to schedule.' : 'No matching staff members found.'; const selectedStaffOption = (selectedPractitionerId || selectedScheduleId) ? staffOptions.find( (option) => option.key === (selectedPractitionerId ?? selectedScheduleId), ) || allStaffOption : allStaffOption; const selectedPractitioner = useMemo( () => selectedPractitionerId ? practitionersList.find((item) => item.practitioner.id === selectedPractitionerId) : undefined, [selectedPractitionerId, practitionersList], ); // // `selectedPractitioner.roles` carries EVERY PractitionerRole this person holds, across every // organization - a `PractitionerRole` means "this person works at THIS organization", so someone // at two practices has two of them. // // NOT A DISPLAY BUG. ManageScheduleModal picks the Schedule's ANCHORING role out of this list // (`find(role => role.active !== false) ?? [0]`), so an unscoped list can anchor a Schedule to a // role at ANOTHER organization. The server then refuses the save - // `assertScheduleBelongsToOrganization` requires the actor's org to match the caller's - and the // staff member sees an inexplicable failure rather than a message naming the cause. The same list // also scopes which services a block may offer, so the picker can offer another practice's. // // Fixed at the CALL SITE, not in the modal: the modal is correct to trust the list it is given, // and established this shape when it fixed the same defect on StaffServicesOfferedPage. const selectedPractitionerOrgRoles = useMemo( () => rolesForOrganization(selectedPractitioner?.roles, currentOrganization?.id), [selectedPractitioner?.roles, currentOrganization?.id], ); /* * AT "MANAGE YOUR OWN SCHEDULE ONLY", THE READER *IS* THE SELECTION -. The rule and the * reason it is not a widening live with `resolveScheduleManageTarget`; this is the half that feeds it. * * IT READS THE SESSION, NOT THE DIRECTORY, and that is what makes it work at all. `currentUser.fhir` * and the auth context's own `practitionerRoles` come from the signed-in principal, so they are still * there when `practitionersList` has been withheld from a narrowed reader - which is precisely the * case that had no way in. */ const ownPractitioner = useMemo( () => (currentUser?.fhir?.resourceType === 'Practitioner' ? currentUser.fhir : undefined), [currentUser?.fhir], ); const ownOrgRoles = useMemo( () => rolesForOrganization(currentUserRoles, currentOrganization?.id), [currentUserRoles, currentOrganization?.id], ); const manageTarget = useMemo( () => resolveScheduleManageTarget({ selectedPractitioner: selectedPractitioner?.practitioner, selectedPractitionerRoles: selectedPractitionerOrgRoles, onlyOwnSchedules, ownPractitioner, ownRoles: ownOrgRoles, }), [selectedPractitioner, selectedPractitionerOrgRoles, onlyOwnSchedules, ownPractitioner, ownOrgRoles], ); /* * The periods the modal opens with. On the selection path this is unchanged. On the own-scope path * there is no selected record to derive them from, so they are filtered by the target directly - * without this a narrowed reader who ALREADY has hours would open an empty editor and create a * SECOND schedule beside the one they meant to edit. */ const manageInitialRecords = useMemo( () => { if (!manageTarget?.isOwn) return practitionerScheduleRecords; return staffScheduleRecords.filter( (record) => record.practitioner?.practitioner.id === manageTarget.practitioner.id, ); }, [manageTarget, practitionerScheduleRecords, staffScheduleRecords], ); const selectedStaffName = useMemo(() => { if (selectedRecord) return selectedRecord.staffName; if (selectedPractitioner) { return formatFhirHumanName(selectedPractitioner.practitioner.name, { emptyText: 'Unknown staff member' }); } if (manageTarget?.isOwn) { return formatFhirHumanName(manageTarget.practitioner.name, { emptyText: 'Your schedule' }); } return ''; }, [selectedRecord, selectedPractitioner, manageTarget]); useEffect(() => { const calendarApi = calendarRef.current?.getApi(); if (!calendarApi) { return; } const nextView = getFullCalendarView(viewMode, range, phone); if (calendarApi.view.type !== nextView) { calendarApi.changeView(nextView); } }, [range, viewMode, phone]); const handleDatesSet = (dateInfo: DatesSetArg) => { setVisibleRange({ start: new Date(dateInfo.start), end: new Date(dateInfo.end), }); setCalendarTitle(dateInfo.view.title || 'Staff schedules'); }; const handleStaffSelection = (selectedItem: StaffScheduleSelectOption) => { setSelectedScheduleId(selectedItem.scheduleId || null); setSelectedPractitionerId(selectedItem.practitionerId || null); if (selectedItem.scheduleId || selectedItem.practitionerId) { setStaffSearchQuery(''); } setShowManageModal(false); setLocalError(undefined); }; const handleStaffSearchChange = (nextQuery: string) => { setStaffSearchQuery(nextQuery); }; const handleStaffSearchSelect = (option: StaffSearchOption) => { if (option.value === ALL_STAFF_KEY) { handleStaffSelection(allStaffOption); setStaffSearchQuery(''); return; } if (!option.scheduleId && !option.practitionerId) { return; } setSelectedScheduleId(option.scheduleId || null); setSelectedPractitionerId(option.practitionerId || null); setStaffSearchQuery(''); setShowManageModal(false); setLocalError(undefined); }; const handleViewModeChange = (nextViewMode: ScheduleViewMode) => { if (range === 'year' && nextViewMode === 'calendar') { return; } setViewMode(nextViewMode); }; const handleRangeChange = (nextRange: ScheduleRange) => { rangeChosen.current = true; setRange(nextRange); if (nextRange === 'year') { setViewMode('list'); } }; const goToPreviousRange = () => calendarRef.current?.getApi().prev(); const goToNextRange = () => calendarRef.current?.getApi().next(); const goToToday = () => calendarRef.current?.getApi().today(); const handleEventClick = (eventInfo: EventClickArg) => { const scheduleId = (eventInfo.event.extendedProps as StaffScheduleEventProps).scheduleId; if (scheduleId) { setSelectedScheduleId(scheduleId); const record = staffScheduleRecords.find((r) => r.schedule.id === scheduleId); setSelectedPractitionerId(record?.practitioner?.practitioner.id ?? null); } }; // YOUR OWN HOURS OPEN ON THE WEEK, as the practitioner deck draws them ("A week of your own hours"). The scope // arrives with the schedules, after the first render, so this follows it rather than seeding the state. useEffect(() => { if (!onlyOwnSchedules || drill || rangeChosen.current) return; setRange('week'); }, [onlyOwnSchedules, drill]); // The week your own hours are drawn as: its Monday, while the range is a week of your own schedules. const ownWeek = onlyOwnSchedules && range === 'week' && visibleRange ? visibleRange.start : undefined; const titleBlock = (
{ownWeek ? `Week of ${ownWeek.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}` : calendarTitle} {/* The calendar draws coverage, not bookable slots, and says so where the count would be. */} {ownWeek ? 'Mon – Sun' : viewMode === 'calendar' ? 'Staff coverage · not appointment slots' : loading ? '\u00a0' : schedulesUnknown ? 'Not loaded' : scheduleEntriesLabel(scheduleEvents.length)}
); const prevButton = ( ); const nextButton = ( ); const renderEventContent = (eventInfo: EventContentArg) => { const eventProps = eventInfo.event.extendedProps as StaffScheduleEventProps; const isListView = eventInfo.view.type.startsWith('list'); return (
{!isListView && eventInfo.timeText && ( {eventInfo.timeText} )} {/* At own scope the reader is the selection: a row leads with where, not with their name. */}
{onlyOwnSchedules ? eventProps.locationLabel : eventProps.staffName} {/* Your own hours end in the deck's pill: an open block is Bookable. */} {onlyOwnSchedules ? ( {eventProps.status === 'scheduled' ? 'Bookable' : eventProps.statusLabel} ) : ( {eventProps.statusLabel} )}
{!onlyOwnSchedules && ( <> {eventProps.locationLabel} )} {eventProps.servicesLabel} {eventProps.description && ( <> {eventProps.description} )}
); }; // Every sentence this page says about absence comes from here, so "never assert a practice-wide // absence to a narrowed reader" is one checkable rule rather than three ternaries to keep in step. const scopeCopy = schedulesScopeCopy(onlyOwnSchedules, maySeeSchedules); // A practice-wide empty day names itself; a narrowed reader or one filtered to a person keeps the // scope copy, which must never claim the practice has nobody. const emptyDay = range === 'day' && visibleRange && scopeCopy.noEvents === PRACTICE_WIDE_EMPTY_SENTENCE && !selectedPractitionerId && !selectedScheduleId ? () => ( {emptyDaySentence(visibleRange.start)} {EMPTY_DAY_HINT} ) : undefined; return (
Staff Schedules {/* A narrowed reader is not promised the whole practice. */} {scopeCopy.subtitle}
{/* At manage-your-own-only there is no picker, so the reader's own door sits here. A selected colleague's sits beside the picker, as the frame shows. */} {manageTarget?.isOwn && canManageSchedules && ( )}
{/* A limit, not an empty practice: shown whenever the view is narrowed. */} {scopeCopy.showNotice && (
)} {/* One picker, withdrawn at manage-your-own-only: every choice in it would be the reader or somebody they may not touch. "All staff" clears the focus, keeping date and view. */} {!onlyOwnSchedules && (
handleStaffSearchSelect(option as StaffSearchOption)} />
{!onlyOwnSchedules && (selectedScheduleId || selectedPractitionerId) && ( )} {manageTarget && !manageTarget.isOwn && canManageSchedules && ( )}
)} {schedulesError && ( <> {schedulesError} )} {/* 🪞 CARD 480 — A PERMISSION LIMIT IS NOT AN ERROR, AND THIS PAGE HAD ALREADY EXPLAINED IT. A narrowed reader saw the scope notice twice, gracefully — *"Your role can see only your own schedules"*, and why, and who can change it — and then this alert in red about the SAME denial: *"Failed to load practitioners. You do not have access to this information."* 🔴 One screen saying both *"this is how it works"* and *"something went wrong."* A staff member reads the third one as breakage, reports it, or waits for it to come back. ⭐ So a denial the page ALREADY ANTICIPATED is absorbed into the explanation it already has. ⛔ AND ONLY THAT ONE. Every other failure still renders — swallowing the fetch error wholesale would turn a real outage into a silent empty list, which is the empty-state-that-was-never-asked class this project has already been bitten by. The condition is narrowed-reader AND a 401/403, carried as a FACT from the hook rather than read back out of the message ⇒ never branch on the server's English. */} {practitionersError && !(onlyOwnSchedules && practitionersWithheld) && ( <> {practitionersError} )} {localError && ( <> setLocalError(undefined)}> {localError} )}
{/* Your own week, as the deck draws it: its arrows either side of the title, and no Today. */} {onlyOwnSchedules ? (
{prevButton} {titleBlock} {nextButton}
) : ( <>
{prevButton} {nextButton}
{titleBlock} )}
{onlyOwnSchedules ? null : (
)}
{(onlyOwnSchedules ? OWN_RANGES : ALL_RANGES).map((rangeOption) => ( ))}
{scopeCopy.listHint && (

{scopeCopy.listHint}

)} {/* Gated by the host screen — reaching /dashboard/schedules already required the schedules VIEW predicate. canManage only WORDS the fix guidance, it never decides whether this renders. */} {/* Only while the selected schedule still exists: after "Delete all" the id outlived the schedule and the panel kept reporting on nothing. */} {/* NOT AT THE FRONT DESK: receptionists can book anything, so a patient self-booking diagnosis here would read as a gate on them (RULINGS, receptionist schedules). */} {selectedScheduleId && selectedRecord && !loading && activeWorkspace !== USER_ROLES.RECEPTIONIST && (
{serviceBasis.state !== 'complete' && (
{serviceBasis.state === 'checking' ? 'Checking available services' : 'Available services could not be verified.'} {serviceBasis.state === 'unavailable' && }
)} {serviceBasis.state === 'complete' && canManage(section)} />}
)}
{/* THE CALENDAR STAYS MOUNTED WHILE IT LOADS. Unmounting it for the spinner rebuilt it at today's list on every reload, while the toggles still said Week or Calendar and the date the person had chosen was gone. */} {loading && (
)}
{canManageSchedules && showManageModal && manageTarget && ( setShowManageModal(false)} staffName={selectedStaffName} practitioner={manageTarget.practitioner} initialRecords={manageInitialRecords} practitionerRoles={manageTarget.roles} /> )}
); }; export default SchedulesPage;