From fd32b1d7bebcb1a351c645e37e2560c754661799 Mon Sep 17 00:00:00 2001 From: zachery with an e <45150570+zweatshirt@users.noreply.github.com> Date: Tue, 12 May 2026 12:58:09 -0500 Subject: [PATCH 1/4] Export OverCapPerson interface and include overUserCap in useCaps return value --- .../HrTools/SalaryCalculator/SalaryCalculation/useCaps.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/HrTools/SalaryCalculator/SalaryCalculation/useCaps.ts b/src/components/HrTools/SalaryCalculator/SalaryCalculation/useCaps.ts index 70ccc5b33d..d209e7e5f1 100644 --- a/src/components/HrTools/SalaryCalculator/SalaryCalculation/useCaps.ts +++ b/src/components/HrTools/SalaryCalculator/SalaryCalculation/useCaps.ts @@ -1,7 +1,7 @@ import { useSalaryCalculator } from '../SalaryCalculatorContext/SalaryCalculatorContext'; import { useFormatters } from '../Shared/useFormatters'; -interface OverCapPerson { +export interface OverCapPerson { /** The name of the person whose salary is over their effective cap */ name: string | null; @@ -13,6 +13,9 @@ interface UseCapsResult { /** The sum of the users' requested gross salaries */ combinedGross: number; + /** Whether the user is over their effective cap */ + overUserCap: boolean; + /** The person whose salary is over their effective cap */ overCapPerson: OverCapPerson | null; } @@ -44,6 +47,7 @@ export const useCaps = (): UseCapsResult => { return { combinedGross, + overUserCap, overCapPerson, }; }; From 0ed8b0f3f5661bcd76b1632464944c485e048d62 Mon Sep 17 00:00:00 2001 From: zachery with an e <45150570+zweatshirt@users.noreply.github.com> Date: Tue, 12 May 2026 12:58:20 -0500 Subject: [PATCH 2/4] Add useSosaBlockOverCap hook and corresponding tests --- .../useSosaBlockOverCap.test.tsx | 74 +++++++++++++++++++ .../SalaryCalculation/useSosaBlockOverCap.ts | 27 +++++++ 2 files changed, 101 insertions(+) create mode 100644 src/components/HrTools/SalaryCalculator/SalaryCalculation/useSosaBlockOverCap.test.tsx create mode 100644 src/components/HrTools/SalaryCalculator/SalaryCalculation/useSosaBlockOverCap.ts diff --git a/src/components/HrTools/SalaryCalculator/SalaryCalculation/useSosaBlockOverCap.test.tsx b/src/components/HrTools/SalaryCalculator/SalaryCalculation/useSosaBlockOverCap.test.tsx new file mode 100644 index 0000000000..13ae9011c1 --- /dev/null +++ b/src/components/HrTools/SalaryCalculator/SalaryCalculation/useSosaBlockOverCap.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { DeepPartial } from 'ts-essentials'; +import { UserPersonTypeEnum } from 'pages/api/graphql-rest.page.generated'; +import { SalaryCalculationQuery } from '../SalaryCalculatorContext/SalaryCalculation.generated'; +import { SalaryCalculatorTestWrapper } from '../SalaryCalculatorTestWrapper'; +import { useSosaBlockOverCap } from './useSosaBlockOverCap'; + +const sosaUser = { + staffInfo: { userPersonType: UserPersonTypeEnum.EmployeeStaffNonRmoSpouse }, +}; + +const overCapMock: DeepPartial = { + calculations: { effectiveCap: 10004, requestedGross: 15000 }, +}; + +const renderUseSosaBlockOverCap = ( + props: Parameters[0], +) => + renderHook(() => useSosaBlockOverCap(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + +describe('useSosaBlockOverCap', () => { + it('blocks when a SOSA user is over their cap', async () => { + const { result } = renderUseSosaBlockOverCap({ + hasSpouse: false, + hcmUser: sosaUser, + salaryRequestMock: overCapMock, + }); + + await waitFor(() => expect(result.current.blockOnCap).toBe(true)); + }); + + it('does not block when a SOSA user is under their cap', async () => { + const { result } = renderUseSosaBlockOverCap({ + hasSpouse: false, + hcmUser: sosaUser, + salaryRequestMock: { + calculations: { effectiveCap: 10004, requestedGross: 10003 }, + }, + }); + + await waitFor(() => expect(result.current.isUserSosa).toBe(true)); + expect(result.current.blockOnCap).toBe(false); + }); + + it('does not block when a SOSA user is exactly at their cap', async () => { + const { result } = renderUseSosaBlockOverCap({ + hasSpouse: false, + hcmUser: sosaUser, + salaryRequestMock: { + calculations: { effectiveCap: 10004, requestedGross: 10004 }, + }, + }); + + await waitFor(() => expect(result.current.isUserSosa).toBe(true)); + expect(result.current.blockOnCap).toBe(false); + }); + + it('does not block when the user is not SOSA, even if over cap', async () => { + const { result } = renderUseSosaBlockOverCap({ + hasSpouse: false, + salaryRequestMock: overCapMock, + }); + + await waitFor(() => expect(result.current.isUserSosa).toBe(false)); + expect(result.current.blockOnCap).toBe(false); + }); +}); diff --git a/src/components/HrTools/SalaryCalculator/SalaryCalculation/useSosaBlockOverCap.ts b/src/components/HrTools/SalaryCalculator/SalaryCalculation/useSosaBlockOverCap.ts new file mode 100644 index 0000000000..6f6a1e6f41 --- /dev/null +++ b/src/components/HrTools/SalaryCalculator/SalaryCalculation/useSosaBlockOverCap.ts @@ -0,0 +1,27 @@ +import { UserPersonTypeEnum } from 'pages/api/graphql-rest.page.generated'; +import { useSalaryCalculator } from '../SalaryCalculatorContext/SalaryCalculatorContext'; +import { useCaps } from './useCaps'; + +interface UseSosaBlockOverCapResult { + /** Whether the user is a SOSA employee (Employee Staff Non-RMO Spouse) */ + isUserSosa: boolean; + + /** Whether the user is a SOSA employee whose saved gross exceeds their effective cap */ + blockOnCap: boolean; +} + +/** + * Determine whether a SOSA user's requested gross salary exceeds their + * effective cap. Requests over their cap may not be submitted online. + */ +export const useSosaBlockOverCap = (): UseSosaBlockOverCapResult => { + const { hcmUser } = useSalaryCalculator(); + const { overUserCap } = useCaps(); + + const isUserSosa = + hcmUser?.staffInfo.userPersonType === + UserPersonTypeEnum.EmployeeStaffNonRmoSpouse; + const blockOnCap = isUserSosa && overUserCap; + + return { isUserSosa, blockOnCap }; +}; From cce257b4211cbef17065a53be78662e024dbe591 Mon Sep 17 00:00:00 2001 From: zachery with an e <45150570+zweatshirt@users.noreply.github.com> Date: Tue, 12 May 2026 12:58:47 -0500 Subject: [PATCH 3/4] Refactor AutosaveTextField to include external error handling and saveOnChange prop --- .../Autosave/AutosaveTextField.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/components/HrTools/SalaryCalculator/Autosave/AutosaveTextField.tsx b/src/components/HrTools/SalaryCalculator/Autosave/AutosaveTextField.tsx index 7586a31efa..5da1fb1beb 100644 --- a/src/components/HrTools/SalaryCalculator/Autosave/AutosaveTextField.tsx +++ b/src/components/HrTools/SalaryCalculator/Autosave/AutosaveTextField.tsx @@ -13,11 +13,21 @@ export interface AutosaveTextFieldProps > { fieldName: Exclude; schema: yup.Schema; + /** Additional error flag from the parent; OR'd with the field's own validation error */ + error?: boolean; + + /** + * Save on every keystroke instead of on blur. Defaults to true for select + * boxes; opt-in for text inputs where immediate feedback is required. + */ + saveOnChange?: boolean; } export const AutosaveTextField: React.FC = ({ fieldName, schema, + error: externalError, + saveOnChange, ...props }) => { const saveField = useSaveField(); @@ -28,10 +38,17 @@ export const AutosaveTextField: React.FC = ({ saveValue: (value) => saveField({ [fieldName]: value }), fieldName, schema, - // Select boxes should save on change instead of on blur - saveOnChange: props.select, + saveOnChange: saveOnChange ?? !!props.select, disabled: !calculation, }); - return ; + return ( + + ); }; From 98170b45718f1b032fd18f9a1817b1b65b609e10 Mon Sep 17 00:00:00 2001 From: zachery with an e <45150570+zweatshirt@users.noreply.github.com> Date: Tue, 12 May 2026 12:59:01 -0500 Subject: [PATCH 4/4] Add SOSA over-cap alert and autosave handling in RequestedSalaryCard --- .../ApprovalProcessCard.test.tsx | 35 +++++++++++ .../ApprovalProcessCard.tsx | 4 +- .../RequestedSalaryCard.test.tsx | 58 ++++++++++++++++++- .../RequestedSalaryCard.tsx | 38 +++++++++++- 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/src/components/HrTools/SalaryCalculator/SalaryCalculation/ApprovalProcessCard/ApprovalProcessCard.test.tsx b/src/components/HrTools/SalaryCalculator/SalaryCalculation/ApprovalProcessCard/ApprovalProcessCard.test.tsx index 6bf6ca787e..e7ec50c834 100644 --- a/src/components/HrTools/SalaryCalculator/SalaryCalculation/ApprovalProcessCard/ApprovalProcessCard.test.tsx +++ b/src/components/HrTools/SalaryCalculator/SalaryCalculation/ApprovalProcessCard/ApprovalProcessCard.test.tsx @@ -1,4 +1,5 @@ import { render, waitFor } from '@testing-library/react'; +import { UserPersonTypeEnum } from 'pages/api/graphql-rest.page.generated'; import { ProgressiveApprovalTierEnum } from 'src/graphql/types.generated'; import { SalaryCalculatorTestWrapper, @@ -71,6 +72,40 @@ If this is correct, please provide reasoning for why Jane's Salary should exceed }); }); + describe('SOSA blockOnCap', () => { + const sosaUser = { + staffInfo: { + userPersonType: UserPersonTypeEnum.EmployeeStaffNonRmoSpouse, + }, + }; + const overCapMock = { + calculations: { requestedGross: 40000, effectiveCap: 30000 }, + progressiveApprovalTier: { + tier: ProgressiveApprovalTierEnum.VicePresident, + }, + }; + + it('renders nothing when the SOSA user is over their effective cap', async () => { + const { queryByRole } = render( + , + ); + + await waitFor(() => + expect(queryByRole('textbox')).not.toBeInTheDocument(), + ); + }); + + it('renders the card when the user is not SOSA, even if over cap', async () => { + const { findByRole } = render( + , + ); + + expect( + await findByRole('textbox', { name: 'Additional info' }), + ).toBeInTheDocument(); + }); + }); + describe('combined over cap', () => { it('renders single status message and textfield', async () => { const { getByRole, getByTestId } = render( diff --git a/src/components/HrTools/SalaryCalculator/SalaryCalculation/ApprovalProcessCard/ApprovalProcessCard.tsx b/src/components/HrTools/SalaryCalculator/SalaryCalculation/ApprovalProcessCard/ApprovalProcessCard.tsx index 4780da7696..4e22dc0ba3 100644 --- a/src/components/HrTools/SalaryCalculator/SalaryCalculation/ApprovalProcessCard/ApprovalProcessCard.tsx +++ b/src/components/HrTools/SalaryCalculator/SalaryCalculation/ApprovalProcessCard/ApprovalProcessCard.tsx @@ -9,11 +9,13 @@ import { useSalaryCalculator } from '../../SalaryCalculatorContext/SalaryCalcula import { StepCard } from '../../Shared/StepCard'; import { StyledCardHeader } from '../StyledCardHeader'; import { useCaps } from '../useCaps'; +import { useSosaBlockOverCap } from '../useSosaBlockOverCap'; export const ApprovalProcessCard: React.FC = () => { const { t } = useTranslation(); const { calculation, hcmSpouse } = useSalaryCalculator(); const { overCapPerson } = useCaps(); + const { blockOnCap } = useSosaBlockOverCap(); const schema = useMemo( () => @@ -26,7 +28,7 @@ export const ApprovalProcessCard: React.FC = () => { const spouseName = hcmSpouse?.staffInfo.preferredName; const tier = calculation?.progressiveApprovalTier?.tier; - if (!tier) { + if (!tier || blockOnCap) { return null; } diff --git a/src/components/HrTools/SalaryCalculator/SalaryCalculation/RequestedSalaryCard/RequestedSalaryCard.test.tsx b/src/components/HrTools/SalaryCalculator/SalaryCalculation/RequestedSalaryCard/RequestedSalaryCard.test.tsx index 6de970b1e7..df24a665eb 100644 --- a/src/components/HrTools/SalaryCalculator/SalaryCalculation/RequestedSalaryCard/RequestedSalaryCard.test.tsx +++ b/src/components/HrTools/SalaryCalculator/SalaryCalculation/RequestedSalaryCard/RequestedSalaryCard.test.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { DeepPartial } from 'ts-essentials'; +import { UserPersonTypeEnum } from 'pages/api/graphql-rest.page.generated'; +import { AutosaveForm } from 'src/components/Shared/Autosave/AutosaveForm'; import { SalaryCalculationQuery } from '../../SalaryCalculatorContext/SalaryCalculation.generated'; import { SalaryCalculatorTestWrapper, @@ -33,7 +35,9 @@ const TestComponent: React.FC = (props) => ( }} {...props} > - + + + ); @@ -164,4 +168,56 @@ As you set your salary level, the amount you receive should reflect the amount o ); }); }); + + describe('SOSA over-cap Alert', () => { + const sosaUser = { + staffInfo: { + userPersonType: UserPersonTypeEnum.EmployeeStaffNonRmoSpouse, + }, + }; + const overCapMock: DeepPartial = { + calculations: { + minimumRequiredSalary: 10002, + minimumRequestedSalary: 10003, + effectiveCap: 10004, + requestedGross: 15000, + }, + }; + + it('renders when the SOSA user is over their effective cap', async () => { + const { findByRole } = render( + , + ); + + expect(await findByRole('alert')).toHaveTextContent( + 'Your request requires additional approvals and cannot be submitted online. SOSA staff can have requests exceeding the $10,004.00 cap approved for certain geographic locations with the appropriate levels of approval.Please contact payroll@cru.org for further assistance.', + ); + expect( + await findByRole('link', { name: 'payroll@cru.org' }), + ).toHaveAttribute('href', 'mailto:payroll@cru.org'); + }); + + it('does not render when the user is SOSA but under their cap', async () => { + const { queryByRole } = render( + , + ); + + await waitFor(() => expect(queryByRole('alert')).not.toBeInTheDocument()); + }); + + it('does not render when the user is not SOSA, even if over cap', async () => { + const { queryByRole } = render( + , + ); + + await waitFor(() => expect(queryByRole('alert')).not.toBeInTheDocument()); + }); + }); }); diff --git a/src/components/HrTools/SalaryCalculator/SalaryCalculation/RequestedSalaryCard/RequestedSalaryCard.tsx b/src/components/HrTools/SalaryCalculator/SalaryCalculation/RequestedSalaryCard/RequestedSalaryCard.tsx index 352006293c..13e1f3828c 100644 --- a/src/components/HrTools/SalaryCalculator/SalaryCalculation/RequestedSalaryCard/RequestedSalaryCard.tsx +++ b/src/components/HrTools/SalaryCalculator/SalaryCalculation/RequestedSalaryCard/RequestedSalaryCard.tsx @@ -1,7 +1,9 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { + Alert, CardContent, CardHeader, + Link, Table, TableBody, TableCell, @@ -10,6 +12,7 @@ import { } from '@mui/material'; import { Trans, useTranslation } from 'react-i18next'; import * as yup from 'yup'; +import { useAutosaveForm } from 'src/components/Shared/Autosave/AutosaveForm'; import { amount } from 'src/lib/yupHelpers'; import { AutosaveTextField } from '../../Autosave/AutosaveTextField'; import { CalculationFieldsFragment } from '../../SalaryCalculatorContext/SalaryCalculation.generated'; @@ -17,6 +20,8 @@ import { useSalaryCalculator } from '../../SalaryCalculatorContext/SalaryCalcula import { EffectiveDateNote } from '../../Shared/EffectiveDateNote'; import { StepCard, StepTableHead } from '../../Shared/StepCard'; import { useFormatters } from '../../Shared/useFormatters'; +import { useCaps } from '../useCaps'; +import { useSosaBlockOverCap } from '../useSosaBlockOverCap'; export const RequestedSalaryCard: React.FC = () => { const { t } = useTranslation(); @@ -26,6 +31,19 @@ export const RequestedSalaryCard: React.FC = () => { hcmSpouse, } = useSalaryCalculator(); const { formatCurrency } = useFormatters(); + const { overCapPerson } = useCaps(); + const { isUserSosa, blockOnCap } = useSosaBlockOverCap(); + const { markValid, markInvalid } = useAutosaveForm(); + + // Disable the Continue button while the saved gross exceeds the SOSA cap. + useEffect(() => { + if (blockOnCap) { + markInvalid('over-cap'); + } else { + markValid('over-cap'); + } + return () => markValid('over-cap'); + }, [blockOnCap, markValid, markInvalid]); const minimumSalaryValue = salaryCalculation?.calculations.minimumRequestedSalary; @@ -160,6 +178,8 @@ export const RequestedSalaryCard: React.FC = () => { schema={schema} label={t('Requested salary')} required + error={blockOnCap} + saveOnChange={isUserSosa} /> {hcmSpouse && ( @@ -175,6 +195,22 @@ export const RequestedSalaryCard: React.FC = () => { + + {blockOnCap && ( + + + Your request requires additional approvals and cannot be submitted + online. SOSA staff can have requests exceeding the{' '} + {{ cap: overCapPerson?.effectiveCap }} cap approved for certain + geographic locations with the appropriate levels of approval. +
+
+ Please contact{' '} + payroll@cru.org for + further assistance. +
+
+ )} );