import { useMemo, useState } from 'react'; import { ChevronDown, ChevronRight } from 'lucide-react'; import { resolveScope, type BookabilityScope, type ScopeInput, } from '../../utils/booking/bookabilityScopes'; import { blockerCopy, byDesignCopy, widenerCopy } from '../../utils/booking/bookabilityCopy'; import { bucketCombinations } from '../../utils/booking/bookabilityBuckets'; import { fixGuidance, fixOwnerFor } from '../../utils/booking/blockerFixOwner'; import type { BookabilityBlocker } from '../../utils/booking/bookabilityReasons'; import './BookabilityPanel.css'; const STAFF_BOOKED: ReadonlySet = new Set([ 'NOT_SELF_BOOKABLE_ORG', 'NOT_SELF_BOOKABLE_PRACTITIONER', 'NOT_SELF_BOOKABLE_BLOCK', ]); // "Why doesn't Dr. Chen show up for a Cleaning at Westside?" - answered at three scopes from one component. // // MIRROR, NEVER A GATE. Read-only; it changes nothing and authorizes nothing. // // Gating is the HOST screen's own VIEW predicate (canViewStaffSection), never canManage - that is the whole // point of the placement decision: HR authors the fragile middle of the chain and must be able to SEE // org-level causes it cannot itself fix. `canManageSection` is passed in only to decide the WORDING of the // fix guidance, never whether the panel renders. export interface BookabilityPanelProps { scope: BookabilityScope; input: ScopeInput; /** Whether the viewer can EDIT a given section - used only to word the guidance, never to hide anything. */ canManageSection: (section: 'organization' | 'staff' | 'schedules') => boolean; /** Org scope is long, so it starts collapsed; a single block is short and starts open. */ defaultOpen?: boolean; className?: string; } const BookabilityPanel = ({ scope, input, canManageSection, defaultOpen, className, }: BookabilityPanelProps) => { const result = useMemo(() => resolveScope(scope, input), [scope, input]); const [open, setOpen] = useState(defaultOpen ?? scope.kind === 'schedule'); const { combinations, totalCount } = result; // Only GAPS count as problems. A combination the practice deliberately configured is working as // configured, and counting it here would flag a correct decision as a fault. // // - BUCKETED ON THE FRAMING AXIS, NOT THE AUDIENCE ONE. This used to be // `!c.staffBookable`, which asks "who does this stop?" and was being used to answer "deliberate or // broken?". Those coincided until the telehealth gates arrived: those codes are BY DESIGN and stop // EVERYONE, so `staffBookable` went false and "this service can't be done as a video visit" rendered // amber, with fix guidance beside it, counted in the headline - the one thing // `bookabilityReasons.ts` says never to do with them. The rule now lives in `bucketCombinations`. const { gaps, byDesign: deliberate, bookable } = useMemo(() => bucketCombinations(combinations), [combinations]); // A service turned off for patients stays in the list with a line saying staff book it, rather than vanishing. const staffBooked = deliberate.filter((c) => STAFF_BOOKED.has(c.blockers[0])); const byDesign = deliberate.filter((c) => !STAFF_BOOKED.has(c.blockers[0])); // NEVER say "combinations" - that is the resolver's unit, not an operator's. A practice thinks // "Dr. Chen, Cleaning, Westside", so the panel names the actual things throughout. const headline = (() => { if (totalCount === 0) return 'Nothing set up here yet'; if (gaps.length > 0) { return `${gaps.length} ${gaps.length === 1 ? 'thing patients' : 'things patients'} can’t book`; } // A row is a service with a provider at a location; the headline names services, so it counts them. const services = new Set(bookable.map((c) => c.serviceId ?? c.serviceName)).size; return `Patients can book ${services} ${services === 1 ? 'service' : 'services'}${ scope.kind === 'schedule' ? ' here' : '' }`; })(); // Wideners are org-config facts, not per-combination faults, so they are collected once and shown apart // from the blockers - they are NOT errors and must not read as errors. const wideners = useMemo( () => Array.from(new Set(combinations.flatMap((c) => c.wideners))), [combinations], ); const unbookable = gaps; return (
{open && (
{totalCount === 0 && (

Patients' booking options appear here once the provider has a schedule and services.

)} {/* ⭐ ALWAYS shown when there is anything bookable. The first version rendered only PROBLEMS, so a healthy practice expanded the panel and got an empty box — it disclosed nothing in the state a practice is in almost all the time. Naming what patients CAN book is also the answer to the other question an operator has: "did my setup actually work?" */} {bookable.length + staffBooked.length > 0 && (
    {[...bookable, ...staffBooked].map((c) => (
  • {c.serviceName} {scope.kind === 'schedule' ? '' : ` — ${c.practitionerName} · ${c.locationName}`} {!c.bookable && ( Staff book this one; patients can’t self-book it. )}
  • ))}
)} {unbookable.length > 0 && ( // Wide content scrolls inside its own container rather than pushing the page sideways.
    0 ? 'mt-3 border-t pt-2' : ''}`}> {unbookable.map((c) => { const [first] = c.blockers; const guidance = first ? fixGuidance(first, canManageSection(fixOwnerFor(first).section)) : undefined; return (
  • {c.practitionerName} · {c.serviceName} · {c.locationName} {first ? blockerCopy(first) : 'Not bookable by patients.'} {guidance && ( {guidance.text} )}
  • ); })}
)} {/* Deliberate configuration, NOT faults — neutral styling, never amber, and always paired with the other half of the truth so nobody "fixes" a decision the practice made on purpose. */} {byDesign.length > 0 && (
{/* 🚨 CARD 156 — WAS "Staff-booking only (by design)", which this section can no longer promise. It now also holds telehealth by-design rows, and staff CANNOT book those: a practice with video off cannot take a video booking from the front desk either. The heading states only the framing; whether staff can still book is per-row, and `byDesignCopy` already appends "Staff can still book it for a patient" exactly where `isPatientOnly` is true. */}

Set up this way on purpose

    {byDesign.map((c) => { const [first] = c.blockers; return (
  • {c.practitionerName} · {c.serviceName} · {c.locationName} —{' '} {first ? byDesignCopy(first) : 'Staff can book this; patients can’t self-book it.'}
  • ); })}
)} {wideners.length > 0 && (

Also worth knowing

    {wideners.map((w) => (
  • {widenerCopy(w, { orgCatalogSize: input.orgCatalogSize ?? 0, orgLocationCount: input.locationNameById.size, })}
  • ))}
)}
)}
); }; export default BookabilityPanel;