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
- 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. - 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 holdstreatmentPlanPricingEnabled 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 &&:
| # | Line | What | Behaviour when OFF |
|---|---|---|---|
| 1 | ~1410–1414 | Per-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 dashboard | Hide that single stat card |
| 3 | ~1115 | Estimated cost in each plan-list row | Hide the cost element |
| 4 | ~1176 | Estimated cost in the plan preview | Hide the cost element |
- 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.
Part B — Reusable custom Clinical Phase
Storage (no migration)
Add to theclinics.documentSettings JSONB $type (server/src/schema/clinics.ts):
sanitizeDocumentSettingsForStorage (document-numbering.ts:434) spreads all keys, so this
passes through untouched on any clinic save.
Server endpoint
New route inserver/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.
- Resolve the caller’s clinic id from auth context (same pattern as the create handler).
- Trim
phase; reject empty or> 60chars (400). - Load the clinic’s current
documentSettings; readtreatmentPlanCustomPhases ?? []. - Dedupe case-insensitively against the 5 built-in defaults and existing customs. If duplicate, treat as success (idempotent) and return the unchanged list.
- Append, write back the merged
documentSettingsto the caller’s clinic only (UPDATE ... WHERE id = <callerClinicId>), within that clinic scope. - Return
{ phases: string[] }(the custom list, not including defaults).
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: calladdTreatmentPlanPhase, setcustomPhasesfrom the response, set this procedure’sphaseto 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.
- New state
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=falseon 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— addtreatmentPlanCustomPhases?: string[]to type.server/src/routes/treatment-plans.ts— newPOST /phases+DEFAULT_TREATMENT_PLAN_PHASES.ui/src/lib/serverComm.ts—addTreatmentPlanPhase.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).

