Files
continuousimprovement/docs/strategic-opportunity-wizard-refactor.md
T

17 KiB

Strategic Opportunity Wizard Refactor — Migration Guide

Who This Is For

If you wrote or maintain StrategicOpportunity.tsx (the wizard orchestrator) or any of its step components, this document explains what's changing, why, and how your code maps to the new structure.


Why Are We Doing This?

The wizard was built for Charge Code opportunities only. Now we need to add General Ledger (GL) opportunities that use the same 5-step wizard flow but have completely different "Define Opportunity" logic (departments + GL accounts instead of encounter groups + charge codes).

The current orchestrator (StrategicOpportunity.tsx, 670 lines) has charge-code-specific state, validation, data marshaling, and service calls baked directly in. If we added GL logic the same way, the file would balloon with if (opportunityType === 'GL') branches everywhere.

Instead, we're extracting type-specific logic into strategy hooks — one for Charge Code, one for GL — and keeping the orchestrator focused on what's shared: wizard navigation, form management, and the steps that are identical for both types.


The Big Picture: Before vs After

Before (today)

StrategicOpportunity.tsx (670 lines)
  ├── Wizard navigation + form setup
  ├── 12+ CC-specific state variables (chargeCodeMembers, selectedChargeCodes, etc.)
  ├── CC-specific functions (loadChargeCodePickerModels, etc.)
  ├── CC-specific validation in handleChange (step 1)
  ├── CC-specific data marshaling in updateOpportunity (step 1)
  ├── CC-specific edit loading in loadEditData
  ├── CC-specific pre-population from exploration
  ├── CC-specific create/update service calls
  └── CC-specific repopulation detection on edit

Everything lives in one file. There's no separation between "wizard infrastructure" and "charge code logic."

After (refactored)

StrategicOpportunity.tsx (~300 lines) — Generic orchestrator
  ├── Wizard navigation + form setup (unchanged)
  ├── Shared state only (primaryMeasures, filters, rollups, workbooks, baseline)
  ├── Calls activeStrategy.validateDefineStep() instead of inline CC validation
  ├── Calls activeStrategy.marshalDefineStep() instead of inline CC marshaling
  ├── Calls activeStrategy.loadEditData() for type-specific edit fields
  ├── Calls activeStrategy.create() / activeStrategy.update()
  └── Renders activeStrategy.renderDefineStep() for step 1

charge-code/useChargeCodeStrategy.ts (~250 lines) — All the CC logic extracted
  ├── CC state (chargeCodeMembers, selectedChargeCodes, etc.)
  ├── CC functions (loadChargeCodePickerModels, etc.)
  ├── CC step 1 validation + marshaling
  ├── CC edit loading + pre-population
  └── CC create/update service calls

general-ledger/useGLStrategy.ts — New GL logic
  ├── GL state (accountType, departments, GL accounts)
  ├── GL step 1 validation + marshaling
  ├── GL edit loading
  └── GL create/update service calls

What Stays the Same

These things are not changing (or changing only trivially):

Component What's happening
StrategicOpportunityDetail.tsx (Step 0) Shared. No change except minor display support for GL type.
StrategicOpportunityDefineTimeframe.tsx (Step 3) Shared. No change.
StrategicOpportunityExternalLinks.tsx (Step 4) Shared. No change.
StrategicOpportunityRenameMeasure.tsx Shared utility. No change.
StrategicOpportunityFilters.tsx Shared utility. No change.
All data/ files No changes to interfaces, services, enums.
Wizard step validation for steps 0, 2, 3, 4 Stays in the orchestrator — these steps are identical for both types.

What's Moving

Files that move (same code, new location)

Old Location New Location Why
wizard/StrategicOpportunityDefine.tsx wizard/charge-code/ChargeCodeDefine.tsx This component is 100% charge-code-specific. Renaming makes that explicit.
wizard/ChargeCodeCustomPickerModal.tsx wizard/charge-code/ChargeCodeCustomPickerModal.tsx Only used by charge code define step.

These are moves + renames, not rewrites. The component logic inside is the same.

State that moves from orchestrator → CC strategy hook

All of these useState calls currently in StrategicOpportunity.tsx will move into useChargeCodeStrategy.ts:

// These move OUT of the orchestrator:
const [chargeCodeMembers, setChargeCodeMembers] = useState(...);
const [selectedChargeCodes, setSelectedChargeCodes] = useState(...);
const [selectedChargeCodesInitial, setSelectedChargeCodesInitial] = useState(...);
const [expPopulationMembers, setExpPopulationMembers] = useState(...);
const [selectedExpPopulations, setSelectedExpPopulations] = useState(...);
const [patientPopulationsInitial, setPatientPopulationsInitial] = useState(...);
const [expPopulationsInitial, setExpPopulationsInitial] = useState(...);
const [expPopCostDriver, setExpPopCostDriver] = useState(...);
const [uploadModalVisible, setUploadModalVisible] = useState(...);
const [isPrePopulated, setIsPrePopulated] = useState(...);
// These STAY in the orchestrator (shared by both types):
const [stepNumber, setStepNumber] = useState(0);
const [opportunity, setOpportunity] = useState(...);   // now typed as IStrategicOpportunityData (base)
const [primaryMeasures, setPrimaryMeasures] = useState(...);
const [workbooks, setWorkbooks] = useState(...);
const [opportunityFilters, setOpportunityFilters] = useState(...);
const [baselinePeriodInitial, setBaselinePeriodInitial] = useState(...);
const [rollupColumns, setRollupColumns] = useState(...);
const [isEditMode, setIsEditMode] = useState(false);
const [loading, setLoading] = useState(false);

Functions that move from orchestrator → CC strategy hook

Function Why it's CC-specific
loadChargeCodePickerModels() Fetches charge code picker data
loadExpPopulationPickerModels() Fetches exploration populations
setPrimaryMeasureChargeCodes() Sets CC-specific form field
setExplorationPopulations() Sets CC-specific encounter group
handleChargeCodesUploaded() Handles CC upload modal result
Pre-population useEffect (lines 584-642) Only applies to CC from exploration
CC-specific parts of loadEditData Loading encounter groups, charge codes
CC-specific parts of handleFormValuesChanged CC form field change handling
Step 1 block in handleChange CC-specific validation checks
Step 1 block in updateOpportunity CC-specific data marshaling
handleSubmit service calls createOpportunityChargeCode / updateOpportunityChargeCode
Repopulation detection logic Comparing CC-specific initial vs current state

The Strategy Interface

Both hooks implement this common interface so the orchestrator doesn't need to know which type it's working with:

interface IOpportunityTypeStrategy {
  // Default data for creating a new opportunity of this type
  getDefaultData(): IStrategicOpportunityData;

  // Renders the "Define Opportunity" step (step 1) — CC shows encounter groups + charge codes, GL shows departments + accounts
  renderDefineStep(context): JSX.Element;

  // Renders any modals this type needs (CC has upload modal, GL has none)
  renderModals(context): JSX.Element | null;

  // Returns true if step 1 form values are valid for this type
  validateDefineStep(formValues): boolean;

  // Converts form values into opportunity data fields for step 1
  marshalDefineStep(formValues, context): Partial<IStrategicOpportunityData>;

  // Handles form value changes that need type-specific reactions
  onFormValuesChanged(changedValues, context): void;

  // Loads type-specific fields when editing an existing opportunity
  loadEditData(data, form, context): void;

  // (CC only) Pre-populates from exploration population URL params
  loadPrePopulationData?(params, form, context): Promise<void>;

  // Calls the correct create API
  create(data): Promise<number>;

  // Calls the correct update API
  update(opportunityId, data, shouldPopulate): Promise<boolean>;

  // Determines if editing has changed enough to require goal recalculation
  shouldRepopulateOnEdit(context): boolean;

  // Returns the measure type list for the "Additional Measures" step dropdown
  getMeasureTypeData(): { key: number; name: string }[];
}

How the Orchestrator Changes

Before: inline CC logic

// In handleChange, step 1 validation:
} else if (currentStep === 1) {
  if (!detailValues.encounterGroup || !detailValues.encounterGroups || ...) {
    return;
  }
}

After: delegates to strategy

} else if (currentStep === 1) {
  if (!activeStrategy.validateDefineStep(detailValues)) {
    return;
  }
}

Before: inline CC rendering

<Wizard.Step key='1' title='Define Opportunity'>
  <StrategicOpportunityDefine
    encounterGroup={opportunity.encounterGroup}
    chargeCodeMembers={chargeCodeMembers}
    selectedChargeCodes={selectedChargeCodes}
    // ... 14 props
  />
</Wizard.Step>

After: strategy renders its own step

<Wizard.Step key='1' title='Define Opportunity'>
  {activeStrategy.renderDefineStep({ form, filters, updateFilters, ... })}
</Wizard.Step>

Before: inline CC service call

const opportunityId = await strategicOpportunityService.createOpportunityChargeCode(data);

After: strategy handles service call

const opportunityId = await activeStrategy.create(data);

Hook Selection: Both Hooks Always Called

React requires hooks to be called unconditionally (same hooks, same order, every render). So the orchestrator calls both hooks every time:

const ccStrategy = useChargeCodeStrategy({ form, ... });
const glStrategy = useGLStrategy({ form, ... });

const activeStrategy = opportunityType === OpportunityTypes.StrategicOpportunityGL
  ? glStrategy
  : ccStrategy;

The inactive hook holds trivial default state — a few empty arrays and false booleans. No API calls, no heavy computation. This is a standard React pattern.


New Folder Structure

strategic-opportunities/
  wizard/
    StrategicOpportunity.tsx              ← Slimmed-down orchestrator
    StrategicOpportunityDetail.tsx        ← Step 0 (unchanged)
    StrategicOpportunityDefineAdditional.tsx  ← Step 2 (adds measureTypeData prop)
    StrategicOpportunityDefineTimeframe.tsx   ← Step 3 (unchanged)
    StrategicOpportunityExternalLinks.tsx     ← Step 4 (unchanged)
    StrategicOpportunityRenameMeasure.tsx     ← Shared utility (unchanged)
    StrategicOpportunityFilters.tsx           ← Shared utility (unchanged)
    types/
      IOpportunityTypeStrategy.ts         ← Strategy interface + context types
    charge-code/
      useChargeCodeStrategy.ts            ← CC strategy hook (extracted from orchestrator)
      ChargeCodeDefine.tsx                ← Moved from StrategicOpportunityDefine.tsx
      ChargeCodeCustomPickerModal.tsx     ← Moved from wizard root
    general-ledger/
      useGLStrategy.ts                    ← GL strategy hook (new)
      GeneralLedgerDefine.tsx             ← GL define step (new)

StrategicOpportunityDefineAdditional Change

This component currently hardcodes the charge code measure type list:

// Line 18 — imports CC-only measure types
import { getDesiredResultByMeasureType, measureTypeData } from '../data/MeasureType';

// Line 31 — hardcoded to CC measures
const secondaryMeasureItems = useMemo(() => measureTypeData.map(...), []);

After the refactor, it receives measureTypeData as a prop from the orchestrator, which gets it from activeStrategy.getMeasureTypeData(). This way CC shows CC measures and GL shows GL measures in the "Add Additional Measure" dropdown.

// Before
<StrategicOpportunityDefineAdditional
  getMeasureItem={getMeasureItem}
  updateFiltersForSecondaryMeasure={updateFiltersForSecondaryMeasure}
  validateAndUpdateMeasureName={validateAndUpdateMeasureName}
/>

// After — adds measureTypeData prop
<StrategicOpportunityDefineAdditional
  getMeasureItem={getMeasureItem}
  updateFiltersForSecondaryMeasure={updateFiltersForSecondaryMeasure}
  validateAndUpdateMeasureName={validateAndUpdateMeasureName}
  measureTypeData={activeStrategy.getMeasureTypeData()}
/>

New Routes

Two new routes added to Navigation.tsx:

Route Purpose
/strategic-opportunities/create/general-ledger Create a new GL strategic opportunity
/strategic-opportunities/edit/general-ledger/:strategicOpportunityId Edit an existing GL strategic opportunity

These follow the exact same pattern as the existing CC routes (lines 226-236 in Navigation.tsx).


What the GL Define Step Looks Like

Instead of Encounter Groups + Charge Codes + Upload, the GL define step has:

  1. Account Type — Radio: Revenue / Expense / Margin
  2. Department — ScoreMemberPicker for GL departments
  3. GL Accounts — Conditional pickers based on account type:
    • Revenue → Revenue account picker
    • Expense → Expense account picker
    • Margin → Both revenue + expense pickers
    • Optional statistic account picker (checkbox toggle)
  4. Filters — Same shared StrategicFilterBar component
  5. Primary Measure — Dropdown from glMeasureTypeData (instead of measureTypeData)
  6. Desired Result + Threshold — Same UI as CC

How to Verify Nothing Broke

After the refactor, test these CC flows — they should behave identically to today:

  1. Create CC opportunity: Navigate to /strategic-opportunities/create/charge-code, complete all 5 steps, verify it saves
  2. Edit CC opportunity: Open an existing CC strategic opportunity, click Edit, verify all fields load correctly, make a change, save
  3. Pre-populate from exploration: From an exploration population, click "Create Strategic Opportunity" — verify encounter group, charge codes, and baseline are pre-filled
  4. Goal reset warning: Edit an opportunity, change the filters or baseline period, click Save — verify the "resets goals" confirmation modal appears
  5. Run frontend tests: cd src/Strata.ContinuousImprovement.Web/continuousimprovement && npm run test-ci

Mapping Cheat Sheet: "Where Did My Code Go?"

If you're looking for... It's now in...
chargeCodeMembers state useChargeCodeStrategy.ts
selectedChargeCodes state useChargeCodeStrategy.ts
loadChargeCodePickerModels() useChargeCodeStrategy.ts
loadExpPopulationPickerModels() useChargeCodeStrategy.ts
handleChargeCodesUploaded() useChargeCodeStrategy.ts
Pre-population useEffect useChargeCodeStrategy.tsloadPrePopulationData()
StrategicOpportunityDefine component charge-code/ChargeCodeDefine.tsx (same code, new name)
ChargeCodeCustomPickerModal charge-code/ChargeCodeCustomPickerModal.tsx (moved)
Step 1 validation in handleChange useChargeCodeStrategy.tsvalidateDefineStep()
Step 1 marshaling in updateOpportunity useChargeCodeStrategy.tsmarshalDefineStep()
createOpportunityChargeCode call useChargeCodeStrategy.tscreate()
updateOpportunityChargeCode call useChargeCodeStrategy.tsupdate()
Repopulation check (shouldPopulateOpportunityDetails) useChargeCodeStrategy.tsshouldRepopulateOnEdit()
Wizard navigation (handleChange, setStepNumber) StrategicOpportunity.tsx (stayed)
Form setup (Form.useForm()) StrategicOpportunity.tsx (stayed)
Shared state (primaryMeasures, filters, rollups) StrategicOpportunity.tsx (stayed)
Steps 0, 2, 3, 4 validation StrategicOpportunity.tsx (stayed)
handleCancel, handleSubmit skeleton StrategicOpportunity.tsx (stayed)

FAQ

Q: Why not just use if/else for GL vs CC in the existing orchestrator? A: The orchestrator would grow to 1000+ lines with interleaved branching. Strategy hooks keep each type's logic cohesive and independently testable.

Q: Why hooks instead of classes? A: React state (useState, useEffect) only works inside hooks or components. A class-based strategy would need workarounds to manage React state.

Q: Does the inactive strategy hook waste memory? A: Negligible. It holds a few empty arrays and false booleans. No API calls fire for the inactive type.

Q: Will this break existing CC tests? A: No. The CC behavior is identical — logic is just relocated. Import paths in tests may need updating if they reference moved files.

Q: Can I still debug the CC flow the same way? A: Yes. Set breakpoints in useChargeCodeStrategy.ts for CC-specific logic, or in StrategicOpportunity.tsx for wizard navigation. The call chain is just one level deeper for type-specific operations.