// - the line the whole HR workspace is for: *"Dr. Ellis - 3 of 5 set up. Not bookable: this // provider has no schedule at this location."* // // THE PROBLEM WAS NEVER A MISSING LIST, IT WAS A MISSING JOIN. Invite - role - location - schedule - // services - NPI live on five screens with nothing joining them, so a practitioner invisible to booking // looks perfectly normal on every one of them. The receptionist meets it as *"the calendar is empty"* // and cannot fix it, because nothing anywhere says which of them is missing. // // Pure presentation: it renders a verdict it is HANDED. The rule is `utils/staff/staffReadiness.ts`, the // facts come off FHIR in `staffFacts.ts`, and the booking half comes from the shared resolver - see that // module's header for what re-deriving it already cost this codebase once. import { blockerCopy } from '../../utils/booking/bookabilityCopy'; import { employmentBlockerNotice, isEmploymentBlocker, type ReadinessStep, type StaffReadiness, } from '../../utils/staff/staffReadiness'; import { GiveClinicianSeatButton } from '../ClinicianSeat/ClinicianSeatStatus'; const STEP_LABEL: Record = { invite: 'invitation accepted', role: 'a role', location: 'a location', // NAMED AS AN EMAIL RATHER THAN AS "contact information", because that is what the predicate // actually asks for. A phone number leaves this step red, and a label saying "contact" would send // an administrator to add one and watch nothing change. contact: 'a contact email on this profile', // THE ROLL-UP STEP, AND IT MUST NOT NAME THE WRONG MISSING THING. See `bookableStepLabel`. bookable: 'a way to be booked', npi: 'an NPI, if they hold one', }; /** * The blockers for which "a bookable schedule" is a true description of what is missing. * * WHY THIS LIST EXISTS. `bookable` is not a step like the others - it is the whole booking verdict * rolled into one line, and its causes are not all schedule-shaped. Labelling it "a bookable schedule" * unconditionally told an administrator to go and add a schedule to a provider who already had one: * measured 2026-09-06 on ZZTest Prac859Walk, whose header read *"still needs a location, an email * address, a bookable schedule, an NPI"* while the Patient Booking tab on the same record read * *"A schedule with hours and dates - done"* and named the real cause, an unheld clinician seat. The * header's own reason line, six inches below the count, said the seat too. It contradicted the tab and * itself at once. * * THE TWO NUMBERS WERE NEVER MISCALCULATED. They answer different questions - "is there a block with * hours and dates?" and "can this person be booked at all?" - and both answered correctly. It is the * label on the second that claimed the first. * * AN UNRECOGNISED BLOCKER FALLS TO THE GENERIC PHRASE, deliberately: a new cause must not be able to * start claiming a schedule is missing by being added to the vocabulary and nothing else. */ const SCHEDULE_SHAPED_BLOCKERS = [ 'BLOCK_NO_HOURS', 'BLOCK_NO_PLANNING_HORIZON', 'BLOCK_HORIZON_EXPIRED', 'NO_SCHEDULE_AT_LOCATION', 'NO_VIRTUAL_LOCATION_ON_BLOCK', ]; export const bookableStepLabel = (booking: StaffReadiness['booking']): string => { if (booking.bookable === 'unknown') { return booking.state === 'checking' ? 'Checking whether this person can be booked' : 'Could not check services for booking'; } return !booking.bookable && SCHEDULE_SHAPED_BLOCKERS.includes(booking.reason as string) ? 'a bookable schedule' : STEP_LABEL.bookable; }; /** The vocabulary is whoever owns the cause. An employment cause is this module's; anything * schedule- or service-shaped belongs to the booking vocabulary, and asking for it here would be the * duplication that rule was rewritten to remove. */ const reasonCopy = (reason: StaffReadiness['booking'] extends { reason: infer R } ? R : never): string => (isEmploymentBlocker(reason as never) ? employmentBlockerNotice(reason as never) : blockerCopy(reason as never)); export const StaffReadinessSummary = ({ readiness, name, scheduleHidden = false, onGiveClinicianSeat }: { readiness: StaffReadiness; name?: string; /** Offered beside the seat reason, so the banner leads to the fix it names. */ onGiveClinicianSeat?: () => void; /** * - THE READER MAY NOT SEE THIS PERSON'S SCHEDULE AT ALL. `GET /Schedule` comes back * narrowed to the reader's own blocks, with a 200 and nothing to say so, so the resolver's verdict * below is about a schedule that was never in the data the answer is a permission fact, not a * fact about this person. */ scheduleHidden?: boolean; }) => { const { done, total, complete, missing, booking } = readiness; const bookingPending = (readiness.pending ?? []).includes('bookable'); /* - TWO SEPARATE SUPPRESSIONS, and collapsing them into one gets a case wrong each way. ① THE COUNT. With the schedule hidden, the `bookable` step is unknowable whatever the reason: the resolver had no schedules to reason over, so it answers "no" by construction. It leaves the DENOMINATOR - printing "4 of 5" while withholding the explanation is the same false claim with the evidence removed, and the reader still concludes something is wrong with this person. ② THE REASON. Only a SCHEDULE-derived one is suppressed. An employment cause - deactivated, on leave, invitation not accepted, not a clinical role - is read off this person's own roles and is just as true with every schedule hidden. Swallowing it would be a second false silence: the reader would be told nothing about somebody who has left the practice. */ const bookableStepUnknowable = scheduleHidden && missing.includes('bookable'); const scheduleBlind = scheduleHidden && !booking.bookable && !isEmploymentBlocker(booking.reason as never); const shownMissing = bookableStepUnknowable ? missing.filter((step) => step !== 'bookable') : missing; return (

{/* ⛔ The count renders even when complete. "5 of 5" is the answer to "is this person set up?", and a summary that disappears once everything is fine leaves the reader unable to tell "all good" from "this page never loaded". */} {done} of {bookableStepUnknowable ? total - 1 : total} set up {!complete && shownMissing.length > 0 && ( {' — still needs '}{shownMissing .map((step) => (step === 'bookable' ? bookableStepLabel(booking) : STEP_LABEL[step])) .join(', ')} )} {bookingPending && , booking not yet checked}

{bookingPending &&

{bookableStepLabel(booking)}

} {/* 🚨 THE HIGHEST-VALUE LINE IN THE WORKSPACE. A practitioner with no schedule is invisible to booking, and the only symptom anywhere else in the product is an empty calendar. ⛔ ONE reason, and it is the one to fix FIRST — the rule orders them, employment causes ahead of the resolver's own band order, because telling someone to add a schedule for a person who has not accepted their invitation is a trip they will actually make. */} {!booking.bookable && !scheduleBlind && (

{name ? `${name} cannot be booked — ` : 'Cannot be booked — '} {reasonCopy(booking.reason as never)} {booking.reason === 'NO_CLINICIAN_SEAT' && onGiveClinicianSeat && ( )}

)} {/* 🚨 CARD 377 — A REFUSAL, NOT AN EMPTY RESULT. Without this the page says "no schedule set" about a colleague who works full time, because a narrowed search and an empty practice look identical ⇒ [[reference-authz-denial-never-renders-as-empty-data]]. ⭐ It names the FIX rather than just the limit: the reader is usually an administrator who can go and change the setting, and "ask an administrator" would be telling them to ask themselves. */} {bookableStepUnknowable && (

Their schedule is not counted here, because your role can only see your own. An administrator can change that under Roles & Permissions.

)}
); };