import { useEffect, useMemo, useState } from 'react'; import { SelectControl } from "../ui/select-control"; import { Heading } from '../ui/heading'; import { Text } from '../ui/text'; import { Spacer } from '../ui/spacer'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { useAuthServiceContext } from '../../services/AuthService.tsx'; import { useValueSetServiceContext, } from '../../services/ValueSetService.tsx'; import type { IValueSet } from '../../services/ValueSetService.tsx'; import { filterPractitionerSpecialty, sortValueSet } from '../../utils/valueSetUtils'; import { LEADERSHIP_DESIGNATION_GROUPS, PRACTICE_TYPE_SYSTEM, TRAINING_LEVEL_OPTIONS, getCoupledWorkspaceForOrgRole, getOrgRoleByCode, getOrgRoleCouplingNote, getWorkspaceFormValue, sortRoleGroupsByPracticeTypes, type StaffWorkspace, } from '../../utils/staffRoleCatalog'; import { useServiceCategories } from '../../config/serviceCategories'; import FhirCodeableConcept from '../FhirCodeableConcept/FhirCodeableConcept'; import { describeCoupledRemoval, type CoupledRemovalDescription } from '../../utils/coupledRemoval'; import CoupledRemovalConfirmDialog from '../CoupledRemovalConfirmDialog/CoupledRemovalConfirmDialog'; interface StaffRoleAssignmentFieldsProps { disabled?: boolean; // Confirm-time refusal hook for a coupled-role removal (IMPORTANT 1 fix): given the org-role code // about to be removed, returns whether completing the removal (both halves) would leave the staff // member's CURRENT form state with zero workspaces - the exact condition the submit-time guard in // PractitionerRolePage.tsx refuses to save. Checking it here, before the dialog even offers "Remove // both", stops the dialog from promising a removal the save can refuse. Left undefined by every // caller where the condition cannot arise (the create form's own workspace UI is visible, and a // brand-new role always inherits at least the Generic workspace) - those callers keep the plain // "Remove both" flow unchanged. evaluateCoupledRoleRemoval?: (code: string) => { blocked: boolean; reason?: string }; // Where this screen sets workspace access: the staff record has a Workspaces tab, Add staff a section. workspacesIn?: 'tab' | 'section'; } // Live two-way tick, add direction (Task 4 step 1): given the org-role codes currently selected and // the workspace FORM keys currently ticked, returns the workspace form keys that must be ticked so // every currently-selected coupled role has its partner workspace showing as selected. Purely // additive - it only appends a missing partner key, it never removes one. Removal is owned // entirely by the dedicated coupled-removal confirm handler (confirmRoleRemoval below), which drops // both halves of a pair together. Because this only ever adds, re-running it after any `code` change // (including one that just removed a coupled role) is a no-op for that role - there is nothing left // to add a workspace for - so it cannot re-add a workspace the user (or the removal handler) just // took away. export const getWorkspaceFormKeysForSelectedRoles = ( orgRoleCodes: string[], currentWorkspaceFormKeys: string[], ): string[] => { const missingKeys = Array.from(new Set( orgRoleCodes .map((code) => getCoupledWorkspaceForOrgRole(code)) .filter((workspace): workspace is StaffWorkspace => Boolean(workspace)) .map((workspace) => getWorkspaceFormValue(workspace)) .filter((key) => key && !currentWorkspaceFormKeys.includes(key)), )); return missingKeys.length > 0 ? [...currentWorkspaceFormKeys, ...missingKeys] : currentWorkspaceFormKeys; }; const StaffRoleAssignmentFields = ({ disabled = false, evaluateCoupledRoleRemoval, workspacesIn = 'tab', }: StaffRoleAssignmentFieldsProps) => { const { control, getValues, setValue, } = useFormContext(); const { wpOrganization } = useAuthServiceContext(); // Sort discipline groups so the org's configured practice type(s) surface first. The disciplines come from // the server taxonomy (fetched), so re-sort when it loads. const { categories: practiceTypeTaxonomy } = useServiceCategories(); const sortedRoleGroups = useMemo(() => { const practiceTypeCodes = (wpOrganization.current?.type ?? []) .flatMap((concept) => concept.coding ?? []) .filter((coding) => coding.system === PRACTICE_TYPE_SYSTEM) .map((coding) => coding.code) .filter((code): code is string => Boolean(code)); return sortRoleGroupsByPracticeTypes(practiceTypeCodes, practiceTypeTaxonomy); }, [practiceTypeTaxonomy]); // eslint-disable-line react-hooks/exhaustive-deps // Workspace-coupled org roles ARE selectable now: picking one auto-assigns its workspace, and // removing it removes the workspace (reconciled on save), so the "a permission-floor coding never // exists without its authorizing workspace" invariant is upheld by the coupling rather than by // locking the chip against removal. Each carries a note naming the workspace it will bring with it. const selectableRoleGroups = sortedRoleGroups; const { fetchValueSet, valueSetUrls } = useValueSetServiceContext(); const [specialtyOptions, setSpecialtyOptions] = useState([]); const [loadingSpecialties, setLoadingSpecialties] = useState(false); const [pendingRoleRemoval, setPendingRoleRemoval] = useState<{ code: string; description: CoupledRemovalDescription; blockedReason?: string } | undefined>(); const watchedOrganizationRoles = useWatch({ control, name: 'code' }); const organizationRoles = useMemo( () => Array.isArray(watchedOrganizationRoles) ? watchedOrganizationRoles : [], [watchedOrganizationRoles], ); const primaryOrganizationRole = useWatch({ control, name: 'primaryOrganizationRole' }) as string | undefined; const selectedRoleCodes = useMemo( () => new Set(organizationRoles.map((role) => role.code)), [organizationRoles], ); const hasResidentRole = selectedRoleCodes.has('resident-physician'); const hasSpecialtyRequiredRole = useMemo( () => organizationRoles.some((role) => getOrgRoleByCode(role.code)?.specialtyRequired === true), [organizationRoles], ); useEffect(() => { if (!hasSpecialtyRequiredRole || specialtyOptions.length > 0) { return undefined; } let active = true; const loadSpecialties = async () => { setLoadingSpecialties(true); try { const values = await fetchValueSet(valueSetUrls.practitionerSpecialty); if (active) { setSpecialtyOptions(sortValueSet(filterPractitionerSpecialty(values))); } } catch (error) { console.error('Failed to load practitioner specialties:', error); } finally { if (active) { setLoadingSpecialties(false); } } }; void loadSpecialties(); return () => { active = false; }; }, [ fetchValueSet, hasSpecialtyRequiredRole, specialtyOptions.length, valueSetUrls.practitionerSpecialty, ]); // Task 4 step 1: adding a coupled role ticks its partner workspace immediately, so the create // form never shows a role selected without the workspace it grants. FhirCodeableConcept owns the // actual "add" interaction (the role picker's Select) and exposes no add hook - only // onBeforeRemove, used below for the removal side - so this reacts to the resulting `code` value // instead. It is safe to run on every `code` change (not just literal "gain" events) because // getWorkspaceFormKeysForSelectedRoles only ever adds a workspace for a role that is STILL // selected; see the comment on that helper for why this cannot fight the removal confirm flow. // Guard note: this can fire on MOUNT (a loaded role reconciled here before the user touches // anything), writing shouldDirty: true with no user action. Verified inert today (2026-07-27) - // nothing in src/ gates navigation or Save on isDirty (no beforeunload/useBlocker/usePrompt). If an // unsaved-changes prompt is ever added, revisit this: it would fire on a mount that made no edit. useEffect(() => { const currentWorkspaces = getValues('workspaces'); if (!Array.isArray(currentWorkspaces)) return; const nextWorkspaces = getWorkspaceFormKeysForSelectedRoles( organizationRoles.map((role) => role.code), currentWorkspaces, ); if (nextWorkspaces !== currentWorkspaces) { setValue('workspaces', nextWorkspaces, { shouldDirty: true, shouldValidate: true, }); } }, [organizationRoles, getValues, setValue]); useEffect(() => { const specialty = getValues('specialty'); if (!hasSpecialtyRequiredRole && Array.isArray(specialty) && specialty.length > 0) { setValue('specialty', [], { shouldDirty: true, shouldValidate: true, }); } }, [getValues, hasSpecialtyRequiredRole, setValue]); useEffect(() => { if (organizationRoles.length === 0) { if (primaryOrganizationRole) { setValue('primaryOrganizationRole', '', { shouldDirty: true, shouldValidate: true, }); } return; } if (!primaryOrganizationRole || !selectedRoleCodes.has(primaryOrganizationRole)) { setValue('primaryOrganizationRole', organizationRoles[0].code, { shouldDirty: false, shouldValidate: true, }); } }, [organizationRoles, primaryOrganizationRole, selectedRoleCodes, setValue]); useEffect(() => { if (!hasResidentRole && getValues('trainingLevel')) { setValue('trainingLevel', null, { shouldDirty: true, shouldValidate: true, }); } }, [getValues, hasResidentRole, setValue]); const handlePrimaryRoleChange = (nextPrimaryCode: string) => { const currentRoles = [...organizationRoles]; const primaryIndex = currentRoles.findIndex((role) => role.code === nextPrimaryCode); if (primaryIndex > 0) { const [primaryRole] = currentRoles.splice(primaryIndex, 1); currentRoles.unshift(primaryRole); setValue('code', currentRoles, { shouldDirty: true, shouldValidate: true, }); } setValue('primaryOrganizationRole', nextPrimaryCode, { shouldDirty: true, shouldValidate: true, }); }; // Spencer 2026-08-06 - `addCustomOrganizationRole` is DELETED. It was the only caller of // getOrganizationDefinedRoleSystem() and the only thing that minted a `custom-` role code. // See the comment at the removed field below for the evidence; in short: 0 of 174 staging roles used // it, and showed a custom role named "Billing" silently inheriting billing:manage. // Veto the immediate removal ONLY for a coupled role, so we can explain the consequence first. // describeCoupledRemoval returns undefined for every uncoupled role, so those remove instantly with // no dialog - returning true here is what keeps that path unchanged. // // IMPORTANT 1 fix: before offering the destructive "Remove both" confirm, ask the caller (when it // supplied evaluateCoupledRoleRemoval) whether completing this removal would leave zero workspaces // assigned - the exact condition the submit-time guard elsewhere refuses to save. If so, show a // blocked/explanatory dialog instead: no destructive action, and the chip is NOT removed (we still // return false to veto FhirCodeableConcept's immediate removal either way). const handleBeforeRoleRemove = (code: string): boolean => { const description = describeCoupledRemoval({ kind: 'role', code }); if (!description) return true; const evaluation = evaluateCoupledRoleRemoval?.(code); if (evaluation?.blocked) { setPendingRoleRemoval({ code, description, blockedReason: evaluation.reason }); return false; } setPendingRoleRemoval({ code, description }); return false; }; const confirmRoleRemoval = () => { if (!pendingRoleRemoval) return; const { code } = pendingRoleRemoval; const currentCodes = (getValues('code') ?? []) as Array<{ code?: string }>; setValue('code', currentCodes.filter((entry) => entry.code !== code), { shouldDirty: true, shouldValidate: true, }); const partnerWorkspace = getCoupledWorkspaceForOrgRole(code); const currentWorkspaces = getValues('workspaces'); if (partnerWorkspace && Array.isArray(currentWorkspaces)) { const partnerKey = getWorkspaceFormValue(partnerWorkspace); setValue( 'workspaces', currentWorkspaces.filter((value: string) => value !== partnerKey), { shouldDirty: true, shouldValidate: true }, ); } setPendingRoleRemoval(undefined); }; const primaryRoleOptions = [ { label: '', value: '' }, ...organizationRoles.map((role) => ({ label: role.display, value: role.code, })), ]; return ( <> Organization roles Choose every job function this staff member holds for the organization.{' '} {workspacesIn === 'section' ? 'Workspace access is set in the Workspace access section below.' : 'Workspace access is managed separately on the Workspaces tab.'} getOrgRoleCouplingNote(option.code)} onBeforeRemove={handleBeforeRoleRemove} /> { if (!open) setPendingRoleRemoval(undefined); }} /> organizationRoles.length === 0 || Boolean(value), }} render={({ field, fieldState }) => ( <> {fieldState.error && (

Select a primary organization role.

)} )} /> {/* Spencer 2026-08-06 — the "Organization-defined role" field is GONE. A practice-defined role slugified its free-text NAME into a role code, and card 126 proved what that costs: naming a role "Billing" or "Human Resources" produced exactly the built-in key and SILENTLY inherited that profile's permissions, while displaying as an ordinary bespoke role. The likeliest trigger was never an attacker — it was an admin naming a role for their own reasons and unknowingly granting billing:manage. Measured before removing: 0 of 174 staging PractitionerRoles carried a custom role coding (control: 43 built-in codings), so nothing was using it and no migration is owed. The 16 built-in roles across 14 groups remain; a genuinely missing role should be ADDED there, where it gets a category, a permission profile and a workspace deliberately. ⛔ The collision guards in staffAccess.ts and the server's staff-permissions.ts STAY. They are cheap, and they are what makes a stray legacy coding harmless. Do not remove them just because this door is now shut. */} {hasResidentRole && ( <> )} {hasSpecialtyRequiredRole && ( <> )} ); }; export default StaffRoleAssignmentFields;