diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index f9c319fbbc..cfaac52622 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -183,9 +183,10 @@
"Enter a valid amount": "Enter a valid amount",
"Faith and Finance": "Faith and Finance",
"Family": "Family",
+ "IBS Cost": "IBS Cost",
"Individual (1 in room)": "Individual (1 in room)",
"Individual (2 in room)": "Individual (2 in room)",
- "NSO & IBS Cost": "NSO & IBS Cost",
+ "NSO Cost": "NSO Cost",
"Please enter the cost details that apply to this training. All fields are required.": "Please enter the cost details that apply to this training. All fields are required.",
"Refresh Retreat": "Refresh Retreat",
"Training Costs": "Training Costs",
diff --git a/src/components/HrTools/MpdGoalAdmin/EditTrainingCostsModal/EditTrainingCostsModal.test.tsx b/src/components/HrTools/MpdGoalAdmin/EditTrainingCostsModal/EditTrainingCostsModal.test.tsx
index fcb68fed03..035042b1c2 100644
--- a/src/components/HrTools/MpdGoalAdmin/EditTrainingCostsModal/EditTrainingCostsModal.test.tsx
+++ b/src/components/HrTools/MpdGoalAdmin/EditTrainingCostsModal/EditTrainingCostsModal.test.tsx
@@ -14,17 +14,19 @@ beforeEach(() => {
});
const filledCosts: TrainingCosts = {
- nsoIbsIndividual1InRoom: 100,
- nsoIbsIndividual2InRoom: 200,
- nsoIbsCouple: 300,
- nsoIbsFamily: 400,
- refreshRetreatSingle: 500,
- refreshRetreatCouple: 600,
- faithAndFinanceSingle: 700,
- faithAndFinanceCouple: 800,
- cruConferenceSingle: 900,
- cruConferenceCouple: 1000,
- cruConferenceFamily: 1100,
+ nsoIndividual1InRoom: 100,
+ nsoIndividual2InRoom: 200,
+ nsoCouple: 300,
+ nsoFamily: 400,
+ ibsSingle: 500,
+ ibsCouple: 600,
+ refreshRetreatSingle: 700,
+ refreshRetreatCouple: 800,
+ faithAndFinanceSingle: 900,
+ faithAndFinanceCouple: 1000,
+ cruConferenceSingle: 1100,
+ cruConferenceCouple: 1200,
+ cruConferenceFamily: 1300,
};
/**
@@ -39,18 +41,25 @@ const fieldsBySection: {
fields: { label: string; value: number }[];
}[] = [
{
- title: 'NSO & IBS Cost',
+ title: 'NSO Cost',
fields: [
{
label: 'Individual (1 in room)',
- value: filledCosts.nsoIbsIndividual1InRoom,
+ value: filledCosts.nsoIndividual1InRoom,
},
{
label: 'Individual (2 in room)',
- value: filledCosts.nsoIbsIndividual2InRoom,
+ value: filledCosts.nsoIndividual2InRoom,
},
- { label: 'Couple', value: filledCosts.nsoIbsCouple },
- { label: 'Family', value: filledCosts.nsoIbsFamily },
+ { label: 'Couple', value: filledCosts.nsoCouple },
+ { label: 'Family', value: filledCosts.nsoFamily },
+ ],
+ },
+ {
+ title: 'IBS Cost',
+ fields: [
+ { label: 'Single', value: filledCosts.ibsSingle },
+ { label: 'Couple', value: filledCosts.ibsCouple },
],
},
{
@@ -106,8 +115,8 @@ const TestComponent: React.FC<
);
describe('EditTrainingCostsModal', () => {
- it('renders the cohort-specific title, subtitle and every section', () => {
- const { getByText, getByRole } = render();
+ it('renders the cohort-specific title, subtitle and every section in order', () => {
+ const { getByText, getByRole, getAllByRole } = render();
expect(
getByRole('heading', { name: 'Training Costs for Fall NSO 2026' }),
).toBeInTheDocument();
@@ -116,10 +125,14 @@ describe('EditTrainingCostsModal', () => {
'Please enter the cost details that apply to this training. All fields are required.',
),
).toBeInTheDocument();
- expect(getByText('NSO & IBS Cost')).toBeInTheDocument();
- expect(getByText('Refresh Retreat')).toBeInTheDocument();
- expect(getByText('Faith and Finance')).toBeInTheDocument();
- expect(getByText('Cru Conference')).toBeInTheDocument();
+ // Assert the section headings as an ordered list: NSO and IBS being distinct
+ // sections in this order is the point of the layout, so order is asserted
+ // rather than mere presence.
+ expect(
+ getAllByRole('heading', { level: 3 }).map(
+ (heading) => heading.textContent,
+ ),
+ ).toEqual(fieldsBySection.map((section) => section.title));
});
it('falls back to a generic title when no cohort name is given', () => {
@@ -129,10 +142,10 @@ describe('EditTrainingCostsModal', () => {
).toBeInTheDocument();
});
- it('renders all eleven cost inputs, initially blank', () => {
+ it('renders all thirteen cost inputs, initially blank', () => {
const { getAllByRole } = render();
const inputs = getAllByRole('spinbutton');
- expect(inputs).toHaveLength(11);
+ expect(inputs).toHaveLength(13);
inputs.forEach((input) => expect(input).toHaveValue(null));
});
@@ -208,9 +221,9 @@ describe('EditTrainingCostsModal', () => {
await userEvent.click(apply);
await waitFor(() => expect(onSave).toHaveBeenCalledWith(filledCosts));
- // Typing into all eleven fields exceeds the default 5s timeout when the
+ // Typing into all thirteen fields exceeds the default 5s timeout when the
// full suite runs in parallel, so give this test extra headroom.
- }, 15000);
+ }, 20000);
it('closes via the Cancel button', async () => {
const { getByRole } = render();
diff --git a/src/components/HrTools/MpdGoalAdmin/EditTrainingCostsModal/EditTrainingCostsModal.tsx b/src/components/HrTools/MpdGoalAdmin/EditTrainingCostsModal/EditTrainingCostsModal.tsx
index 9c59e9133c..58460136dc 100644
--- a/src/components/HrTools/MpdGoalAdmin/EditTrainingCostsModal/EditTrainingCostsModal.tsx
+++ b/src/components/HrTools/MpdGoalAdmin/EditTrainingCostsModal/EditTrainingCostsModal.tsx
@@ -23,7 +23,7 @@ import { TrainingCosts } from '../mpdGoalAdminHelpers';
type FieldName = keyof TrainingCosts;
/**
- * The 11 cost fields as held by Formik. Empty inputs are `''`; because these
+ * The cost fields as held by Formik. Empty inputs are `''`; because these
* are `type="number"` inputs, Formik's `handleChange` coerces entered text to
* a `number`, so a populated field is a `number`.
*/
@@ -53,10 +53,12 @@ export interface EditTrainingCostsModalProps {
}
const FIELD_NAMES: FieldName[] = [
- 'nsoIbsIndividual1InRoom',
- 'nsoIbsIndividual2InRoom',
- 'nsoIbsCouple',
- 'nsoIbsFamily',
+ 'nsoIndividual1InRoom',
+ 'nsoIndividual2InRoom',
+ 'nsoCouple',
+ 'nsoFamily',
+ 'ibsSingle',
+ 'ibsCouple',
'refreshRetreatSingle',
'refreshRetreatCouple',
'faithAndFinanceSingle',
@@ -103,30 +105,47 @@ export const EditTrainingCostsModal: React.FC = ({
const sections = useMemo(
() => [
{
- title: t('NSO & IBS Cost'),
+ title: t('NSO Cost'),
fields: [
{
- name: 'nsoIbsIndividual1InRoom',
+ name: 'nsoIndividual1InRoom',
label: t('Individual (1 in room)'),
size: { xs: 12, sm: 6, md: 3 },
},
{
- name: 'nsoIbsIndividual2InRoom',
+ name: 'nsoIndividual2InRoom',
label: t('Individual (2 in room)'),
size: { xs: 12, sm: 6, md: 3 },
},
{
- name: 'nsoIbsCouple',
+ name: 'nsoCouple',
label: t('Couple'),
size: { xs: 12, sm: 6, md: 3 },
},
{
- name: 'nsoIbsFamily',
+ name: 'nsoFamily',
label: t('Family'),
size: { xs: 12, sm: 6, md: 3 },
},
],
},
+ // IBS is costed separately from NSO so an attendee who goes to NSO but not
+ // IBS can be priced without the IBS portion.
+ {
+ title: t('IBS Cost'),
+ fields: [
+ {
+ name: 'ibsSingle',
+ label: t('Single'),
+ size: { xs: 12, sm: 6 },
+ },
+ {
+ name: 'ibsCouple',
+ label: t('Couple'),
+ size: { xs: 12, sm: 6 },
+ },
+ ],
+ },
{
title: t('Refresh Retreat'),
fields: [
diff --git a/src/components/HrTools/MpdGoalAdmin/MpdGoalAdminContext.test.tsx b/src/components/HrTools/MpdGoalAdmin/MpdGoalAdminContext.test.tsx
index 99321a4658..3b0bdeb65d 100644
--- a/src/components/HrTools/MpdGoalAdmin/MpdGoalAdminContext.test.tsx
+++ b/src/components/HrTools/MpdGoalAdmin/MpdGoalAdminContext.test.tsx
@@ -4,17 +4,19 @@ import { mockCohorts } from './mockData';
import { MpdGoalAdminTabEnum, TrainingCosts } from './mpdGoalAdminHelpers';
const trainingCosts: TrainingCosts = {
- nsoIbsIndividual1InRoom: 100,
- nsoIbsIndividual2InRoom: 200,
- nsoIbsCouple: 300,
- nsoIbsFamily: 400,
- refreshRetreatSingle: 500,
- refreshRetreatCouple: 600,
- faithAndFinanceSingle: 700,
- faithAndFinanceCouple: 800,
- cruConferenceSingle: 900,
- cruConferenceCouple: 1000,
- cruConferenceFamily: 1100,
+ nsoIndividual1InRoom: 100,
+ nsoIndividual2InRoom: 200,
+ nsoCouple: 300,
+ nsoFamily: 400,
+ ibsSingle: 500,
+ ibsCouple: 600,
+ refreshRetreatSingle: 700,
+ refreshRetreatCouple: 800,
+ faithAndFinanceSingle: 900,
+ faithAndFinanceCouple: 1000,
+ cruConferenceSingle: 1100,
+ cruConferenceCouple: 1200,
+ cruConferenceFamily: 1300,
};
const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
diff --git a/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminHelpers.ts b/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminHelpers.ts
index dfd042134e..2281208ceb 100644
--- a/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminHelpers.ts
+++ b/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminHelpers.ts
@@ -25,15 +25,24 @@ export interface StaffGoalRow {
/**
* Per-training cost figures captured in the "Edit Training Costs" modal. Every
- * value is a USD amount; the keys mirror the modal's four cost sections. All
+ * value is a USD amount; the keys mirror the modal's five cost sections. All
* fields are required in the UI, so a saved `TrainingCosts` is fully populated.
+ *
+ * There is one key per column in `NewStaffCohort::COST_FIELDS` in mpdx_api, named
+ * to match. Note the columns are nullable while these are required, so wiring this
+ * up will need to decide how an unentered cost arrives. NSO and IBS are separate
+ * so an attendee going to NSO but not IBS can be costed (MPDX-9811; Figma node
+ * 789-32532).
*/
export interface TrainingCosts {
- /** NSO & IBS Cost */
- nsoIbsIndividual1InRoom: number;
- nsoIbsIndividual2InRoom: number;
- nsoIbsCouple: number;
- nsoIbsFamily: number;
+ /** NSO Cost */
+ nsoIndividual1InRoom: number;
+ nsoIndividual2InRoom: number;
+ nsoCouple: number;
+ nsoFamily: number;
+ /** IBS Cost */
+ ibsSingle: number;
+ ibsCouple: number;
/** Refresh Retreat */
refreshRetreatSingle: number;
refreshRetreatCouple: number;