import { useEffect, useState } from 'react'; import { FormTokenField } from "../../../../components/ui/form-token-field"; import { SelectControl } from "../../../../components/ui/select-control"; import { TextControl } from "../../../../components/ui/text-control"; import { Divider } from '../../../../components/ui/divider'; import { DismissibleAlert } from '../../../../components/ui/dismissible-alert'; import { Heading } from '../../../../components/ui/heading'; import { Flex, FlexItem, FlexBlock } from '../../../../components/ui/flex'; import { Text } from '../../../../components/ui/text'; import { Spacer } from '../../../../components/ui/spacer'; import { Modal } from '../../../../components/ui/wp-modal-shim'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../../../components/ui/dialog'; import { Check, ChevronDown, ChevronUp, Plus, Trash2, TriangleAlert } from 'lucide-react'; import { Controller, FormProvider, useFieldArray, useForm, useWatch } from 'react-hook-form'; import { DEFAULT_SLOT_INCREMENT_MINUTES, SCHEDULE_SLOT_INCREMENTS, SCHEDULE_SLOT_INCREMENT_EXTENSION_URL, getScheduleSlotIncrement, } from '../../../../config/scheduleSlotIncrement'; import type { HealthcareService, Location, Practitioner, PractitionerRole, Reference, Schedule } from 'fhir/r4'; import { readBlockSelfBookingChoices, resolveServices, toServiceActors, toServiceTypeConcepts, } from '../../../../utils/scheduleServiceActors'; import { choiceToOverride, type SelfBookableChoice } from '../../../../config/serviceSelfBookable'; import { FhirOperationalHours, FhirOperationalHoursNotAvailable, FhirPeriod } from '../../../../components'; import { describeBlockServiceScope } from '../../../../utils/booking/blockServiceScope'; import { shouldWarnBeforeSave } from '../../../../utils/booking/scheduleFormBookability'; import { isClinicianEnabled } from '../../../../utils/clinicianEnabled'; import type { BookabilityBlocker } from '../../../../utils/booking/bookabilityReasons'; import UnbookableSaveDialog from '../../../../components/booking/UnbookableSaveDialog'; import { useAuthServiceContext, useFhirServiceContext, usePractitionerRoleServiceContext, useScheduleServiceContext, } from '../../../../services'; import { SCHEDULE_ALLOW_BOOKING_EXTENSION_URL, SCHEDULE_ALLOW_PATIENT_SELF_BOOKING_EXTENSION_URL, SCHEDULE_ALLOW_NEW_PATIENT_GENERAL_EXAM_EXTENSION_URL, SCHEDULE_AVAILABLE_TIME_EXTENSION_URL, SCHEDULE_NOT_AVAILABLE_EXTENSION_URL, SCHEDULE_BREAK_EXTENSION_URL, buildScheduleAvailabilityExtensions, buildScheduleBreakExtensions, getScheduleAvailability, getScheduleBreaks, getScheduleBookingFlags, getScheduleLocationReference, } from '../../../../services/ScheduleService.tsx'; import { resolveOrgAppointmentPolicy } from '../../../../config/appointmentPolicy'; import { holdsClinicalRole } from '../../../../utils/staffRoleCatalog'; import { onlySites, onlyVirtual } from '../../../../utils/roomLocation'; import { buildScheduleLocationActors, scheduleOffersVideo } from '../../../../utils/scheduleTelehealth'; // The seeding rule - which site the picker opens on - lives beside the label reader, because both // answer questions about a block's locations that the actor order alone gets wrong. import { getScheduleLocationIds, seedScheduleLocationId } from '../../../../utils/scheduleLocations'; import { findOccurrenceOverlap } from '../../../../utils/scheduleZoneAlign'; import { resolveBookingTimeZone } from '../../../../utils/practiceTimezone'; import { Video } from 'lucide-react'; import { Checkbox } from '../../../../components/ui/checkbox.tsx'; import { Label } from '../../../../components/ui/label.tsx'; import { useFhirLocations } from '../../../../hooks/useFhirLocations.tsx'; import { useFhirHealthcareServices } from '../../../../hooks'; import type { StaffScheduleRecord } from '../../../../utils/staffScheduleRecord'; import './ManageScheduleModal.css'; // - WordPress's ToggleControl, swapped for the ui/* adapter. Same props, same JSX; // only `__nextHasNoMarginBottom` is dropped, which means nothing outside WordPress. import { ToggleControl } from '../../../../components/ui/toggle-control'; import { describeWriteFailure } from '../../../../utils/fetchFailureMessage'; import { scheduleNoticeSentence } from '../../../../utils/scheduleNotice'; import { upsertById } from '../../../../utils/upsertById'; import { seedDefaultWorkweek } from '../../../../utils/scheduleDefaultWorkweek'; /** * WHAT A SEATLESS PRACTITIONER'S SCHEDULE BLOCK SAYS INSTEAD OF HIDING TWO CONTROLS. * * The wording follows the staff record's Services tab, which is where this refusal is drawn, so the same * person meets the same sentence in both places rather than two descriptions of one rule. It names the * PERSON rather than the screen - whoever is reading it is configuring somebody else's schedule - says * what a service is, because nothing else here explains why a booking concept refuses a schedule edit, * and says who can lift it. * * The last clause is not decoration. Without it an administrator setting a practice up before buying * seats has no way to know the work is kept, and the rational move is to stop configuring. */ const SEATLESS_BLOCK_REFUSAL_HEADLINE = (name: string): string => `${name} does not hold an active clinician seat, so this block cannot offer services or be booked by patients`; const SEATLESS_BLOCK_REFUSAL_BODY = 'A service is something a patient can book, and only a seated clinician can be booked. An ' + 'administrator can grant a seat on their staff record, Workspaces tab, under Clinician seat, and ' + 'anything already set here takes effect as soon as one is granted.'; // Compact services summary for a saved block's collapsed header - the names of the HealthcareServices the block // offers (its Schedule.actor HealthcareService references). const getScheduleServicesLabel = (schedule?: Schedule): string => (schedule?.actor ?? []) .filter((ref) => ref.reference?.startsWith('HealthcareService/')) .map((ref) => ref.display) .filter((display): display is string => Boolean(display)) .join(', ') || 'No services set'; // Build a FHIR Location reference for a selected location id, stored on PractitionerRole.location. // Empty id = "All locations" (org-wide) returns undefined (the role carries no location). const buildLocationRef = ( locationId: string | undefined, locations: Location[], ): Reference | undefined => { if (!locationId) return undefined; const location = locations.find((loc) => loc.id === locationId); if (!location) return undefined; return { reference: `Location/${location.id}`, display: location.name }; }; // helpers const formatDate = (dateStr?: string): string => { if (!dateStr) return '?'; const date = /^\d{4}-\d{2}-\d{2}$/.test(dateStr) ? (() => { const [y, m, d] = dateStr.split('-').map(Number); return new Date(y, m - 1, d); })() : new Date(dateStr); return isNaN(date.getTime()) ? '?' : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); }; const formatTimeStr = (t?: string): string => { if (!t) return ''; const [h, m] = t.split(':').map(Number); const period = h >= 12 ? 'PM' : 'AM'; return `${h % 12 || 12}:${String(m).padStart(2, '0')} ${period}`; }; const getAvailabilitySummary = (role?: PractitionerRole): string => { const available = (role?.availableTime ?? []).filter( (t) => t.allDay || (t.availableStartTime && t.availableEndTime), ); if (!available.length) return 'No availability set'; const dayMap: Record = { sun: 'Sun', mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat', }; // One entry per day with its own time range, e.g. "Mon 9:00 AM-5:00 PM, Wed 9:00 AM-5:00 PM". const parts: string[] = []; for (const t of available) { const timeRange = t.allDay ? 'All day' : `${formatTimeStr(t.availableStartTime)}–${formatTimeStr(t.availableEndTime)}`; for (const day of t.daysOfWeek ?? []) { parts.push(`${dayMap[day] ?? day} ${timeRange}`); } } return parts.length ? parts.join(', ') : 'No availability set'; }; // day/time overlap (for block conflicts) type DaySlot = { day: string; startMin: number; endMin: number }; const DAY_LABELS: Record = { sun: 'Sunday', mon: 'Monday', tue: 'Tuesday', wed: 'Wednesday', thu: 'Thursday', fri: 'Friday', sat: 'Saturday', }; const parseTimeToMinutes = (time?: string): number => { if (!time) return 0; const [h, m] = time.split(':').map(Number); return (h || 0) * 60 + (m || 0); }; const minutesLabel = (minutes: number): string => { const h = Math.floor(minutes / 60); const m = minutes % 60; const period = h >= 12 ? 'PM' : 'AM'; return `${h % 12 || 12}:${String(m).padStart(2, '0')} ${period}`; }; // Expand FHIR availableTime into per-day time slots (minutes since midnight) for conflict checks. const toDaySlots = (availableTime: PractitionerRole['availableTime']): DaySlot[] => { const slots: DaySlot[] = []; for (const entry of availableTime ?? []) { const startMin = entry.allDay ? 0 : parseTimeToMinutes(entry.availableStartTime); const endMin = entry.allDay ? 24 * 60 : parseTimeToMinutes(entry.availableEndTime); if (endMin <= startMin) continue; // skip empty/degenerate ranges for (const day of entry.daysOfWeek ?? []) { slots.push({ day, startMin, endMin }); } } return slots; }; const slotTimeLabel = (slot: DaySlot): string => slot.startMin === 0 && slot.endMin === 24 * 60 ? 'All day' : `${minutesLabel(slot.startMin)}–${minutesLabel(slot.endMin)}`; // The block form's per-service choices as the overrides its service actors carry. Default is undefined: // no extension, so the practitioner and org levels decide. const blockSelfBookingOverrides = (formData: any): Record => Object.fromEntries( Object.entries((formData?.serviceSelfBooking ?? {}) as Record) .map(([id, choice]) => [id, choiceToOverride(choice)]), ); // PeriodBlock interface PeriodBlockProps { schedule?: Schedule; role?: PractitionerRole; locations: Location[]; healthcareServices: HealthcareService[]; defaultValues: any; isExpanded: boolean; isNew: boolean; onToggleExpand: () => void; onSave: (formData: any) => Promise; /** * THE REFUSALS, ASKED BEFORE THE ADVISORY RATHER THAN INSIDE THE WRITE. * * The same checks `onSave` already runs before it stores anything, exposed so this component can * ask them first. It is an addition, never a replacement: the save path keeps its own gate, so a * degenerate day still cannot reach storage through a caller that skips this. * * Returns the message to show, or null when there is nothing wrong. */ onValidate?: (formData: any) => string | null; onDelete: () => void; isSaving: boolean; // - the org's virtual (telehealth) Location, owned by ManageScheduleModal and passed DOWN. // It must be a prop: this component is declared above the modal, so reading the modal's local of the // same name is a free identifier that throws at render (which is exactly what it did). // Optional because an org that has created no virtual Location is not offered the video opt-in. virtualLocation?: Location; } // Exported for the regression test that renders it EXPANDED - the state in which it used to throw. export const PeriodBlock = ({ schedule, role, locations, healthcareServices, defaultValues, isExpanded, isNew, onToggleExpand, onSave, onValidate, onDelete, isSaving, virtualLocation, }: PeriodBlockProps) => { const [blockError, setBlockError] = useState(null); const [pendingSave, setPendingSave] = useState<{ formData: any; blockers: BookabilityBlocker[] } | null>(null); const methods = useForm({ defaultValues }); const { control } = methods; const { wpOrganization } = useAuthServiceContext(); // The outermost of telehealth's three gates, which this editor never consulted. It defaults to false, // so a practice that has never opened Appointment policies is not offering video visits. const orgTelehealthEnabled = resolveOrgAppointmentPolicy( wpOrganization.current ?? undefined, ).telehealthEnabled; /** * Does this schedule's owner hold a clinician seat? * * Spencer, 2026-08-27: "A schedule for a non-practitioner user shouldn't have practitioner based * options like booking options and services offered." * * The booking pickers now refuse a seatless person, and that stops the SYMPTOM. This stops the * CAUSE: without it a receptionist's schedule can still be given clinical services and patient * self-booking, and the pickers quietly filter that data out afterwards. Data that should not * exist is worse than data that is merely hidden - it reads as configured, it is what somebody * will point at when asking why booking does not work, and every new surface has to remember to * filter it. * * WHAT STAYS FOR EVERYBODY, because a front desk legitimately holds appointments: the schedule * itself, its hours, its location, and "Allow booking". Only the practitioner-shaped options go. */ const ownerHoldsClinicianSeat = isClinicianEnabled(role); /** * AND THE TWO RULINGS ABOVE AND BELOW ARE DIFFERENT RULINGS, which is the whole reason this is two * questions rather than one. * * Spencer's 2026-08-27 ruling is about a NON-PRACTITIONER: a receptionist's schedule should not carry * practitioner-shaped options at all, and "receptionist would never have a clinician seat". That * still holds and those controls stay absent for them - offering a seat refusal to somebody who can * never be granted one is worse than silence, because the copy tells them to go and do something * impossible. * * The 2026-09-06 ruling is about a PRACTITIONER WHO HAS NO SEAT YET, which is the state the deck * calls self-concealing: the controls vanish, nothing says why, and the person looking at it cannot * tell a gate from a bug. For them the control stays and refuses in words. * * PRESENCE is the workspace question; ENABLED is the seat question. Collapsing them back into one * flag reinstates whichever ruling is dropped. * * A SEAT ALONE IS ENOUGH TO SHOW THEM, and the `||` is not belt-and-braces. The seat can only be * granted alongside the Practitioner workspace, so the two normally travel together - but the * workspace lives in an extension that older roles do not carry, and `getPractitionerRoleWorkspaces` * has no fallback to reach for here. Asking the workspace alone would take these controls away from a * clinician whose role holds the seat and not the extension: a paying customer losing the thing they * are paying for, over a field that is only sometimes written. The seat is the fact being enforced, * so anyone who holds it sees the controls whatever else is missing. */ const ownerDoesClinicalWork = ownerHoldsClinicianSeat || holdsClinicalRole(role ? [role] : []); const seatlessClinician = ownerDoesClinicalWork && !ownerHoldsClinicianSeat; const ownerName = role?.practitioner?.display?.trim() || 'This staff member'; const hoursFieldArray = useFieldArray({ control, name: 'availableTime' }); const notAvailableFieldArray = useFieldArray({ control, name: 'notAvailable' }); const breaksFieldArray = useFieldArray({ control, name: 'breaks' }); // No watch defaultValue: it would override the saved block's own values until a field is edited. const allowBookingValue = useWatch({ control, name: 'allowBooking' }) ?? true; const allowPatientSelfBookingValue = useWatch({ control, name: 'allowPatientSelfBooking' }) ?? true; const locationValue = (useWatch({ control, name: 'location' }) ?? '') as string; const persist = async (formData: any) => { try { setBlockError(null); const err = await onSave(formData); if (err) setBlockError(err); } catch (e: any) { setBlockError(describeWriteFailure(e, 'Save failed')); } }; // Say what saving this will mean BEFORE walking away from it - the moment attention is highest. This // never blocks the save; a practice mid-setup legitimately saves a half-configured block. const handleSave = async () => { const formData = methods.getValues(); /* * A REFUSAL OUTRANKS AN ADVISORY, AND THE ORDER USED TO BE THE OTHER WAY ROUND. * * The bookability warning below never blocks anything - it states a consequence and offers * "Save anyway". The checks here are the opposite: a day that opens and closes at the same * time, or hours that put a practitioner in two places at once, are refused outright. * * Asking the advisory first meant it returned before the refusal was ever evaluated, so a * block that was BOTH unbookable and invalid reported only that patients could not book it - * and the reason it would not save was never mentioned until the person pressed "Save anyway". * The less important of the two messages was in front of the more important one. * * It also matters when the advisory dialog itself does not reach the screen: this path sets * the inline alert directly, which renders beside the button that was pressed. */ const refusal = onValidate?.(formData) ?? null; if (refusal) { setBlockError(refusal); return; } const warnings = shouldWarnBeforeSave(formData); if (warnings.length > 0) { setPendingSave({ formData, blockers: warnings }); return; } await persist(formData); }; const horizonStart = schedule?.planningHorizon?.start; const horizonEnd = schedule?.planningHorizon?.end; const summary = isNew ? 'New period' : `${formatDate(horizonStart)} – ${formatDate(horizonEnd)}`; // Availability + location live on the Schedule now; fall back to the role for blocks saved before the move. const scheduleAvailability = getScheduleAvailability(schedule); const summaryAvailableTime = scheduleAvailability.availableTime.length ? scheduleAvailability.availableTime : (role?.availableTime ?? []); const availSummary = isNew ? '' : getAvailabilitySummary({ availableTime: summaryAvailableTime } as PractitionerRole); const headerLocationLabel = getScheduleLocationReference(schedule)?.display?.trim() || role?.location?.[0]?.display?.trim() || 'All locations'; return (
e.key === 'Enter' && onToggleExpand()} > {isNew ? ( New period ) : (
{headerLocationLabel} {summary} {availSummary}
)}
{/* No onClick by design — the whole header row is the click target; this is the affordance for it, so the click bubbles up rather than being handled here. */}
{isExpanded && (
Location ( /* THE GROUP HEADING ABOVE ALREADY SAYS `Location`, AND THIS SAID IT AGAIN ON THE VERY NEXT LINE. The two sibling groups show the pattern this modal actually follows: the heading names the topic and the field describes itself, so `Planning Horizon` carries `Period of time covered by schedule` and `Booking` carries `Allow booking`. The sentence describing this field was already written - it was sitting at the front of the help text - so it moves up, and the help keeps only the part a reader cannot infer. */ ({ label: loc.name ?? loc.id ?? 'Unnamed location', value: loc.id ?? '', })), ]} onChange={field.onChange} /> )} /> Planning Horizon Booking ( )} /> {/* HOW FAR APART THE BOOKABLE START TIMES ARE - the grid a patient picks from, and NOT the length of the visit. The length comes from the service, so a 40-minute service on a 15-minute grid may begin at 9:00, 9:15 or 9:30 and runs 40 minutes from wherever it began. ⚠️ Until this existed the patient's grid was a hand-typed table of half-hours, so every practice was offered thirty whatever it had chosen - and thirty is not even the stated default. Ruled 2026-09-06: 10, 15, 20 or 30, and the practice default is 15. */} ( ({ label: `${minutes} minutes`, value: String(minutes), }))} onChange={(value: string) => field.onChange(Number(value))} /> )} /> {/* PRACTITIONER-SHAPED, so it needs the seat. A patient self-books a clinical visit; somebody without a clinician seat cannot perform one, and offering the toggle invites a practice to configure a booking that every picker will then refuse. */} {seatlessClinician && ( <> {SEATLESS_BLOCK_REFUSAL_HEADLINE(ownerName)} {'\n'} {SEATLESS_BLOCK_REFUSAL_BODY} )} {ownerDoesClinicalWork && ( ( )} /> )} {ownerDoesClinicalWork && ( <> ( )} /> )} {/* Card 113 — opt this block into video. Offered only when the org has actually created a virtual Location: with none there is nothing to reference, so an enabled control here would write a dangling actor or, worse, read as "offers video" when it cannot. Radix rather than the WordPress ToggleControl above it: this is NEW surface, and the standing rule is zero new WordPress. Its neighbours are deliberately left alone — converting them would balloon this change well past its purpose in a shared 1000-line file. */} {virtualLocation && ( <> (
field.onChange(checked === true) } disabled={!allowBookingValue} />
{/* The shared-slot-pool guarantee, said plainly — it is the single most surprising thing about this model and the reason it is a checkbox on the existing block rather than a second block. */} {/* WHICH OF THE TWO SHAPES THIS IS, SAID AS THE PRACTICE MAKES IT. Spencer ruled on 2026-09-14 that a block offering video with no site named is a VIDEO-ONLY block, and that this shape stays expressible - a clinician working a Friday from home is nowhere in particular. A block that is both in person and by video names its site. Before this, one sentence described both, and the one it described was the wrong one: "booking one by video uses up that time in person too" says nothing at all about a block with no in person to use up, and the practitioner found out by quietly not being bookable anywhere. */} {locationValue ? (

Patients can book these same times as a video visit. It’s the one set of appointment slots — booking one by video uses up that time in person too, so this block can’t be double-booked.

) : (

This block names no site, so it is a video-only block — these times are offered by video and not in person. To offer both, choose the site this pattern happens at.

)} {/* Ticking this saves and persists, but every booking surface hides the option until the practice turns video visits on - so say so, and say where. The saved flag is untouched, so enabling the policy makes it live without re-ticking anything. */} {!orgTelehealthEnabled && (

This practice is not offering video visits yet, so this setting will have no effect. An administrator can turn them on under Organization, Appointment policies.

)}
)} /> )} {/* SERVICES ARE THE CLINICAL HALF OF A SCHEDULE, so they need the seat. Spencer, 2026-08-27: "A schedule for a non-practitioner user shouldn't have practitioner based options like booking options and services offered." Attaching a clinical service to a receptionist's schedule is exactly what made one bookable for a Consultation. The pickers refuse that person now, which stops the symptom - this stops the cause, so the data is never created and no future surface has to remember to filter it out. */} {ownerDoesClinicalWork && ( <> Services offered here {/* Repeated rather than relying on the banner far above it. This dialog is long enough to scroll, and a reader who arrives at a dead picker with the explanation off-screen is back to guessing whether it is a gate or a bug - which is the defect this change exists to end. Same constant, so the two cannot drift apart. */} {seatlessClinician && ( <> {SEATLESS_BLOCK_REFUSAL_BODY} )} { // Candidate services are scoped to what THIS practitioner offers // (PractitionerRole.healthcareService). Fall back to the full org catalog when the // practitioner has no offered services set, so pre-migration schedules don't break. const offeredIds = new Set( (role?.healthcareService ?? []) .map((ref) => ref.reference?.split('/').pop()) .filter((id): id is string => Boolean(id)), ); const scopedServices = offeredIds.size ? healthcareServices.filter((service) => (service.id ? offeredIds.has(service.id) : false)) : healthcareServices; // Services eligible for this block: offered at all locations, or at the block's // selected location. An "All locations" block (no location) shows every service. const eligible = scopedServices.filter((service) => { const locs = service.location ?? []; if (!locationValue || locs.length === 0) return true; return locs.some((loc) => loc.reference?.split('/').pop() === locationValue); }); const nameById = new Map( healthcareServices.map((service) => [service.id, service.name ?? (service.id as string)]), ); const idByName = new Map( healthcareServices.map((service) => [service.name ?? (service.id as string), service.id]), ); // Say out loud what this picker is choosing FROM, and - - what // leaving it EMPTY costs. An empty offered-set silently scopes the block to // the entire org catalog (deliberate, so a small practice can skip the step); // an empty block list silently makes the provider un-bookable by patients. const scope = describeBlockServiceScope({ offeredCount: offeredIds.size, eligibleCount: eligible.length, orgCatalogSize: healthcareServices.length, selectedCount: ((field.value ?? []) as string[]).length, }); return ( <>

{/* Card 285 — an icon, never a literal `⚠`: WordPress core's emoji script rewrites that character into a 120px fetched from s.w.org, after we render. */} {scope.warn && ( )} {scope.message}

idByName.has(input)} disabled={!ownerHoldsClinicianSeat} label="Services offered at this location" value={((field.value ?? []) as string[]) .map((id) => nameById.get(id)) .filter((name): name is string => Boolean(name))} suggestions={eligible.map((service) => service.name ?? (service.id as string))} onChange={(tokens) => { const ids = tokens .map((token) => typeof token === 'string' ? token : token.value, ) .map((name) => idByName.get(name)) .filter((id): id is string => Boolean(id)); field.onChange(Array.from(new Set(ids))); }} /> {/* THE BLOCK'S OWN SELF-BOOKING CHOICE, per service. Booking reads it before the practitioner and org levels, and the block PUT carries it on each service actor. */} {((field.value ?? []) as string[]).length > 0 && ( (
{((field.value ?? []) as string[]).map((id) => ( ))}

"Default" follows this practitioner's Services Offered setting; "Enabled" / "Disabled" override it for this block only.

)} /> )} ); }} /> )} Recurring breaks Daily breaks (e.g. lunch) applied to every working day in this block. Break time is not bookable and shows as “On break”. {breaksFieldArray.fields.map((breakField, index) => (
( )} />
( )} />
( )} />
))} {blockError && ( <> setBlockError(null)}> {blockError} )} { const pending = pendingSave; setPendingSave(null); if (pending) await persist(pending.formData); }} onKeepEditing={() => setPendingSave(null)} /> {/* This Cancel is KEPT. It is not a modal Cancel — it collapses the period block via onToggleExpand, and there is no X that does the same thing, so the redundancy rule does not apply. */}
)}
); }; // ManageScheduleModal interface ManageScheduleModalProps { isOpen: boolean; onRequestClose: () => void; staffName: string; practitioner: Practitioner; initialRecords: StaffScheduleRecord[]; // The practitioner's existing PractitionerRole(s), passed so a FIRST schedule can be attached to their // existing role even when they have no schedule (and therefore no record) to derive a role from yet. // Scheduling never creates a role - this only ever references one that already exists. practitionerRoles?: PractitionerRole[]; } const ManageScheduleModal = ({ isOpen, onRequestClose, staffName, practitioner, initialRecords, practitionerRoles, }: ManageScheduleModalProps) => { const { createSchedule, updateSchedule, deleteSchedule, setSchedulesList, transformToFhirSchedule, } = useScheduleServiceContext(); const { transformFromFhirPractitionerRole, } = usePractitionerRoleServiceContext(); const { transformToOperationalHours, transformToOperationalHoursNotAvailable, transformFromOperationalHours, transformFromOperationalHoursNotAvailable, addFhirPeriodIfApplicable, } = useFhirServiceContext(); const { locationsList, loadLocations } = useFhirLocations(); // A location saved elsewhere since the list was cached (a telehealth location, say) is offered here. useEffect(() => { void loadLocations(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const { healthcareServicesList } = useFhirHealthcareServices(); // - the org's virtual (telehealth) Location, if it has created one. An org with none has // nothing to opt a block into, so the video control is not offered at all rather than shown broken. // `onlyVirtual` exists because `onlySites` deliberately EXCLUDES virtual from every site picker. const virtualLocation = onlyVirtual(locationsList)[0]; const { wpOrganization: conflictOrganization } = useAuthServiceContext(); // A block's hours are in its site's zone, or the practice's when it names no site. const blockZone = (siteId?: string) => resolveBookingTimeZone({ site: siteId ? onlySites(locationsList).find((loc) => loc.id === siteId) : undefined, organization: conflictOrganization.current ?? undefined, }); const [localRecords, setLocalRecords] = useState(initialRecords); // The first period opens with the modal, as the deck draws it. const [expandedId, setExpandedId] = useState(initialRecords[0]?.schedule.id ?? null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [savingId, setSavingId] = useState(null); /** * What to tell the administrator when the PROVIDER could not be told. * * Separate from the error channel on purpose: the save succeeded, and reporting this as a failure * would send somebody to re-do a change that is already stored. `undefined` is the ordinary outcome * and renders nothing. */ const [notice, setNotice] = useState(undefined); // A practitioner with no schedule yet has no records to derive a form from - open the new-block form by // default so they can create their first one immediately. const [showNewBlock, setShowNewBlock] = useState(initialRecords.length === 0); const [globalError, setGlobalError] = useState(null); // Existing records carry the role a saved block was attached to; when there are none yet (first schedule), // fall back to the practitioner's existing role. Never mints a role - only references one already on file. const primaryRole = localRecords.find((r) => r.role)?.role ?? practitionerRoles?.find((role) => role.active !== false) ?? practitionerRoles?.[0]; const primaryTransformed = primaryRole ? transformFromFhirPractitionerRole(primaryRole) : null; const newBlockDefaults = { planningHorizon: { start: '', end: '' }, availabilityExceptions: primaryTransformed?.availabilityExceptions ?? '', availableTime: seedDefaultWorkweek(primaryTransformed?.availableTime ?? []), notAvailable: [], breaks: [], allowBooking: true, allowPatientSelfBooking: true, allowNewPatientGeneralExam: false, // - a NEW block does not offer video until someone says so. Same fail-closed posture as // the org toggle and the service gate. offerByVideo: false, location: '', serviceIds: [] as string[], serviceSelfBooking: {} as Record, // The practice default, stated by the ruling. A new block starts where the product says, // not where the old hand-typed grid happened to sit. slotIncrementMinutes: DEFAULT_SLOT_INCREMENT_MINUTES, }; /** * A DAY THAT ENDS WHEN IT STARTS IS NOT A WORKING DAY, and it must not be saved. * * MEASURED ON STAGING: toggling Wednesday on defaulted it to 09:00 open and 09:00 close, and * nothing anywhere flagged the pair. What it produced downstream was worse than nothing - the * schedules calendar wrapped it to the next day and drew **a Wednesday running to midnight plus a * Thursday nobody had turned on**, while this dialog's own summary still said Wednesday only. * * THIS CHECK DOES NOT WAIT ON THE OVERNIGHT QUESTION, and that is deliberate. Whether the product * supports a block crossing midnight (it does - see the booking side) has no bearing on whether a * ZERO-LENGTH day is meaningful. It is not, either way. * * It refuses rather than repairing. Guessing the hours somebody meant - copying the day beside it, * say - would save something they never typed, on the one screen whose entire job is to record what * they did type. */ const checkDegenerateDay = (formData: any): string | null => { /* * IT READS THE RAW ENTRIES, NOT `toDaySlots`, and that is the whole reason this is written out * rather than reusing the helper beside it. `toDaySlots` opens with * `if (endMin <= startMin) continue; // skip empty/degenerate ranges` - so a degenerate day is * gone before any caller sees it. The first version of this check DID use it, type-checked * clean, and could never have fired: the one shape it exists to catch is the one the helper * removes. **A validator built on a filter that drops the invalid case validates nothing.** */ for (const entry of transformToOperationalHours(formData.availableTime) ?? []) { if (entry.allDay) continue; if (!entry.availableStartTime || !entry.availableEndTime) continue; if (entry.availableStartTime !== entry.availableEndTime) continue; const day = (entry.daysOfWeek ?? [])[0]; return `${DAY_LABELS[day] ?? day ?? 'That day'} opens and closes at the same time ` + `(${formatTimeStr(entry.availableStartTime)}). Give it real hours, or turn the day off.`; } return null; }; const checkConflict = (formData: any, excludeScheduleId?: string): string | null => { const newStart = formData.planningHorizon?.start; const newEnd = formData.planningHorizon?.end; if (!newStart || !newEnd) return null; // Overlapping DATE ranges are allowed - a practitioner can cover the same period at multiple // locations. The only real conflict is hours that both blocks actually run, on a date inside // both periods (that would put the practitioner in two places at once). const newSlots = toDaySlots(transformToOperationalHours(formData.availableTime)); if (newSlots.length === 0) return null; const next = { slots: newSlots, zone: blockZone(formData.location || undefined), start: newStart, end: newEnd }; for (const other of localRecords) { if (other.schedule.id === excludeScheduleId) continue; const horizon = other.schedule.planningHorizon; const otherSlots = toDaySlots(getScheduleAvailability(other.schedule).availableTime); const otherSite = getScheduleLocationIds(other.schedule).find((id) => id !== virtualLocation?.id); const slot = findOccurrenceOverlap(next, { slots: otherSlots, zone: blockZone(otherSite), start: horizon?.start, end: horizon?.end }); if (slot) { const otherLocationName = getScheduleLocationReference(other.schedule)?.display; const where = otherLocationName ? ` at ${otherLocationName}` : ''; return `This block's ${DAY_LABELS[slot.day] ?? slot.day} hours (${slotTimeLabel(slot)}) overlap another block${where} on the same day. Change the day or time before saving.`; } } return null; }; const handleSaveExisting = async (record: StaffScheduleRecord, formData: any): Promise => { if (!record.schedule.id) return 'Missing schedule ID.'; // Both save paths gate on this, so a degenerate day cannot reach storage through either one. const degenerate = checkDegenerateDay(formData); if (degenerate) return degenerate; const conflict = checkConflict(formData, record.schedule.id); if (conflict) return conflict; setSavingId(record.schedule.id); try { const existingExtensions = (record.schedule.extension ?? []).filter( (e) => e.url !== SCHEDULE_ALLOW_BOOKING_EXTENSION_URL && e.url !== SCHEDULE_ALLOW_PATIENT_SELF_BOOKING_EXTENSION_URL && e.url !== SCHEDULE_ALLOW_NEW_PATIENT_GENERAL_EXAM_EXTENSION_URL && e.url !== SCHEDULE_AVAILABLE_TIME_EXTENSION_URL && e.url !== SCHEDULE_NOT_AVAILABLE_EXTENSION_URL && e.url !== SCHEDULE_BREAK_EXTENSION_URL && e.url !== SCHEDULE_SLOT_INCREMENT_EXTENSION_URL, ); const selectedLocationRef = buildLocationRef(formData.location, onlySites(locationsList)); const selectedServices = resolveServices(formData.serviceIds, healthcareServicesList); const serviceTypeConcepts = toServiceTypeConcepts(selectedServices); const fhirAvailableTime = transformToOperationalHours(formData.availableTime); const fhirNotAvailable = transformToOperationalHoursNotAvailable(formData.notAvailable); const updatedSchedule: Schedule = { ...record.schedule, planningHorizon: addFhirPeriodIfApplicable({}, formData, 'planningHorizon').planningHorizon, // Schedule.actor now owns location + services: keep the identity Practitioner/PractitionerRole, // replace Location + HealthcareService refs. Location is native FHIR; the weekly hours ride in // the extensions below. No PractitionerRole is created or mutated by scheduling anymore. actor: [ ...(record.schedule.actor ?? []).filter( (ref) => !ref.reference?.startsWith('HealthcareService/') && !ref.reference?.startsWith('Location/'), ), //: the virtual Location rides on THIS schedule as a co-actor when the block // offers video - never as a second Schedule, which would fork the slot pool. ...buildScheduleLocationActors( selectedLocationRef, virtualLocation, formData.offerByVideo === true, ), ...toServiceActors(selectedServices, blockSelfBookingOverrides(formData)), ], serviceType: serviceTypeConcepts.length ? serviceTypeConcepts : undefined, extension: [ ...existingExtensions, { url: SCHEDULE_ALLOW_BOOKING_EXTENSION_URL, valueBoolean: formData.allowBooking !== false }, { url: SCHEDULE_ALLOW_PATIENT_SELF_BOOKING_EXTENSION_URL, valueBoolean: formData.allowPatientSelfBooking !== false }, { url: SCHEDULE_ALLOW_NEW_PATIENT_GENERAL_EXAM_EXTENSION_URL, valueBoolean: formData.allowNewPatientGeneralExam === true }, ...buildScheduleAvailabilityExtensions(fhirAvailableTime, fhirNotAvailable), ...buildScheduleBreakExtensions(formData.breaks), // THE DEFAULT IS STORED AS ABSENCE. A practice that never opened this setting and // one that opened it and chose 15 are the same practice; writing the value for // both would make the record claim a decision nobody made, which matters the day // the default moves. ...(Number(formData.slotIncrementMinutes) !== DEFAULT_SLOT_INCREMENT_MINUTES && SCHEDULE_SLOT_INCREMENTS.includes(Number(formData.slotIncrementMinutes) as never) ? [{ url: SCHEDULE_SLOT_INCREMENT_EXTENSION_URL, valueInteger: Number(formData.slotIncrementMinutes), }] : []), ], }; const { schedule: savedSchedule, noticeOutcome } = await updateSchedule(updatedSchedule); setSchedulesList((prev) => prev.map((s) => (s.id === savedSchedule.id ? savedSchedule : s)), ); setLocalRecords((prev) => prev.map((r) => r.schedule.id === savedSchedule.id ? { ...r, schedule: savedSchedule } : r, ), ); setExpandedId(null); // THE SAVE SUCCEEDED EITHER WAY, and that is why this is not returned as an error. Moving // somebody's hours emails them, and when that did not happen the administrator is the person // who can fix it - they are already on a staff screen and can add the address or pick up the // phone. Silence here is the common case: a provider who WAS told produces no sentence, // because a caveat on every save is a caveat nobody reads. setNotice(scheduleNoticeSentence(noticeOutcome)); return null; } catch (err: unknown) { // A refusal reads as a refusal: the raw message made a permanent 403 look like a blip. return describeWriteFailure(err, 'Failed to save period'); } finally { setSavingId(null); } }; const handleSaveNew = async (formData: any): Promise => { if (!formData.planningHorizon?.start || !formData.planningHorizon?.end) { return 'Planning horizon start and end dates are required.'; } const degenerate = checkDegenerateDay(formData); if (degenerate) return degenerate; const conflict = checkConflict(formData); if (conflict) return conflict; if (!primaryRole) { return 'This staff member has no role yet. Assign a role on the Roles tab first.'; } setSavingId('new'); try { const selectedLocationRef = buildLocationRef(formData.location, onlySites(locationsList)); const selectedServices = resolveServices(formData.serviceIds, healthcareServicesList); const fhirAvailableTime = transformToOperationalHours(formData.availableTime); const fhirNotAvailable = transformToOperationalHoursNotAvailable(formData.notAvailable); // Reference the practitioner's existing identity role - scheduling never creates a role. const scheduleFhir = transformToFhirSchedule( { planningHorizon: formData.planningHorizon, allowBooking: formData.allowBooking !== false, allowPatientSelfBooking: formData.allowPatientSelfBooking !== false, allowNewPatientGeneralExam: formData.allowNewPatientGeneralExam === true, }, practitioner, primaryRole, ); scheduleFhir.actor = [ ...(scheduleFhir.actor ?? []), // Same co-actor rule as the update path above - see buildScheduleLocationActors. ...buildScheduleLocationActors( selectedLocationRef, virtualLocation, formData.offerByVideo === true, ), ...toServiceActors(selectedServices, blockSelfBookingOverrides(formData)), ]; // Guarded on the CONCEPTS, not the services: a service saved with a name and no type has no // coding to derive, and an empty serviceType array is refused by the server (400), so the // first period for that service could never be created. The update path already guards this. const newServiceTypeConcepts = toServiceTypeConcepts(selectedServices); if (newServiceTypeConcepts.length) { scheduleFhir.serviceType = newServiceTypeConcepts; } scheduleFhir.extension = [ ...(scheduleFhir.extension ?? []), ...buildScheduleAvailabilityExtensions(fhirAvailableTime, fhirNotAvailable), ...buildScheduleBreakExtensions(formData.breaks), // Same rule as the update path: the default is stored as absence, so a new block // left at 15 carries no extension at all. ...(Number(formData.slotIncrementMinutes) !== DEFAULT_SLOT_INCREMENT_MINUTES && SCHEDULE_SLOT_INCREMENTS.includes(Number(formData.slotIncrementMinutes) as never) ? [{ url: SCHEDULE_SLOT_INCREMENT_EXTENSION_URL, valueInteger: Number(formData.slotIncrementMinutes), }] : []), ]; const { schedule: createdSchedule, noticeOutcome } = await createSchedule(scheduleFhir); // A schedule CREATED for somebody is their hours going from nothing to something, so the // same rule applies as on an edit. setNotice(scheduleNoticeSentence(noticeOutcome)); // By id: the server's schedule.created broadcast can land before this answer and add it first. setSchedulesList((prev) => upsertById(prev, createdSchedule)); const newRecord: StaffScheduleRecord = { schedule: createdSchedule, role: primaryRole, practitioner: { practitioner, roles: [primaryRole] }, staffName, roleLabel: primaryRole.code?.[0]?.text ?? 'Staff', locationLabel: selectedLocationRef?.display ?? 'All locations', servicesLabel: getScheduleServicesLabel(createdSchedule), active: true, }; setLocalRecords((prev) => [...prev, newRecord]); setShowNewBlock(false); return null; } catch (err: unknown) { return describeWriteFailure(err, 'Failed to create period'); } finally { setSavingId(null); } }; const handleDeleteConfirmed = async () => { if (!deleteConfirmId) return; setGlobalError(null); if (deleteConfirmId === 'all') { // EACH DELETE STANDS ON ITS OWN. With Promise.all one refusal hid the ones that had already // gone, so the list kept periods the server had deleted and a retry met 404s. Remove what // went, keep what did not, and say how many stayed. const results = await Promise.allSettled(localRecords.map((r) => deleteSchedule(r.schedule))); const deletedIds = new Set( localRecords.filter((_, index) => results[index].status === 'fulfilled').map((r) => r.schedule.id), ); const firstFailure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected'); setSchedulesList((prev) => prev.filter((s) => !deletedIds.has(s.id))); setLocalRecords((prev) => prev.filter((r) => !deletedIds.has(r.schedule.id))); setDeleteConfirmId(null); setExpandedId(null); if (!firstFailure) { setShowNewBlock(false); onRequestClose(); return; } const kept = results.length - deletedIds.size; setGlobalError(describeWriteFailure( firstFailure.reason, `${kept} of ${results.length} ${results.length === 1 ? 'period was' : 'periods were'} not deleted`, )); return; } const record = localRecords.find((r) => r.schedule.id === deleteConfirmId); if (!record) { setDeleteConfirmId(null); return; } try { // A DELETION is the change most worth being told about - the week does not move, it // disappears - so this path reports it too rather than only the edit path. const { noticeOutcome } = await deleteSchedule(record.schedule); setNotice(scheduleNoticeSentence(noticeOutcome)); setSchedulesList((prev) => prev.filter((s) => s.id !== record.schedule.id)); setLocalRecords((prev) => prev.filter((r) => r.schedule.id !== deleteConfirmId)); if (expandedId === deleteConfirmId) setExpandedId(null); } catch (err: any) { setGlobalError(describeWriteFailure(err, 'Failed to delete period')); } finally { setDeleteConfirmId(null); } }; if (!isOpen) return null; const deleteTarget = deleteConfirmId === 'all' ? null : localRecords.find((r) => r.schedule.id === deleteConfirmId); return ( { if (!open) onRequestClose(); }}> {`Manage Schedule — ${staffName}`}
{/* THE PROVIDER WAS NOT TOLD, AND THE SAVE STILL WORKED. `warning`, never `error`: the hours ARE changed, and dressing this as a failure sends somebody to re-do a change that is already stored. It renders only when there is something to act on - a provider who was notified produces nothing here at all. */} {notice && ( <> setNotice(undefined)}> {notice} )} {globalError && ( <> setGlobalError(null)}> {globalError} )} {localRecords.length === 0 && !showNewBlock && ( No schedule periods yet. Add one below. )} {(localRecords.length > 0 || showNewBlock) && ( <> Schedule periods )}
{localRecords.map((record) => { const blockId = record.schedule.id ?? ''; const transformed = record.role ? transformFromFhirPractitionerRole(record.role) : newBlockDefaults; // Availability + location now live on the Schedule; fall back to the role for blocks // saved before the move. const blockAvailability = getScheduleAvailability(record.schedule); const formAvailableTime = blockAvailability.availableTime.length ? transformFromOperationalHours(blockAvailability.availableTime) : (transformed.availableTime ?? []); const formNotAvailable = blockAvailability.notAvailable.length ? transformFromOperationalHoursNotAvailable(blockAvailability.notAvailable) : (transformed.notAvailable ?? []); // - the PHYSICAL location only. A block can now carry the virtual // Location too, and on an org-wide video block that is the ONLY Location actor, // so the old first-actor read would seed this single-select with a virtual id // that `onlySites` never offers as an option. const scheduleLocationId = seedScheduleLocationId( record.schedule, virtualLocation?.id, record.role?.location?.[0]?.reference?.split('/').pop(), ); const today = new Date(); today.setHours(0, 0, 0, 0); const activeNotAvailable = (formNotAvailable ?? []).filter((na: any) => { const end = na.during?.end; if (!end) return true; const endDate = /^\d{4}-\d{2}-\d{2}$/.test(end) ? (() => { const [y, m, d] = (end as string).split('-').map(Number); return new Date(y, m - 1, d); })() : new Date(end); return endDate >= today; }); const bookingFlags = getScheduleBookingFlags(record.schedule); return ( ref.reference?.startsWith('HealthcareService/')) .map((ref) => ref.reference?.split('/').pop()) .filter((id): id is string => Boolean(id)), serviceSelfBooking: readBlockSelfBookingChoices(record.schedule), breaks: getScheduleBreaks(record.schedule), // Read through the helper so an unsupported stored value falls // back to the default here too, rather than seeding the picker // with something it cannot show. slotIncrementMinutes: getScheduleSlotIncrement(record.schedule), }} isExpanded={expandedId === blockId} isNew={false} onToggleExpand={() => setExpandedId((prev) => (prev === blockId ? null : blockId)) } onSave={(formData) => handleSaveExisting(record, formData)} // The same two refusals handleSaveExisting runs, asked before the // advisory rather than after it. It excludes this block from the // conflict search for the same reason the save does - a block cannot // overlap itself. onValidate={(formData) => checkDegenerateDay(formData) ?? checkConflict(formData, record.schedule.id) } onDelete={() => setDeleteConfirmId(blockId)} isSaving={savingId === blockId} virtualLocation={virtualLocation} /> ); })} {showNewBlock && ( setShowNewBlock(false)} onSave={handleSaveNew} // A new block has no id to exclude, so the conflict search covers every // existing one. The missing-dates and missing-role refusals stay inside // handleSaveNew: both are about whether a block can be CREATED at all, // and neither is a property of the hours being edited. onValidate={(formData) => checkDegenerateDay(formData) ?? checkConflict(formData) } onDelete={() => setShowNewBlock(false)} isSaving={savingId === 'new'} virtualLocation={virtualLocation} /> )}
{(!showNewBlock || localRecords.length > 0) && (
{!showNewBlock && ( )}
{/* Delete All carried a hand-rolled hover state (a useState plus two mouse handlers and an inline colour) whose only job was turning the label red on hover. btn-red-ghost does that in CSS, so all of it was removed — reusable styling belongs in index.css, not per site. The symbol name is left out of this comment deliberately: a grep for a removed symbol should not match the note explaining that it is gone. NB: this comment sits ABOVE the `&&` guard, not inside it — the first slot inside `{cond && (` takes an EXPRESSION, so a JSX comment there parses as an object literal and breaks the file. */} {localRecords.length > 0 && ( )}
)} {deleteConfirmId !== null && ( setDeleteConfirmId(null)} > {deleteConfirmId === 'all' ? `Delete all ${localRecords.length} schedule period${localRecords.length === 1 ? '' : 's'} for ${staffName}? This cannot be undone.` : `Delete the period ${formatDate(deleteTarget?.schedule.planningHorizon?.start)} – ${formatDate(deleteTarget?.schedule.planningHorizon?.end)} for ${staffName}? This cannot be undone.`} )}
); }; export default ManageScheduleModal;