diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index f1a27e649..f2d0521ec 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -1394,6 +1394,7 @@ "Goal Name": "Goal Name", "Goal Name is a required field": "Goal Name is a required field", "Goal Status": "Goal Status", + "Goals were last run and sent on {{date}} at {{time}}.": "Goals were last run and sent on {{date}} at {{time}}.", "Good Afternoon,": "Good Afternoon,", "Good Evening,": "Good Evening,", "Good Morning,": "Good Morning,", @@ -2367,6 +2368,7 @@ "Program Based": "Program Based", "Progress": "Progress", "Prompt": "Prompt", + "Provide Training Cost": "Provide Training Cost", "Puerto Rico": "Puerto Rico", "Purchase": "Purchase", "Qatar": "Qatar", @@ -3073,6 +3075,7 @@ "Traditional 403(b) Deduction": "Traditional 403(b) Deduction", "Training": "Training", "Training Cost": "Training Cost", + "Training costs are required to run & send goals.": "Training costs are required to run & send goals.", "Training Size": "Training Size", "Training, conferences, supplies, evangelism & discipleship materials, communication with ministry partners, ministry travel expenses, etc.": "Training, conferences, supplies, evangelism & discipleship materials, communication with ministry partners, ministry travel expenses, etc.", "Transactions": "Transactions", diff --git a/src/components/HrTools/MpdGoalAdmin/CohortBar/CohortBar.test.tsx b/src/components/HrTools/MpdGoalAdmin/CohortBar/CohortBar.test.tsx index 0c8c877fe..4defafba9 100644 --- a/src/components/HrTools/MpdGoalAdmin/CohortBar/CohortBar.test.tsx +++ b/src/components/HrTools/MpdGoalAdmin/CohortBar/CohortBar.test.tsx @@ -9,11 +9,13 @@ import { MpdGoalAdminProvider } from '../MpdGoalAdminContext'; import { NewStaffCohortAttendeesQuery, NewStaffCohortsQuery, + UpdateNewStaffCohortMutation, } from '../NewStaffCohorts.generated'; import { attendeesMock, cohortsMock, cohortsWithoutCostsMock, + updatedCohortMock, } from '../mpdGoalAdminMocks'; import { CohortBar } from './CohortBar'; @@ -38,10 +40,13 @@ const TestComponent: React.FC = ({ mocks={{ NewStaffCohorts: withoutCosts ? cohortsWithoutCostsMock : cohortsMock, NewStaffCohortAttendees: attendeesMock(), + // Normalizes over the selected cohort so a save clears the gate. + UpdateNewStaffCohort: updatedCohortMock('fall-nso-2026'), }} onCall={mutationSpy} > @@ -54,9 +59,13 @@ const TestComponent: React.FC = ({ ); /** Waits for the cohort first; clicking early opens the modal with no cohort. */ -const openModal = async (screen: ReturnType) => { +const openModal = async ( + screen: ReturnType, + // A cohort missing its costs prompts to provide them instead. + name: string = 'View/Edit', +) => { await screen.findByText('Fall NSO 2026'); - userEvent.click(screen.getByRole('button', { name: 'View/Edit' })); + userEvent.click(screen.getByRole('button', { name })); return screen.findByRole('heading', { name: /Training Costs for/ }); }; @@ -72,6 +81,56 @@ describe('CohortBar', () => { expect(await findByText('8/10/2026')).toBeInTheDocument(); }); + it('renders the disabled View/Edit link while the cohort is still loading', () => { + const { getByRole, queryByRole } = render(); + + // The prompt must not flash before the cohorts query has resolved. + expect(getByRole('button', { name: 'View/Edit' })).toBeDisabled(); + expect( + queryByRole('button', { name: 'Provide Training Cost' }), + ).not.toBeInTheDocument(); + }); + + it('prompts to provide the costs when the cohort has none', async () => { + const { findByText, findByRole, queryByRole } = render( + , + ); + + await findByText('Fall NSO 2026'); + expect( + await findByRole('button', { name: 'Provide Training Cost' }), + ).toBeInTheDocument(); + expect( + queryByRole('button', { name: 'View/Edit' }), + ).not.toBeInTheDocument(); + }); + + it('explains why the costs are needed when the cohort has none', async () => { + const { findByText, findByRole } = render(); + + await findByText('Fall NSO 2026'); + const prompt = await findByRole('button', { + name: 'Provide Training Cost', + }); + + userEvent.hover(prompt); + expect( + await findByText('Training costs are required to run & send goals.'), + ).toBeInTheDocument(); + }); + + it('opens the modal from the Provide Training Cost prompt', async () => { + const screen = render(); + const { findByText, findByRole, getByRole } = screen; + + await findByText('Fall NSO 2026'); + userEvent.click(getByRole('button', { name: 'Provide Training Cost' })); + + expect( + await findByRole('heading', { name: /Training Costs for/ }), + ).toHaveTextContent('Training Costs for Fall NSO 2026'); + }); + it('opens the Edit Training Costs modal for the selected cohort', async () => { const screen = render(); const { queryByRole } = screen; @@ -149,10 +208,30 @@ describe('CohortBar', () => { ); }); + it('replaces the prompt with View/Edit once the costs are saved', async () => { + const screen = render(); + const { findByRole, getAllByRole, queryByRole } = screen; + await openModal(screen, 'Provide Training Cost'); + + // Apply stays disabled until all thirteen costs are entered. + getAllByRole('spinbutton').forEach((input, index) => + userEvent.type(input, String((index + 1) * 100)), + ); + const apply = await findByRole('button', { name: 'Apply' }); + await waitFor(() => expect(apply).toBeEnabled()); + userEvent.click(apply); + + expect(await findByRole('button', { name: 'View/Edit' })).toBeEnabled(); + expect( + queryByRole('button', { name: 'Provide Training Cost' }), + ).not.toBeInTheDocument(); + // Typing all thirteen fields exceeds the default 5s timeout under load. + }, 20000); + it('keeps APPLY disabled until every cost is entered', async () => { const screen = render(); const { findByRole } = screen; - await openModal(screen); + await openModal(screen, 'Provide Training Cost'); // The cohort has no saved costs, so the form opens blank. expect(await findByRole('button', { name: 'Apply' })).toBeDisabled(); diff --git a/src/components/HrTools/MpdGoalAdmin/CohortBar/CohortBar.tsx b/src/components/HrTools/MpdGoalAdmin/CohortBar/CohortBar.tsx index 9a1fea8d1..704f14eae 100644 --- a/src/components/HrTools/MpdGoalAdmin/CohortBar/CohortBar.tsx +++ b/src/components/HrTools/MpdGoalAdmin/CohortBar/CohortBar.tsx @@ -1,10 +1,12 @@ import React, { useState } from 'react'; +import { ErrorOutline } from '@mui/icons-material'; import { Box, Link, MenuItem, Stack, TextField, + Tooltip, Typography, } from '@mui/material'; import { useSnackbar } from 'notistack'; @@ -46,6 +48,10 @@ export const CohortBar: React.FC = () => { } = useMpdGoalAdmin(); const [trainingCostsOpen, setTrainingCostsOpen] = useState(false); + // Only a loaded cohort can be short its costs; an absent one is still loading. + const needsTrainingCosts = + !!selectedCohort && !selectedCohort.hasTrainingCosts; + const handleSaveTrainingCosts = async (costs: TrainingCosts) => { if (!selectedCohort) { return; @@ -63,6 +69,38 @@ export const CohortBar: React.FC = () => { setTrainingCostsOpen(false); }; + const trainingCostLink = ( + setTrainingCostsOpen(true)} + onMouseEnter={preloadEditTrainingCostsModal} + sx={ + needsTrainingCosts + ? (theme) => ({ + // MUI's warning palette is only 3.79:1 on white; the Cru vermilion + // token clears WCAG AA for body2's 14px text. + color: theme.palette.statusWarning.main, + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + }) + : undefined + } + > + {needsTrainingCosts ? ( + <> + + {t('Provide Training Cost')} + + ) : ( + t('View/Edit') + )} + + ); + return ( { {selectedCohort?.nsoDate ?? '—'} - setTrainingCostsOpen(true)} - onMouseEnter={preloadEditTrainingCostsModal} - > - {t('View/Edit')} - + {/* Only the costs-missing branch is tooltipped, and a disabled child + would need a wrapper element for the tooltip to fire. */} + {needsTrainingCosts ? ( + + {trainingCostLink} + + ) : ( + trainingCostLink + )} {trainingCostsOpen && ( = ({ neverSent = false }) => ( + + + mocks={{ + NewStaffCohorts: neverSent ? neverSentMock : cohortsMock, + NewStaffCohortAttendees: attendeesMock(), + }} + onCall={onCall} + > + + + + + +); + +describe('GoalsSentBanner', () => { + it('reports when the cohort goals were last run and sent', async () => { + const { findByRole } = render(); + + expect(await findByRole('status')).toHaveTextContent( + 'Goals were last run and sent on 8/10/2026 at 3:40 PM UTC.', + ); + }); + + it('renders nothing until the cohort has been sent at least once', async () => { + const { queryByRole } = render(); + + // The banner renders nothing here, so settle on the cohort query instead; + // asserting immediately would pass on the pre-load render either way. + await waitFor(() => + expect(onCall).toHaveGraphqlOperation('NewStaffCohortAttendees'), + ); + expect(queryByRole('status')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/HrTools/MpdGoalAdmin/GoalsSentBanner/GoalsSentBanner.tsx b/src/components/HrTools/MpdGoalAdmin/GoalsSentBanner/GoalsSentBanner.tsx new file mode 100644 index 000000000..79398baa0 --- /dev/null +++ b/src/components/HrTools/MpdGoalAdmin/GoalsSentBanner/GoalsSentBanner.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Alert } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useLocale } from 'src/hooks/useLocale'; +import { dateFormatShort, timeFormat } from 'src/lib/intlFormat'; +import { useMpdGoalAdmin } from '../MpdGoalAdminContext'; + +/** + * Confirms the cohort's most recent Run & Send batch. Absent until the first + * send, so its absence is itself meaningful — don't render a placeholder. + */ +export const GoalsSentBanner: React.FC = () => { + const { t } = useTranslation(); + const locale = useLocale(); + const { selectedCohort } = useMpdGoalAdmin(); + const goalsSentAt = selectedCohort?.goalsSentAt; + + if (!goalsSentAt) { + return null; + } + + return ( + + {t('Goals were last run and sent on {{date}} at {{time}}.', { + date: dateFormatShort(goalsSentAt, locale), + time: timeFormat(goalsSentAt, locale), + })} + + ); +}; diff --git a/src/components/HrTools/MpdGoalAdmin/MpdGoalAdmin.test.tsx b/src/components/HrTools/MpdGoalAdmin/MpdGoalAdmin.test.tsx index 5ea3e5f31..43fd969c3 100644 --- a/src/components/HrTools/MpdGoalAdmin/MpdGoalAdmin.test.tsx +++ b/src/components/HrTools/MpdGoalAdmin/MpdGoalAdmin.test.tsx @@ -58,6 +58,17 @@ describe('MpdGoalAdmin', () => { expect(await findByText('John & Jane Doe')).toBeInTheDocument(); }); + it('reports when the cohort goals were last run and sent', async () => { + const { findByText } = renderMain(); + + // Query the sentence, not role="status", which the null state also uses. + expect( + await findByText( + 'Goals were last run and sent on 8/10/2026 at 3:40 PM UTC.', + ), + ).toBeInTheDocument(); + }); + it('shows a loading indicator until the attendees arrive', () => { const { getByRole, queryByRole } = renderMain(); diff --git a/src/components/HrTools/MpdGoalAdmin/MpdGoalAdmin.tsx b/src/components/HrTools/MpdGoalAdmin/MpdGoalAdmin.tsx index f61c5c55d..d5fb0093e 100644 --- a/src/components/HrTools/MpdGoalAdmin/MpdGoalAdmin.tsx +++ b/src/components/HrTools/MpdGoalAdmin/MpdGoalAdmin.tsx @@ -23,6 +23,7 @@ import { } from 'src/components/Shared/MultiPageLayout/MultiPageHeader'; import { getHeaderTitleAccess } from 'src/components/Shared/MultiPageLayout/helpers'; import { CohortBar } from './CohortBar/CohortBar'; +import { GoalsSentBanner } from './GoalsSentBanner/GoalsSentBanner'; import { GoalsTable } from './GoalsTable/GoalsTable'; import { GoalsTableToolbar } from './GoalsTableToolbar/GoalsTableToolbar'; import { useMpdGoalAdmin } from './MpdGoalAdminContext'; @@ -57,6 +58,7 @@ const ActiveGoalsContent: React.FC = () => { return ( <> + {/* Surface query failures here rather than as an empty table. */} {error ? ( diff --git a/src/components/HrTools/MpdGoalAdmin/NewStaffCohorts.graphql b/src/components/HrTools/MpdGoalAdmin/NewStaffCohorts.graphql index 65014021d..b4601fe2f 100644 --- a/src/components/HrTools/MpdGoalAdmin/NewStaffCohorts.graphql +++ b/src/components/HrTools/MpdGoalAdmin/NewStaffCohorts.graphql @@ -24,6 +24,7 @@ query NewStaffCohorts($after: String) { name trainingSize date + goalsSentAt hasTrainingCosts canRunAndSend runAndSendBlockers diff --git a/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminHelpers.test.ts b/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminHelpers.test.ts index 7940f9690..f123021fa 100644 --- a/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminHelpers.test.ts +++ b/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminHelpers.test.ts @@ -1,3 +1,4 @@ +import { DateTime } from 'luxon'; import { NewStaffCohortAttendeeGoalStatusEnum, NewStaffQuestionnaireMaritalStatusEnum, @@ -75,6 +76,21 @@ describe('cohortNodeToCohort', () => { ).toBe('—'); }); + it('keeps goalsSentAt as a DateTime the banner can format', () => { + const cohort = cohortNodeToCohort(cohortWithCosts, 'en-US'); + + expect(cohort.goalsSentAt?.toISO()).toBe( + DateTime.fromISO('2026-08-10T15:40:00Z').toISO(), + ); + }); + + it('leaves goalsSentAt null until the first Run & Send', () => { + expect( + cohortNodeToCohort({ ...cohortWithCosts, goalsSentAt: null }, 'en-US') + .goalsSentAt, + ).toBeNull(); + }); + it('leaves trainingCosts undefined when costs are not fully entered', () => { expect( cohortNodeToCohort(cohortWithoutCosts, 'en-US').trainingCosts, diff --git a/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminHelpers.ts b/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminHelpers.ts index 94d2eeec6..572e50717 100644 --- a/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminHelpers.ts +++ b/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminHelpers.ts @@ -92,6 +92,9 @@ export interface Cohort { trainingSize: number; /** Display string, e.g. "08/10/2026"; "—" when the API has no date yet. */ nsoDate: string; + /** When the last Run & Send batch finished; null until the first send. Kept + * unformatted so the banner can split date and time across its own sentence. */ + goalsSentAt: DateTime | null; hasTrainingCosts: boolean; /** Saved training cost figures; undefined until every cost is entered. */ trainingCosts?: TrainingCosts; @@ -131,6 +134,7 @@ export const cohortNodeToCohort = ( nsoDate: node.date ? dateFormatShort(DateTime.fromISO(node.date), locale) : '—', + goalsSentAt: node.goalsSentAt ? DateTime.fromISO(node.goalsSentAt) : null, hasTrainingCosts: node.hasTrainingCosts, trainingCosts: cohortToTrainingCosts(node), }); diff --git a/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminMocks.ts b/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminMocks.ts index 8838fcd16..5921136ad 100644 --- a/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminMocks.ts +++ b/src/components/HrTools/MpdGoalAdmin/mpdGoalAdminMocks.ts @@ -58,6 +58,7 @@ export const cohortsMock: NewStaffCohortsQuery = { name: 'Fall NSO 2026', trainingSize: 13, date: '2026-08-10', + goalsSentAt: '2026-08-10T15:40:00Z', hasTrainingCosts: true, canRunAndSend: true, runAndSendBlockers: [], @@ -68,6 +69,7 @@ export const cohortsMock: NewStaffCohortsQuery = { name: 'Spring NSO 2027', trainingSize: 2, date: '2027-01-11', + goalsSentAt: null, hasTrainingCosts: false, canRunAndSend: false, runAndSendBlockers: [], @@ -85,6 +87,8 @@ export const cohortsWithoutCostsMock: NewStaffCohortsQuery = { { ...cohortsMock.newStaffCohorts.nodes[0], hasTrainingCosts: false, + // Costs gate Run & Send, so goals cannot already have gone out. + goalsSentAt: null, ...noCostFields, }, ], diff --git a/src/lib/intlFormat.test.ts b/src/lib/intlFormat.test.ts index 87582a71a..a29f5d885 100644 --- a/src/lib/intlFormat.test.ts +++ b/src/lib/intlFormat.test.ts @@ -13,6 +13,7 @@ import { numberFormat, parseNumberFromCurrencyString, percentageFormat, + timeFormat, validateAndFormatInvalidDate, } from './intlFormat'; @@ -370,6 +371,31 @@ describe('intlFormat', () => { }); }); + describe('timeFormat', () => { + const locale = 'en-US'; + it('returns the hour, minute and time zone without the date', () => { + const time = timeFormat(DateTime.local(2026, 8, 10, 15, 40), locale); + + expect(time).toBe('3:40 PM UTC'); + }); + + it('uses a 24-hour clock where the locale expects one', () => { + expect(timeFormat(DateTime.local(2026, 8, 10, 15, 40), 'fr')).toBe( + '15:40 UTC', + ); + }); + + it('returns es-419 formatted', () => { + expect(timeFormat(DateTime.local(2026, 8, 10, 15, 40), 'es-419')).toBe( + '3:40 p.m. UTC', + ); + }); + + it('returns an empty string when there is no time', () => { + expect(timeFormat(null, locale)).toBe(''); + }); + }); + describe('formatRelativeTime', () => { const locale = 'en-US'; diff --git a/src/lib/intlFormat.ts b/src/lib/intlFormat.ts index 9c6db1603..e7e236ad2 100644 --- a/src/lib/intlFormat.ts +++ b/src/lib/intlFormat.ts @@ -250,6 +250,19 @@ export const dateTimeFormat = ( }).format(date.toJSDate()); }; +export const timeFormat = (date: DateTime | null, locale: string): string => { + if (date === null) { + return ''; + } + // Server instants are rendered in the viewer's zone, so name it to keep + // team-wide timestamps comparable across time zones. + return new Intl.DateTimeFormat(locale, { + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short', + }).format(date.toJSDate()); +}; + export const validateAndFormatInvalidDate = ( year: number | null | undefined, month: number,