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:
- Account Type — Radio: Revenue / Expense / Margin
- Department — ScoreMemberPicker for GL departments
- 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)
- Filters — Same shared StrategicFilterBar component
- Primary Measure — Dropdown from
glMeasureTypeData(instead ofmeasureTypeData) - 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:
- Create CC opportunity: Navigate to
/strategic-opportunities/create/charge-code, complete all 5 steps, verify it saves - Edit CC opportunity: Open an existing CC strategic opportunity, click Edit, verify all fields load correctly, make a change, save
- Pre-populate from exploration: From an exploration population, click "Create Strategic Opportunity" — verify encounter group, charge codes, and baseline are pre-filled
- Goal reset warning: Edit an opportunity, change the filters or baseline period, click Save — verify the "resets goals" confirmation modal appears
- 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.ts → loadPrePopulationData() |
StrategicOpportunityDefine component |
charge-code/ChargeCodeDefine.tsx (same code, new name) |
ChargeCodeCustomPickerModal |
charge-code/ChargeCodeCustomPickerModal.tsx (moved) |
Step 1 validation in handleChange |
useChargeCodeStrategy.ts → validateDefineStep() |
Step 1 marshaling in updateOpportunity |
useChargeCodeStrategy.ts → marshalDefineStep() |
createOpportunityChargeCode call |
useChargeCodeStrategy.ts → create() |
updateOpportunityChargeCode call |
useChargeCodeStrategy.ts → update() |
Repopulation check (shouldPopulateOpportunityDetails) |
useChargeCodeStrategy.ts → shouldRepopulateOnEdit() |
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.