Files
continuousimprovement/docs/add-opportunity-type-playbook.md

22 KiB

Add Opportunity Type Playbook

Single source of truth for adding a new strategic opportunity type. Derived from the Charge Code (CC) and General Ledger (GL) implementations.

Quick-Scan Checklist

Use this to track progress. Each item references a detailed section below.

Backend

Frontend

Cross-Cutting


Backend Steps

1. Entity

Create Biz/StrategicOpportunities/StrategicOpportunity{Type}.cs inheriting from StrategicOpportunity (TPT pattern).

Pattern:

using System.ComponentModel.DataAnnotations.Schema;

namespace Strata.ContinuousImprovement.Biz.StrategicOpportunities
{
    [Table("strategic_opportunity_{type}")]
    public class StrategicOpportunity{Type} : StrategicOpportunity
    {
        // Type-specific properties
        public string SomeHPath { get; set; }

        public StrategicOpportunity{Type}() { }

        // Copy constructor (used by CopyStrategicOpportunity)
        public StrategicOpportunity{Type}(StrategicOpportunity{Type} opportunity, string opportunityName)
        {
            // Copy all base properties
            Name = opportunityName;
            OpportunityType = opportunity.OpportunityType;
            OpportunityFiltersJSON = opportunity.OpportunityFiltersJSON;
            Rollup1Id = opportunity.Rollup1Id;
            Rollup2Id = opportunity.Rollup2Id;
            Rollup3Id = opportunity.Rollup3Id;
            BaselineType = opportunity.BaselineType;
            BaselineDistribution = opportunity.BaselineDistribution;
            BaselineStartDate = opportunity.BaselineStartDate;
            BaselineEndDate = opportunity.BaselineEndDate;
            EstimatedTrackingDuration = opportunity.EstimatedTrackingDuration;
            StrataId = opportunity.StrataId;
            LayoutJSON = opportunity.LayoutJSON;
            TeamMembers = opportunity.TeamMembers;
            OwnerGuid = opportunity.OwnerGuid;
            ExecutiveSponsorGuid = opportunity.ExecutiveSponsorGuid;
            CostLeaderGuid = opportunity.CostLeaderGuid;

            // Copy type-specific properties
            SomeHPath = opportunity.SomeHPath;
        }
    }
}

DbContext: Add DbSet<StrategicOpportunity{Type}> to CentralDbContext and configure in OnModelCreating.

References:

  • Biz/StrategicOpportunities/StrategicOpportunityChargeCode.cs
  • Biz/StrategicOpportunities/StrategicOpportunityGL.cs
  • Base: Biz/StrategicOpportunities/StrategicOpportunity.cs

2. Enums

OpportunityTypes (Biz/Generics/OpportunityTypes.cs or wherever defined): Add a new enum value. Current values:

  • StrategicOpportunityChargeCode (value in IAllOpportunity.ts frontend)
  • StrategicOpportunityGL

MeasureType (Biz/StrategicOpportunities/Enums/MeasureType.cs): Add new measure type values after the last GL value (currently 37 = GLMarginPerAdjDischarge).

// {Type} measure types (38+)
{Type}Measure1 = 38,
{Type}Measure2,
// ...

Also add a {TYPE}_MEASURE_TYPES constant list in MeasureTypeConstants.

Other enums: If the type has a concept like GL's GLAccountType, create a new enum file in Biz/StrategicOpportunities/Enums/.

3. DTOs

Create two DTOs in Biz/StrategicOpportunities/Dtos/:

Create DTOStrategicOpportunity{Type}Dto.cs:

public class StrategicOpportunity{Type}Dto : StrategicOpportunityDto
{
    // Type-specific properties matching the entity
    public string SomeHPath { get; set; }
}

Update DTOStrategicOpportunity{Type}UpdateDto.cs:

public class StrategicOpportunity{Type}UpdateDto
{
    public StrategicOpportunity{Type}Dto Opportunity { get; set; }
    public bool ShouldPopulateOpportunityDetails { get; set; }
}

References:

  • Dtos/StrategicOpportunityGLDto.cs / StrategicOpportunityGLUpdateDto.cs
  • Dtos/StrategicOpportunityChargeCodeDto.cs / StrategicOpportunityChargeCodeUpdateDto.cs

4. Detail Service

Create interface + implementation for populating detail data.

InterfaceIStrategic{Type}OpportunityDetailService.cs:

public interface IStrategic{Type}OpportunityDetailService
{
    Task PopulateOpportunityDetailsAsync(StrategicOpportunity{Type} opportunity, CancellationToken ct);
    Task<StrategicOpportunityDetailData> GetDetailDataAsync(long opportunityId, bool isTracking, List<int> dimensions, CancellationToken ct);
}

ImplementationStrategic{Type}OpportunityDetailService.cs: Uses primary constructor DI, queries Snowflake/SQL for dimension data, populates StrategicOpportunityDetail rows.

References:

  • IStrategicChargeCodeOpportunityDetailService.cs + StrategicChargeCodeOpportunityDetailService.cs
  • IStrategicGLOpportunityDetailService.cs + StrategicGLOpportunityDetailService.cs

5. Query Layer

SQL files (embedded resources in Biz/StrategicOpportunities/Queries/ or similar):

  • StrategicOpportunity{Type}DetailCTE.sql — main detail data query
  • Dimension-specific queries as needed

ServiceQueryStrategicOpportunity{Type}ServiceQuery.cs: Loads SQL from embedded resources, provides query text to the detail service.

QueryBuilderStrategicOpportunity{Type}DetailQueryBuilder.cs: Implements IStrategicOpportunityDetailQueryBuilder, builds parameterized queries for dimensions.

References:

  • Queries/StrategicOpportunityGLDetailQueryBuilder.cs
  • Queries/StrategicOpportunityGLServiceQuery.cs
  • StrategicOpportunityServiceQuery.cs (CC version)

6. Distribution Process

Create in Biz/StrategicOpportunities/DistributionProcess/{Type}/:

  • DistributionProcess{Type}.cs — orchestrates data distribution
  • DistributionProcess{Type}SqlBuilder.cs — builds SQL for distribution
  • Distribution{Type}Summary.cs — summary model

Update DistributionProcessFactory.cs to handle the new type.

References:

  • DistributionProcess/ChargeCode/DistributionProcessChargeCode.cs
  • DistributionProcess/GL/DistributionProcessGL.cs
  • DistributionProcess/DistributionProcessFactory.cs

7. Dimension Config

In StrategicOpportunityService.cs, add a {Type}DimensionsConfig that maps dimension IDs to dimension names/hierarchy paths for the new type.

8. Service Methods

Add to IStrategicOpportunityService.cs:

Task<long> CreateOpportunity{Type}Async(StrategicOpportunity{Type}Dto dto, CancellationToken ct);
Task<long> UpdateOpportunity{Type}(long id, StrategicOpportunity{Type}UpdateDto dto, CancellationToken ct);
Task<StrategicOpportunityEditData> GetStrategicOpportunity{Type}(long id, CancellationToken ct);
Task<bool> Delete{Type}OpportunityAsync(long id, CancellationToken ct);

Implement in StrategicOpportunityService.cs.

9. Controller Endpoints

Add endpoints to the strategic opportunities controller (or create a new controller if separation is needed):

POST   /api/v1.0/strategic-opportunities/{type}          — Create
PUT    /api/v1.0/strategic-opportunities/{type}/{id}      — Update
GET    /api/v1.0/strategic-opportunities/{id}/{type}      — Get for edit
DELETE /api/v1.0/strategic-opportunities/{type}/{id}      — Delete
POST   /api/v1.0/strategic-opportunities/{id}/{type}-detail — Get detail data
PUT    /api/v1.0/strategic-opportunities/{id}/{type}-details — Update goal values

10. DI Registration

In Biz/Configurations/ContinuousImprovementServiceExtensions.cs:

services.AddScoped<IStrategic{Type}OpportunityDetailService, Strategic{Type}OpportunityDetailService>();

11. Database Migration

dotnet ef migrations add AddStrategicOpportunity{Type}Table -p src/Strata.ContinuousImprovement.Biz -s src/Strata.ContinuousImprovement.Api

TPT gotcha: EF Core generates the child table PK with the same name as the parent (pk_strategic_opportunity). You must manually rename it in the migration file:

// Change this:
name: "pk_strategic_opportunity",
// To this:
name: "pk_strategic_opportunity_{type}",

Frontend Steps

All paths relative to src/Strata.ContinuousImprovement.Web/continuousimprovement/src/.

F1. Data Model

Create strategic-opportunities/data/IStrategicOpportunity{Type}Data.ts:

import { IStrategicOpportunityData, defaultStrategicOpportunityData } from './IStrategicOpportunityData';

export interface IStrategicOpportunity{Type}Data extends IStrategicOpportunityData {
  // Type-specific properties
  someHPath: string;
}

export const defaultStrategicOpportunity{Type}Data: IStrategicOpportunity{Type}Data = {
  ...defaultStrategicOpportunityData,
  someHPath: ''
};

References:

  • data/IStrategicOpportunityChargeCodeData.ts
  • data/IStrategicOpportunityGLData.ts

F2. Measure Types

In strategic-opportunities/data/MeasureType.ts:

  1. Add enum values (must match backend MeasureType.cs):
// {Type} measure types (sync with backend, values N-M)
{Type}Measure1 = N,
{Type}Measure2,
  1. Add display names array:
export const {type}MeasureTypeData: IKeyNameItem[] = [
  { key: MeasureType.{Type}Measure1, name: 'Display Name 1' },
  // ...
];
  1. Add entries to mapMeasureTypeToDesiredResult.

  2. Add helper like is{Type}PerMeasureType() if the type has "per unit" style measures.

F3. Strategy Hook

Create strategic-opportunities/wizard/{type}/use{Type}Strategy.ts:

Must implement IOpportunityTypeStrategy from wizard/types/IOpportunityTypeStrategy.ts:

export interface IOpportunityTypeStrategy {
  getDefaultData(): IStrategicOpportunityData;
  renderDefineStep(context: IDefineStepContext): React.ReactElement | null;
  renderModals(context: IModalContext): React.ReactElement | null;
  validateDefineStep(formValues: any): string | true;
  marshalDefineStep(formValues: any, context: IMarshalContext): Partial<IStrategicOpportunityData>;
  onFormValuesChanged(changedValues: any, context: IFormChangeContext): void;
  loadEditData(data: IStrategicOpportunityEditData, context: IEditLoadContext): void;
  loadPrePopulationData?(params: URLSearchParams, context: IPrePopContext): Promise<void>;
  create(data: IStrategicOpportunityData): Promise<number>;
  update(opportunityId: number, data: IStrategicOpportunityData, shouldPopulate: boolean): Promise<boolean>;
  shouldRepopulateOnEdit(context: IRepopulateContext): boolean;
  getMeasureTypeData(): IKeyNameItem[];
}

Key responsibilities:

  • getDefaultData() — return defaultStrategicOpportunity{Type}Data with correct opportunityType
  • renderDefineStep() — render the type-specific Define component via React.createElement
  • validateDefineStep() — return error string or true
  • marshalDefineStep() — convert form values to data model fields
  • create()/update() — call type-specific service methods
  • getMeasureTypeData() — return the type-specific measure list

Props pattern:

export interface I{Type}StrategyProps {
  form: FormInstance;
  isActive: boolean;
}

References:

  • wizard/charge-code/useChargeCodeStrategy.ts
  • wizard/general-ledger/useGLStrategy.ts

F4. Define Component

Create strategic-opportunities/wizard/{type}/{Type}Define.tsx:

Type-specific Step 2 content — pickers, filters, measure type selection, desired result.

References:

  • wizard/charge-code/ChargeCodeDefine.tsx
  • wizard/general-ledger/GeneralLedgerDefine.tsx

F5. Detail Store

Create strategic-opportunities/data/store/StrategicOpportunity{Type}DetailStoreTreeData.ts:

Extends StoreTreeData with the type-specific grid item interface. Handles:

  • Loading data into tree structure
  • Goal editing (onChange)
  • Row config by dimensions

References:

  • data/store/StrategicOpportunityDetailStoreTreeData.ts (CC)
  • data/store/StrategicOpportunityGLDetailStoreTreeData.ts (GL)

F6. Detail Grid

Create strategic-opportunities/detail/StrategicOpportunity{Type}DetailGrid.tsx:

DataGrid component with type-specific columns. Receives root, columns, levelsCount, loading, expandedKeys, onChange, isEditable.

References:

  • detail/StrategicOpportunityDetailGrid.tsx (CC)
  • detail/StrategicOpportunityGLDetailGrid.tsx (GL)

F7. Layout Config

In strategic-opportunities/detail/data/IStrategicOpportunityLayout.ts:

  1. Add default layout:
export const defaultStrategicOpportunity{Type}Layout: IStrategicOpportunityLayout = {
  rows: [strategicOpportunityDetailDimensionTotal, ...default{Type}Dimensions],
  columns: [...default{Type}Columns]
};
  1. Add layout config:
export const {type}OpportunityLayoutConfig: IDetailLayoutConfig = {
  defaultLayout: defaultStrategicOpportunity{Type}Layout,
  rowLayoutList: {type}RowLayoutList,
  columnLayoutList: {type}ColumnLayoutList,
  requiredRows: [StrategicOpportunityDetailDimension.{TypeDimension}],
  requiredColumns: [{Type}DetailField.{RequiredColumn}]
};
  1. Update factory function:
export function getDetailLayoutConfig(...): IDetailLayoutConfig {
  if (opportunityType === OpportunityTypes.StrategicOpportunity{Type}) {
    return {type}OpportunityLayoutConfig;
  }
  // ... existing cases
}

F8. Dimension Enums

In strategic-opportunities/detail/data/StrategicOpportunityDetailDimension.ts:

  1. Add dimension values to StrategicOpportunityDetailDimension enum (if new dimensions needed)
  2. Add dimension ID to StrategicOpportunityDetailDimensionId enum
  3. Create detail field enum:
export enum StrategicOpportunity{Type}DetailField {
  // Type-specific grid columns
}
  1. Create column name/header maps
  2. Create field section enum + section-to-field mappings
  3. Add helper functions for parsing columns/dimensions by string

F9. Service Methods

In strategic-opportunities/data/strategicOpportunityService.tsx, add:

createOpportunity{Type}: async (opportunity) => httpPost(`${url}/{type}`, opportunity),
updateOpportunity{Type}: async (id, opportunity, shouldPopulate) =>
  httpPut(`${url}/{type}/${id}`, { opportunity, shouldPopulateOpportunityDetails: shouldPopulate }),
getOpportunity{Type}: async (id) => httpGet(`${url}/${id}/GetOpportunity{Type}`),
getOpportunity{Type}DetailData: async (id, isTracking, dimensions) =>
  httpPost(`${url}/${id}/${isTracking ? '{type}-tracking-detail' : '{type}-detail'}`, dimensions),
updateOpportunity{Type}DetailsData: async (id, data) =>
  httpPut(`${url}/${id}/{type}-details`, data),

Also update IStrategicOpportunityService.ts interface with the new methods.

F10. Wizard Integration

In strategic-opportunities/wizard/StrategicOpportunity.tsx:

  1. Import the new strategy hook:
import { use{Type}Strategy } from './{type}/use{Type}Strategy';
  1. Add hook call (must always be called — React rules of hooks):
const {type}Strategy = use{Type}Strategy({ form, isActive: opportunityType === OpportunityTypes.StrategicOpportunity{Type} });
  1. Update activeStrategy selection:
const is{Type} = opportunityType === OpportunityTypes.StrategicOpportunity{Type};
// All hooks always called
const ccStrategy = useChargeCodeStrategy({ form, isActive: !isGL && !is{Type} });
const glStrategy = useGLStrategy({ form, isActive: isGL });
const {type}Strategy = use{Type}Strategy({ form, isActive: is{Type} });
const activeStrategy = is{Type} ? {type}Strategy : isGL ? glStrategy : ccStrategy;

F11. Routes

In shared/Navigation.tsx, add create and edit routes:

<Route
  path={['/strategic-opportunities/create/{type}']}
  exact
  component={(props) => StrategicOpportunity({ ...props, opportunityType: OpportunityTypes.StrategicOpportunity{Type} })}
  key='/strategic-opportunities/create/{type}'
/>
<Route
  path={['/strategic-opportunities/edit/{type}/:id']}
  exact
  component={(props) => StrategicOpportunity({ ...props, opportunityType: OpportunityTypes.StrategicOpportunity{Type} })}
  key='/strategic-opportunities/edit/{type}'
/>

F12. List Page

In strategic-opportunities/opportunities/StrategicItems.tsx, add to the ButtonMenu items:

items={[
  { key: 'charge-code', label: 'Charge Code' },
  { key: 'general-ledger', label: 'General Ledger' },
  { key: '{type}', label: '{Display Name}' }
]}

And handle the click:

onClick={(e) => {
  // ... existing cases
  else if (e.key === '{type}') {
    history.push('/strategic-opportunities/create/{type}');
  }
}}

Cross-Cutting

X1. OpportunityTypes Enum

Both backend and frontend must have matching values:

BackendBiz/Generics/OpportunityTypes.cs (or wherever OpportunityTypes is defined) Frontendopportunities/opportunities/data/IAllOpportunity.ts

Current values:

StrategicOpportunityChargeCode = 17 (approx)
StrategicOpportunityGL = 18 (approx)

New type should be the next sequential value.

X2. Detail Page Integration

In strategic-opportunities/detail/StrategicOpportunityDetailPage.tsx, add branches for:

  1. storeTreeData selection (around line 181):
if (is{Type}) {
  return new StrategicOpportunity{Type}DetailStoreTreeData(DefaultGridItem);
}
  1. Detail data fetching (around line 252):
if (is{Type}) {
  // Fetch type-specific detail data
}
  1. Grid column parsing (around line 305):
if (is{Type}) {
  _gridColumns = getStrategicOpportunity{Type}DetailColumnsByString(layout.columns);
}
  1. Grid rendering (around line 853):
is{Type} ? (
  <StrategicOpportunity{Type}DetailGrid ... />
) : isGL ? (
  ...
)
  1. Save handler — map dirty data to goal values for the new type.

  2. Edit navigation path:

const typePath = is{Type} ? '{type}' : isGL ? 'general-ledger' : 'charge-code';
  1. Comments drawer opportunityType prop.

X3. Testing

Backend:

  • Unit tests for the detail service
  • Unit tests for the distribution process
  • Unit tests for new service methods

Frontend:

  • Unit test for the strategy hook (validate, marshal, create/update)
  • Unit test for the Define component
  • Unit test for the detail grid component

File Reference Table

Pattern CC Reference GL Reference
Entity StrategicOpportunityChargeCode.cs StrategicOpportunityGL.cs
Create DTO StrategicOpportunityChargeCodeDto.cs StrategicOpportunityGLDto.cs
Update DTO StrategicOpportunityChargeCodeUpdateDto.cs StrategicOpportunityGLUpdateDto.cs
Detail Service StrategicChargeCodeOpportunityDetailService.cs StrategicGLOpportunityDetailService.cs
Distribution DistributionProcess/ChargeCode/ DistributionProcess/GL/
Query Builder StrategicOpportunityDetailQueryBuilder.cs StrategicOpportunityGLDetailQueryBuilder.cs
Service Query StrategicOpportunityServiceQuery.cs StrategicOpportunityGLServiceQuery.cs
Data Model IStrategicOpportunityChargeCodeData.ts IStrategicOpportunityGLData.ts
Strategy Hook useChargeCodeStrategy.ts useGLStrategy.ts
Define Component ChargeCodeDefine.tsx GeneralLedgerDefine.tsx
Store StrategicOpportunityDetailStoreTreeData.ts StrategicOpportunityGLDetailStoreTreeData.ts
Detail Grid StrategicOpportunityDetailGrid.tsx StrategicOpportunityGLDetailGrid.tsx

Tips and Gotchas

  1. TPT PK collision: Always check the generated migration for duplicate PK constraint names. Rename pk_strategic_opportunity to pk_strategic_opportunity_{type}.

  2. React hooks order: All strategy hooks must be called unconditionally in StrategicOpportunity.tsx (React rules of hooks). Use the isActive prop to no-op inactive strategies.

  3. MeasureType sync: Backend MeasureType enum values must exactly match frontend MeasureType const enum values. Double-check after adding.

  4. Layout compatibility: The isLayoutCompatible() function checks requiredColumns. Make sure your type's required columns are unique to avoid stale layouts from other types being loaded.

  5. Formatting: Run npx eslint --fix <files> after editing (not npx prettier --write).