diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/AutosaveTextField.test.tsx b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/AutosaveTextField.test.tsx
index f9e5ded974..a539a14d5e 100644
--- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/AutosaveTextField.test.tsx
+++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/AutosaveTextField.test.tsx
@@ -145,6 +145,22 @@ describe('AutosaveTextField', () => {
expect(input).toHaveAccessibleDescription('');
});
+ it('disables the input when the goal is read-only but still shows its value', async () => {
+ const { getByRole } = render(
+
+
+ ,
+ );
+
+ const input = getByRole('textbox', { name: 'MHA Amount' });
+ await waitFor(() => expect(input).toHaveValue('1000'));
+ expect(input).toBeDisabled();
+ });
+
it('shows validation error for invalid type', async () => {
const { getByRole } = render();
diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useGoalAutosave.ts b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useGoalAutosave.ts
index 93ce4195ad..edb7ff89f8 100644
--- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useGoalAutosave.ts
+++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/useGoalAutosave.ts
@@ -17,13 +17,14 @@ export const useGoalAutoSave = ({
const saveField = useSaveField();
const {
goalCalculationResult: { data },
+ isReadOnly,
} = useGoalCalculator();
return useAutoSave({
value: data?.goalCalculation[fieldName],
saveValue: (value) => saveField({ [fieldName]: value }),
fieldName,
- disabled: !data,
+ disabled: !data || isReadOnly,
...options,
});
};
diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryFinancialForm.tsx b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryFinancialForm.tsx
index 9818b89ddd..1dba70f7c2 100644
--- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryFinancialForm.tsx
+++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryFinancialForm.tsx
@@ -30,6 +30,7 @@ export const InformationCategoryFinancialForm: React.FC<
const {
setRightPanelContent,
goalCalculationResult: { data },
+ isReadOnly,
} = useGoalCalculator();
const saveField = useSaveField();
@@ -89,6 +90,7 @@ export const InformationCategoryFinancialForm: React.FC<
fullWidth
size="small"
select
+ disabled={!data || isReadOnly}
label={
isSpouse
? t('Spouse SECA (Social Security) Status')
diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx
index 092d3986ca..ca46daa184 100644
--- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx
+++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx
@@ -42,6 +42,7 @@ export const InformationCategoryPersonalForm: React.FC<
const { t } = useTranslation();
const {
goalCalculationResult: { data },
+ isReadOnly,
setRightPanelContent,
} = useGoalCalculator();
const { goalGeographicConstantMap, goalBenefitsPlans } =
@@ -77,6 +78,11 @@ export const InformationCategoryPersonalForm: React.FC<
}, [goalBenefitsPlans, familySize]);
useEffect(() => {
+ // Read-only goals reject mutations, so don't try to fix an incompatible plan
+ if (isReadOnly) {
+ return;
+ }
+
// Clear benefits plan if it's not compatible with selected family size
if (familySize && benefitsPlan) {
const isPlanValid = planOptions.some(([plan]) => plan === benefitsPlan);
@@ -134,7 +140,7 @@ export const InformationCategoryPersonalForm: React.FC<
onChange={(_, newValue) =>
saveField({ geographicLocation: newValue })
}
- disabled={!data}
+ disabled={!data || isReadOnly}
size="small"
renderInput={(params) => (
= ({ onCall, noMocks = false, children }) => {
+> = ({ onCall, noMocks = false, readOnly = false, children }) => {
const content = {children};
return (
@@ -281,7 +283,7 @@ export const GoalCalculatorTestWrapper: React.FC<
}>
mocks={{
GoalCalculation: {
- goalCalculation: goalCalculationMock,
+ goalCalculation: { ...goalCalculationMock, readOnly },
},
GoalCalculatorConstants: {
constant: constantsMock,
diff --git a/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.test.tsx b/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.test.tsx
index 3ff736be4f..49d9490813 100644
--- a/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.test.tsx
+++ b/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.test.tsx
@@ -14,7 +14,11 @@ import { MpdGoalCard } from './MpdGoalCard';
const mutationSpy = jest.fn();
-const TestComponent: React.FC = () => (
+interface TestComponentProps {
+ readOnly?: boolean;
+}
+
+const TestComponent: React.FC = ({ readOnly = false }) => (
(
},
}}
>
-
+
@@ -70,4 +76,16 @@ describe('MpdGoalCard', () => {
getByTestId('goal-amount-value').querySelector('.MuiSkeleton-root'),
).toBeInTheDocument();
});
+
+ describe('read-only goal', () => {
+ it('hides the Delete button and shows the Read-Only badge', () => {
+ const { getByText, getByRole, queryByRole } = render(
+ ,
+ );
+
+ expect(queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
+ expect(getByText('Read-Only')).toBeInTheDocument();
+ expect(getByRole('link', { name: 'View' })).toBeInTheDocument();
+ });
+ });
});
diff --git a/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.tsx b/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.tsx
index 91bcfb151c..808825d959 100644
--- a/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.tsx
+++ b/src/components/HrTools/GoalCalculator/GoalCard/MpdGoalCard.tsx
@@ -1,4 +1,6 @@
import React, { useMemo } from 'react';
+import { Chip } from '@mui/material';
+import { useTranslation } from 'react-i18next';
import { GoalCard } from 'src/components/Reports/Shared/GoalCard/GoalCard';
import { useAccountListId } from 'src/hooks/useAccountListId';
import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants';
@@ -13,6 +15,7 @@ export interface MpdGoalCardProps {
}
export const MpdGoalCard: React.FC = ({ goal }) => {
+ const { t } = useTranslation();
const accountListId = useAccountListId();
const constants = useGoalCalculatorConstants();
const [deleteGoalCalculation] = useDeleteGoalCalculationMutation();
@@ -43,7 +46,13 @@ export const MpdGoalCard: React.FC = ({ goal }) => {
loading={constants.loading}
updatedAt={goal.updatedAt}
viewHref={`/accountLists/${accountListId}/hrTools/goalCalculator/${goal.id}`}
- onDelete={handleDelete}
+ // Read-only goals reject deletion, so don't offer it
+ onDelete={goal.readOnly ? undefined : handleDelete}
+ badge={
+ goal.readOnly ? (
+
+ ) : undefined
+ }
/>
);
};
diff --git a/src/components/HrTools/GoalCalculator/GoalsList/GoalCalculations.graphql b/src/components/HrTools/GoalCalculator/GoalsList/GoalCalculations.graphql
index 8a8a2b2cac..4233ca766c 100644
--- a/src/components/HrTools/GoalCalculator/GoalsList/GoalCalculations.graphql
+++ b/src/components/HrTools/GoalCalculator/GoalsList/GoalCalculations.graphql
@@ -1,6 +1,7 @@
fragment ListGoalCalculation on GoalCalculation {
id
name
+ readOnly
role
familySize
benefitsPlan
diff --git a/src/components/HrTools/GoalCalculator/Shared/GoalCalculation.graphql b/src/components/HrTools/GoalCalculator/Shared/GoalCalculation.graphql
index 6d36e14502..342712aefb 100644
--- a/src/components/HrTools/GoalCalculator/Shared/GoalCalculation.graphql
+++ b/src/components/HrTools/GoalCalculator/Shared/GoalCalculation.graphql
@@ -1,5 +1,6 @@
fragment GoalCalculationSettings on GoalCalculation {
name
+ readOnly
firstName
spouseFirstName
lastName
diff --git a/src/components/HrTools/GoalCalculator/Shared/GoalCalculatorContext.tsx b/src/components/HrTools/GoalCalculator/Shared/GoalCalculatorContext.tsx
index c48607e179..5269c2c9a8 100644
--- a/src/components/HrTools/GoalCalculator/Shared/GoalCalculatorContext.tsx
+++ b/src/components/HrTools/GoalCalculator/Shared/GoalCalculatorContext.tsx
@@ -48,6 +48,9 @@ export type GoalCalculatorType = {
goalCalculationResult: ReturnType;
goalTotals: GoalTotals;
+ /** Whether the goal is read-only and all inputs should be disabled */
+ isReadOnly: boolean;
+
/** Whether any mutations are currently in progress */
isMutating: boolean;
/** Call with the mutation promise to track the start and end of mutations */
@@ -91,6 +94,9 @@ export const GoalCalculatorProvider: React.FC = ({ children }) => {
},
});
+ const isReadOnly =
+ goalCalculationResult.data?.goalCalculation?.readOnly ?? false;
+
const role = goalCalculationResult.data?.goalCalculation?.role ?? null;
const familySize =
goalCalculationResult.data?.goalCalculation?.familySize ?? null;
@@ -192,6 +198,7 @@ export const GoalCalculatorProvider: React.FC = ({ children }) => {
selectedReport,
setSelectedReport,
goalCalculationResult,
+ isReadOnly,
isMutating,
trackMutation,
percentComplete,
@@ -215,6 +222,7 @@ export const GoalCalculatorProvider: React.FC = ({ children }) => {
selectedReport,
setSelectedReport,
goalCalculationResult,
+ isReadOnly,
isMutating,
trackMutation,
percentComplete,
diff --git a/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.test.tsx b/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.test.tsx
index f438905da8..0c0eb4f5ea 100644
--- a/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.test.tsx
+++ b/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.test.tsx
@@ -356,4 +356,59 @@ describe('GoalCalculatorGrid', () => {
await findByText('Only the portion not reimbursed as ministry expense.'),
).toBeInTheDocument();
});
+
+ describe('read-only goal', () => {
+ it('disables the mode toggle and add buttons and hides delete actions', async () => {
+ const { findByText, getByRole, queryByLabelText } = render(
+
+
+ ,
+ );
+
+ const otherMinistryRow = (await findByText('Other Ministry')).closest(
+ '[role="row"]',
+ );
+
+ expect(getByRole('button', { name: 'Lump Sum' })).toBeDisabled();
+ expect(getByRole('button', { name: 'Line Item' })).toBeDisabled();
+ expect(getByRole('button', { name: 'Add Line Item' })).toBeDisabled();
+
+ userEvent.hover(otherMinistryRow!);
+ expect(queryByLabelText('Delete')).not.toBeInTheDocument();
+ });
+
+ it('does not open the cell editor', async () => {
+ const { findByText, queryByDisplayValue } = render(
+
+
+ ,
+ );
+
+ const nameCell = await findByText('Other Ministry');
+ userEvent.dblClick(nameCell);
+
+ expect(queryByDisplayValue('Other Ministry')).not.toBeInTheDocument();
+ });
+
+ it('still displays the calculated total', async () => {
+ const { findByText } = render(
+
+
+ ,
+ );
+
+ expect(await findByText('Total')).toBeInTheDocument();
+ expect(await findByText('$1,450')).toBeInTheDocument();
+ });
+
+ it('disables the lump sum total field', async () => {
+ const { findByLabelText } = render(
+
+
+ ,
+ );
+
+ expect(await findByLabelText('Total')).toBeDisabled();
+ });
+ });
});
diff --git a/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.tsx b/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.tsx
index c8c7513240..538b44ab7d 100644
--- a/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.tsx
+++ b/src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/GoalCalculatorGrid.tsx
@@ -93,6 +93,7 @@ export const GoalCalculatorGrid: React.FC = ({
defaultType,
defaultTypeChanged,
clearDefaultTypeChanged,
+ isReadOnly,
} = useGoalCalculator();
const categoryType = category.category;
@@ -226,7 +227,8 @@ export const GoalCalculatorGrid: React.FC = ({
// Update line item defaults when defaultType changes (user changed role/family size)
useEffect(() => {
- if (!hasDefaultsForType) {
+ // Read-only goals reject mutations, and their role/family size can't change anyways
+ if (isReadOnly || !hasDefaultsForType) {
return;
}
@@ -259,6 +261,7 @@ export const GoalCalculatorGrid: React.FC = ({
clearDefaultTypeChanged();
}, [
+ isReadOnly,
hasDefaultsForType,
defaultTypeChanged,
directInput,
@@ -276,6 +279,7 @@ export const GoalCalculatorGrid: React.FC = ({
saveValue: updateDirectInput,
fieldName: 'amount',
schema: directInputSchema,
+ disabled: isReadOnly,
saveOnChange: false,
});
const addExpense = () => {
@@ -497,8 +501,8 @@ export const GoalCalculatorGrid: React.FC = ({
headerName: '',
width: 60,
getActions: (params) => {
- // Don't show delete action for total row
- if (params.id === 'total') {
+ // Don't show delete action for total row, or at all when the goal is read-only
+ if (params.id === 'total' || isReadOnly) {
return [];
}
@@ -538,6 +542,7 @@ export const GoalCalculatorGrid: React.FC = ({
size="small"
onClick={() => handleDirectInputToggle(true)}
startIcon={}
+ disabled={isReadOnly}
>
{t('Lump Sum')}
@@ -547,6 +552,7 @@ export const GoalCalculatorGrid: React.FC = ({
variant={!directInput ? 'contained' : 'outlined'}
onClick={() => handleDirectInputToggle(false)}
startIcon={}
+ disabled={isReadOnly}
>
{t('Line Item')}
@@ -577,6 +583,7 @@ export const GoalCalculatorGrid: React.FC = ({
onClick={addExpense}
size="small"
startIcon={}
+ disabled={isReadOnly}
>
{t('Add Line Item')}
@@ -586,6 +593,10 @@ export const GoalCalculatorGrid: React.FC = ({
columns={columns}
processRowUpdate={processRowUpdate}
isCellEditable={(params) => {
+ // Read-only goals reject all edits
+ if (isReadOnly) {
+ return false;
+ }
// Don't allow editing the total row or label field when canDelete is false
if (params.id === 'total') {
return false;
diff --git a/src/components/Reports/Shared/GoalCard/GoalCard.test.tsx b/src/components/Reports/Shared/GoalCard/GoalCard.test.tsx
index 347c1d54f8..eee0489968 100644
--- a/src/components/Reports/Shared/GoalCard/GoalCard.test.tsx
+++ b/src/components/Reports/Shared/GoalCard/GoalCard.test.tsx
@@ -111,4 +111,11 @@ describe('GoalCard', () => {
expect(queryByText('Default')).not.toBeInTheDocument();
});
+
+ it('hides the Delete button when onDelete is not provided', () => {
+ const { getByRole, queryByRole } = renderCard({ onDelete: undefined });
+
+ expect(queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
+ expect(getByRole('link', { name: 'View' })).toBeInTheDocument();
+ });
});
diff --git a/src/components/Reports/Shared/GoalCard/GoalCard.tsx b/src/components/Reports/Shared/GoalCard/GoalCard.tsx
index 91fac0967d..42359fca88 100644
--- a/src/components/Reports/Shared/GoalCard/GoalCard.tsx
+++ b/src/components/Reports/Shared/GoalCard/GoalCard.tsx
@@ -61,7 +61,8 @@ export interface GoalCardProps {
loading?: boolean;
updatedAt: string;
viewHref: string;
- onDelete: () => Promise;
+ /** Omit to hide the delete button, e.g. for read-only goals */
+ onDelete?: () => Promise;
badge?: React.ReactNode;
}
@@ -81,7 +82,7 @@ export const GoalCard: React.FC = ({
const displayName = name ?? t('Unnamed Goal');
- const handleConfirmDelete = async () => onDelete();
+ const handleConfirmDelete = async () => onDelete?.();
return (
<>
@@ -158,11 +159,16 @@ export const GoalCard: React.FC = ({
-
+ {onDelete ? (
+
+ ) : (
+ // Keep the View button on the right when there is no delete button
+
+ )}