Template
405 lines
17 KiB
Markdown
405 lines
17 KiB
Markdown
# 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`:
|
|
|
|
```typescript
|
|
// 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(...);
|
|
```
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// In handleChange, step 1 validation:
|
|
} else if (currentStep === 1) {
|
|
if (!detailValues.encounterGroup || !detailValues.encounterGroups || ...) {
|
|
return;
|
|
}
|
|
}
|
|
```
|
|
|
|
### After: delegates to strategy
|
|
|
|
```typescript
|
|
} else if (currentStep === 1) {
|
|
if (!activeStrategy.validateDefineStep(detailValues)) {
|
|
return;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Before: inline CC rendering
|
|
|
|
```typescript
|
|
<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
|
|
|
|
```typescript
|
|
<Wizard.Step key='1' title='Define Opportunity'>
|
|
{activeStrategy.renderDefineStep({ form, filters, updateFilters, ... })}
|
|
</Wizard.Step>
|
|
```
|
|
|
|
### Before: inline CC service call
|
|
|
|
```typescript
|
|
const opportunityId = await strategicOpportunityService.createOpportunityChargeCode(data);
|
|
```
|
|
|
|
### After: strategy handles service call
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
// 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.
|
|
|
|
```typescript
|
|
// 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.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.
|