// - the one line worth the whole HR workspace: WHO CANNOT BE BOOKED, AND WHY. // // THE PROBLEM IS A MISSING JOIN, NOT A MISSING LIST. Invite - role - location - schedule - services - // NPI live on FIVE screens with nothing joining them, so a practitioner who is invisible to booking // looks perfectly normal on every one. The receptionist meets it as *"the calendar is empty"* and cannot // fix it, because nothing anywhere says which of them is missing. // // **CORRECTED IMMEDIATELY AFTER THE FIRST VERSION: this file does NOT decide bookability.** My first // cut re-derived it from `hasSchedule` / `hasServices` booleans - a THIRD implementation of a question // `utils/booking/resolveBookability.ts` already answers, and the second time that module has had a // duplicate grow beside it. Its own header records what that cost: *"it counted 'offers a service' from // PractitionerRole.healthcareService while the patient flow matched Schedule.actor, and nothing kept the // two in step - so it could report a provider ready when no patient could book them."* // **The booking verdict is HANDED IN, from the same resolver the patient flow uses.** What this module // owns is the layer ABOVE it, which that resolver deliberately knows nothing about: employment. // // **Spencer, 2026-08-14: deactivation means GONE, not away - absence is `Schedule.notAvailable`.** // `ROLE_INACTIVE` cannot answer this on its own: it says the role is off, not whether the person left // or is on maternity leave, and telling a practice somebody quit is not a rounding error. import type { BookabilityBlocker } from '../booking/bookabilityReasons'; /** Steps that can apply. The last two apply only to a clinical role - see `total`. */ export type ReadinessStep = 'invite' | 'role' | 'location' | 'contact' | 'bookable' | 'npi'; /** Employment-level causes. Everything schedule- or service-shaped is a `BookabilityBlocker`. */ export type EmploymentBlocker = 'invite-pending' | 'deactivated' | 'on-leave' | 'not-clinical'; export type StaffBookingBlocker = EmploymentBlocker | BookabilityBlocker; export interface StaffFacts { inviteAccepted: boolean; hasRole: boolean; hasLocation: boolean; /** * Can the product REACH this person? Every notification resolves its recipient from the FHIR * Practitioner's `telecom`, never from the Cognito account - so somebody who signs in perfectly * well can still be silent for every notice the product sends. That difference is the whole defect: * the sign-in address proves nothing about deliverability. */ hasContact: boolean; /** Only a clinician is booked, or needs an NPI. */ isClinical: boolean; hasNpi: boolean; /** `false` = deactivated. GONE, not away. */ active: boolean; /** On leave - `Schedule.notAvailable`. Away, not gone. */ onLeave: boolean; /** * FROM `getProviderReadiness`, NEVER RE-DERIVED HERE. `blockers` is already band-ordered so its * FIRST entry is the one to fix first - this module must not re-sort or re-interpret it. */ booking: { staffBookable: boolean; blockers: readonly BookabilityBlocker[]; basis?: 'confirmed' | 'checking' | 'unavailable' }; } export interface StaffReadiness { done: number; /** MOVES with the person: a non-clinical role is never asked to be bookable or to hold an NPI. */ total: number; complete: boolean; missing: ReadinessStep[]; /** Steps not yet known, counted in the total but in neither done nor missing: booking while services are unverified. */ pending: ReadinessStep[]; booking: { bookable: true } | { bookable: false; reason: StaffBookingBlocker } | { bookable: 'unknown'; state: 'checking' | 'unavailable' }; } export function staffReadiness(facts: StaffFacts): StaffReadiness { // THE DENOMINATOR MOVES WITH THE PERSON. A receptionist scored against the clinical steps sits at // "3 of 5 - not bookable, no NPI" for ever: permanently incomplete for things that can never apply // to them, and a checklist nobody can finish is a checklist nobody reads. // Non-clinical staff can still hold working hours; this asks what makes a person USABLE, and a // receptionist with no schedule is doing their job perfectly well. // CONTACT IS UNIVERSAL, and deliberately not a clinical step. The cause is not clinician-shaped: // every notification resolves its recipient from the Practitioner's telecom, so a receptionist with // no address is just as silent as a provider with none. Scoring it only for clinicians would fix the // case that happened to be measured and leave the same hole for everybody else. const steps: Array<[ReadinessStep, boolean]> = [ ['invite', facts.inviteAccepted], ['role', facts.hasRole], ['location', facts.hasLocation], ['contact', facts.hasContact], ]; const pending: ReadinessStep[] = []; if (facts.isClinical) { if ((facts.booking.basis ?? 'confirmed') === 'confirmed') steps.push(['bookable', facts.booking.staffBookable]); else pending.push('bookable'); steps.push(['npi', facts.hasNpi]); } const missing = steps.filter(([, done]) => !done).map(([step]) => step); return { done: steps.length - missing.length, total: steps.length + pending.length, complete: missing.length === 0 && pending.length === 0, missing, pending, booking: bookingVerdict(facts), }; } /** * ORDER IS THE DESIGN, not an implementation detail. The page shows ONE reason, so it must be the one * to fix FIRST - telling an administrator to add a schedule for someone who has not signed in yet is a * wasted trip, and they will make it, because the page told them to. * * The EMPLOYMENT causes come first and then it hands over. They are strictly upstream: none of the * resolver's blockers can be acted on for a person who has left, is on leave, or never accepted. */ function bookingVerdict(facts: StaffFacts): StaffReadiness['booking'] { // Deactivation outranks leave - but only when it is actually true. Someone on leave is still an // employee, and reporting them as deactivated tells the practice they quit. if (!facts.active) return { bookable: false, reason: 'deactivated' }; if (!facts.inviteAccepted) return { bookable: false, reason: 'invite-pending' }; // Not a defect. Nobody books a receptionist, and listing them as a booking problem fills the page // with rows working exactly as intended - which is how a page stops being read. if (!facts.isClinical) return { bookable: false, reason: 'not-clinical' }; if (facts.onLeave) return { bookable: false, reason: 'on-leave' }; // Established employment facts decide above; past them, a verdict needs verified services. const basis = facts.booking.basis ?? 'confirmed'; if (basis !== 'confirmed') return { bookable: 'unknown', state: basis }; if (facts.booking.staffBookable) { // An NPI gap does NOT make someone unbookable here - it is a credentialing gap, and the // resolver (the only thing that mirrors the server) says they can be booked. Reporting otherwise // would put this page in disagreement with the booking flow, which is the whole class of defect // the shared resolver exists to prevent. It still shows as a missing readiness STEP. // // A MISSING CONTACT ADDRESS IS THE SAME SHAPE, and it must stay that way. Whether an unreachable // provider should be bookable at all is a real product question with operational weight - a // practice may keep a provider's details elsewhere - and it is Spencer's, not something to // decide as a side effect of making the count honest. Say the true thing on the screen; do not // quietly start refusing bookings. return { bookable: true }; } // HANDED OVER, NOT RE-DECIDED. `blockers` arrives band-ordered; taking `[0]` is taking the // resolver's own answer to "what is closest to working". const [first] = facts.booking.blockers; return { bookable: false, reason: first ?? 'BLOCK_NO_HOURS' }; } /** What the practice is told, for the causes this module owns. A `BookabilityBlocker`'s wording is the * booking vocabulary's to give - asking for it here would be the duplication all over again. */ export function employmentBlockerNotice(reason: EmploymentBlocker): string { switch (reason) { case 'deactivated': return 'Deactivated — not active at this practice.'; // Away, not gone. Naming the return is what stops an administrator "fixing" it by re-inviting. case 'on-leave': return 'On leave — their schedule shows them unavailable, and they return automatically.'; case 'invite-pending': return 'Invitation not accepted yet — they have not signed in.'; default: return 'Not a clinical role — patients are not booked with this person.'; } } export const isEmploymentBlocker = (reason: StaffBookingBlocker): reason is EmploymentBlocker => reason === 'invite-pending' || reason === 'deactivated' || reason === 'on-leave' || reason === 'not-clinical';