Skip to main content

Treatment Plan — Pricing-Toggle Hardening + Reusable Custom Clinical Phase

Date: 2026-06-10 Component: Case Architect / Treatment Planning (ui/src/components/doctor/TreatmentPlanning.tsx) Status: Approved (clarifying answers locked; standing auto-approve preference)

Problem

  1. Pricing leaks. A clinic-wide toggle documentSettings.treatmentPlanPricingEnabled (default ON) already hides money in the Case Architect editor (Case Projection block and per-procedure cost field) and in the PDF / portal / share email. But several money elements are not gated and still render when the toggle is OFF.
  2. Hardcoded clinical phases. The Clinical Phase dropdown is a fixed 5-item list (phaseOptions, lines 127–133). Clinics want to add their own phase labels and have them persist for reuse across all future plans (“quick add and store”).

Scope decisions (from user)

  • Hide scope: “Just the leaks I named.” Gate only the 4 leaking estimate displays. Leave the plan-detail Billing / Financials tab (real invoices, paid, outstanding) fully intact — that is actual money owed, not a quote, and is a separate concern from the treatment-plan pricing toggle.
  • Custom phase storage: Reusable, saved clinic-wide, appears in the dropdown for all future plans for every plan author in that clinic.

Part A — Plug the pricing-toggle leaks

The component already holds treatmentPlanPricingEnabled state (TreatmentPlanning.tsx:284), hydrated from getClinicDetails(...).documentSettings.treatmentPlanPricingEnabled !== false (:289–294). getClinicDetails returns documentSettings for both admin and non-admin paths (clinics.ts:235, :328) — verified. So no new data plumbing is needed; we only add conditionals at four render sites. Gate each of these behind treatmentPlanPricingEnabled &&:
#LineWhatBehaviour when OFF
1~1410–1414Per-procedure price shown next to each item in the procedure picker <SelectItem>Render only the procedure name; drop the trailing price span
2~1018”Total Value” stat card on the plans dashboardHide that single stat card
3~1115Estimated cost in each plan-list rowHide the cost element
4~1176Estimated cost in the plan previewHide the cost element
Layout notes:
  • The “Total Value” stat (leak #2) sits in a stats grid. Hiding one card must not leave a visible hole — verify the grid reflows acceptably (it uses auto/responsive columns); if a fixed column count causes an empty slot, conditionally drop the card from the rendered set so the remaining cards reflow. Confirm visually.
  • Leaks #1, #3, #4 are inline spans/elements; conditional rendering is sufficient.
These follow the exact pattern already used at the gated sites (:1448, :1581, :1739, :1796, :1871). Out of scope (left ON regardless of toggle): plan-detail Billing/Financials tab totals and invoice tables (TreatmentPlanning.tsx ~1921–2097). These are real billing artifacts.

Part B — Reusable custom Clinical Phase

Storage (no migration)

Add to the clinics.documentSettings JSONB $type (server/src/schema/clinics.ts):
// Clinic-defined extra Clinical Phase labels appended to the 5 built-in phases in
// the treatment-plan editor. Stored verbatim — extend freely, no migration (missing
// key = none). Mirrors the termsLibrary precedent.
treatmentPlanCustomPhases?: string[];
sanitizeDocumentSettingsForStorage (document-numbering.ts:434) spreads all keys, so this passes through untouched on any clinic save.

Server endpoint

New route in server/src/routes/treatment-plans.ts, inheriting the route-level guard (requirePermissionByMethod('clinical.treatment_plans.view','clinical.treatment_plans.create')). We need a mutating endpoint, so it must require the create permission.
POST /api/v1/protected/treatment-plans/phases
Body: { phase: string }
Behaviour:
  1. Resolve the caller’s clinic id from auth context (same pattern as the create handler).
  2. Trim phase; reject empty or > 60 chars (400).
  3. Load the clinic’s current documentSettings; read treatmentPlanCustomPhases ?? [].
  4. Dedupe case-insensitively against the 5 built-in defaults and existing customs. If duplicate, treat as success (idempotent) and return the unchanged list.
  5. Append, write back the merged documentSettings to the caller’s clinic only (UPDATE ... WHERE id = <callerClinicId>), within that clinic scope.
  6. Return { phases: string[] } (the custom list, not including defaults).
Rationale for a dedicated endpoint: PUT /clinics/:id is admin/superadmin-only (clinics.ts:710–712), but treatment plans are authored by doctors. Reusing it would 403 for doctors. The dedicated endpoint authorizes via the treatment-plan create permission so any plan author can add a phase. The 5 built-in default phase labels must be referenced server-side for dedupe — define a shared DEFAULT_TREATMENT_PLAN_PHASES const (server constant mirroring the UI phaseOptions) to avoid drift.

Client

  • serverComm.ts: addTreatmentPlanPhase(phase: string): Promise<{ phases: string[] }>.
  • TreatmentPlanning.tsx:
    • New state const [customPhases, setCustomPhases] = useState<string[]>([]).
    • In the existing clinic-load effect (:290), also set setCustomPhases((clinic as any)?.documentSettings?.treatmentPlanCustomPhases ?? []).
    • Dropdown options become [...phaseOptions, ...customPhases] (dedupe defensively).
    • Add a trailing ”+ Add custom phase…” entry in the phase <SelectContent>. Selecting it (sentinel value, e.g. __add_phase__) opens a small Dialog: a single text input + Save/Cancel. On Save: call addTreatmentPlanPhase, set customPhases from the response, set this procedure’s phase to the new label, close the dialog. Disable Save while the request is in flight; surface the 400 message inline.
    • Sentinel must never be persisted as a real phase value.

Persona / permissions

  • Add + use custom phases: any role that can create treatment plans (doctor, admin). No new permission key — reuses clinical.treatment_plans.create.
  • Scope: clinic-wide. No per-user storage.
  • Superadmin parity: custom phases live in documentSettings, already visible in the superadmin clinic-details view; no dedicated superadmin editor needed for v1.

Testing / verification

  • Pricing leaks: With treatmentPlanPricingEnabled=false on the test tenant (ssh & Associates), reload and visually confirm NO estimate money appears in: procedure picker, stat card, list rows, preview, editor Case Projection. Confirm the Billing tab STILL shows invoice money. With toggle ON (default), confirm everything reappears. Visual pass required (reload + screenshot or Playwright) — tsc/build does not prove UI.
  • Custom phase: Add a phase as a doctor → persists across reload → appears in a brand-new plan’s dropdown. Duplicate (case-insensitive) is idempotent. Empty/over-long rejected. Verify a doctor (not admin) can add successfully (permission), and the write only touches the caller’s clinic.

Files touched

  • server/src/schema/clinics.ts — add treatmentPlanCustomPhases?: string[] to type.
  • server/src/routes/treatment-plans.ts — new POST /phases + DEFAULT_TREATMENT_PLAN_PHASES.
  • ui/src/lib/serverComm.tsaddTreatmentPlanPhase.
  • ui/src/components/doctor/TreatmentPlanning.tsx — 4 pricing gates + custom-phase state, dropdown merge, add-phase dialog.
  • docs/api-reference.md — document the new endpoint (per API-doc discipline).