diff --git a/pages/accountLists/[accountListId]/hrTools/pdsGoalCalculator/[pdsGoalId].page.tsx b/pages/accountLists/[accountListId]/hrTools/pdsGoalCalculator/[pdsGoalId].page.tsx index c3cd3d7bd8..ebec9c2017 100644 --- a/pages/accountLists/[accountListId]/hrTools/pdsGoalCalculator/[pdsGoalId].page.tsx +++ b/pages/accountLists/[accountListId]/hrTools/pdsGoalCalculator/[pdsGoalId].page.tsx @@ -57,6 +57,7 @@ const PdsGoalCalculatorContent: React.FC = ({ }) => { const { rightPanelContent, + rightPanelTitle, closeRightPanel, calculation, calculationLoading, @@ -67,7 +68,9 @@ const PdsGoalCalculatorContent: React.FC = ({ const rightPanel = ( <> - {t('Details')} + + {rightPanelTitle ?? t('Details')} + { , ); - expect(await findByText('Hours Per Week Calculator')).toBeInTheDocument(); await waitForDataToLoad(); expect(await findByText('Regular Week')).toBeInTheDocument(); expect(getByText('Travel')).toBeInTheDocument(); diff --git a/src/components/Reports/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx b/src/components/Reports/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx index fe1f5aa157..d95aada7f5 100644 --- a/src/components/Reports/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx +++ b/src/components/Reports/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx @@ -1,10 +1,4 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useMemo } from 'react'; import AddIcon from '@mui/icons-material/Add'; import DeleteIcon from '@mui/icons-material/Delete'; import { @@ -16,21 +10,10 @@ import { Typography, styled, } from '@mui/material'; -import { - GridActionsCellItem, - GridColDef, - GridValidRowModel, -} from '@mui/x-data-grid'; -import { useSnackbar } from 'notistack'; +import { GridActionsCellItem, GridColDef } from '@mui/x-data-grid'; import { useTranslation } from 'react-i18next'; import { BaseGrid } from 'src/components/Reports/GoalCalculator/SharedComponents/GoalCalculatorGrid/BaseGrid'; -import { - useCreateDesignationSupportHoursItemMutation, - useDeleteDesignationSupportHoursItemMutation, - useUpdateDesignationSupportHoursItemMutation, -} from '../../GoalsList/PdsGoalCalculations.generated'; -import { useSaveField } from '../../Shared/Autosave/useSaveField'; -import { usePdsGoalCalculator } from '../../Shared/PdsGoalCalculatorContext'; +import { useHoursPerWeekGrid } from './useHoursPerWeekGrid'; const StyledCard = styled(Card)(({ theme }) => ({ borderRadius: theme.shape.borderRadius, @@ -44,321 +27,23 @@ const FooterRow = styled(Box)(({ theme }) => ({ fontWeight: 'bold', })); -export interface HoursPerWeekEntry { - id: string; - label: string; - hoursPerWeek: number; - weeks: number; - canDelete: boolean; - predefined: boolean; - position: number; -} - interface HoursPerWeekGridProps { onApply?: (averageHoursPerWeek: number) => void; } -const MAX_TOTAL_WEEKS = 52; - export const HoursPerWeekGrid: React.FC = ({ onApply, }) => { const { t } = useTranslation(); - const { enqueueSnackbar } = useSnackbar(); - const { calculation, trackMutation } = usePdsGoalCalculator(); - const [createHoursItem] = useCreateDesignationSupportHoursItemMutation(); - const [updateHoursItem] = useUpdateDesignationSupportHoursItemMutation(); - const [deleteHoursItem] = useDeleteDesignationSupportHoursItemMutation(); - const saveField = useSaveField(); - - const [entries, setEntries] = useState([]); - const initializedRef = useRef(false); - const nextEntryIdRef = useRef(0); - - useEffect(() => { - const items = calculation?.designationSupportHoursItems; - if (!initializedRef.current && items && items.length > 0) { - initializedRef.current = true; - setEntries( - items - .slice() - .sort( - (hoursItemA, hoursItemB) => - (hoursItemA.position ?? 0) - (hoursItemB.position ?? 0), - ) - .map((item) => ({ - id: item.id, - label: item.label, - hoursPerWeek: item.hoursPerWeek ?? 0, - weeks: item.numberOfWeeks ?? 0, - canDelete: !item.predefined, - predefined: item.predefined, - position: item.position ?? 0, - })), - ); - } - }, [calculation?.designationSupportHoursItems]); - - const totalWeeks = useMemo( - () => entries.reduce((sum, entry) => sum + entry.weeks, 0), - [entries], - ); - - const totalHours = useMemo( - () => - entries.reduce((sum, entry) => sum + entry.hoursPerWeek * entry.weeks, 0), - [entries], - ); - - const averageHoursPerWeek = useMemo( - () => (totalWeeks > 0 ? totalHours / totalWeeks : 0), - [totalHours, totalWeeks], - ); - - const weeksRemaining = MAX_TOTAL_WEEKS - totalWeeks; - - const saveHoursItem = useCallback( - async (entry: HoursPerWeekEntry, currentEntries: HoursPerWeekEntry[]) => { - if (!calculation) { - return; - } - - try { - if (entry.id.startsWith('default-')) { - const result = await trackMutation( - createHoursItem({ - variables: { - attributes: { - designationSupportCalculationId: calculation.id, - label: entry.label, - hoursPerWeek: entry.hoursPerWeek, - numberOfWeeks: entry.weeks, - position: currentEntries.findIndex((e) => e.id === entry.id), - }, - }, - refetchQueries: ['PdsGoalCalculation'], - }), - ); - const created = - result.data?.createDesignationSupportHoursItem - ?.designationSupportHoursItem; - if (created) { - setEntries((prev) => - prev.map((e) => - e.id === entry.id ? { ...e, id: created.id } : e, - ), - ); - } - } else { - await trackMutation( - updateHoursItem({ - variables: { - attributes: { - id: entry.id, - designationSupportCalculationId: calculation.id, - label: entry.label, - hoursPerWeek: entry.hoursPerWeek, - numberOfWeeks: entry.weeks, - }, - }, - refetchQueries: ['PdsGoalCalculation'], - }), - ); - } - } catch (error) { - enqueueSnackbar(t('Failed to save hours entry. Please try again.'), { - variant: 'error', - }); - throw error; - } - }, - [ - calculation, - createHoursItem, - updateHoursItem, - trackMutation, - enqueueSnackbar, - t, - ], - ); - - const updateEntry = useCallback( - (id: string, updates: Partial) => { - setEntries((prev) => - prev.map((entry) => - entry.id === id ? { ...entry, ...updates } : entry, - ), - ); - }, - [], - ); - - const addEntry = useCallback(async () => { - if (!calculation) { - return; - } - - const tempId = `temp-${nextEntryIdRef.current++}`; - const newPosition = - entries.length > 0 ? Math.max(...entries.map((e) => e.position)) + 1 : 0; - const newEntry: HoursPerWeekEntry = { - id: tempId, - label: t('New Entry'), - hoursPerWeek: 0, - weeks: 0, - canDelete: true, - predefined: false, - position: newPosition, - }; - setEntries((prev) => [...prev, newEntry]); - - try { - const result = await trackMutation( - createHoursItem({ - variables: { - attributes: { - designationSupportCalculationId: calculation.id, - label: t('New Entry'), - hoursPerWeek: 0, - numberOfWeeks: 0, - position: newPosition, - }, - }, - refetchQueries: ['PdsGoalCalculation'], - }), - ); - const created = - result.data?.createDesignationSupportHoursItem - ?.designationSupportHoursItem; - if (created) { - setEntries((prev) => - prev.map((e) => (e.id === tempId ? { ...e, id: created.id } : e)), - ); - } - } catch { - setEntries((prev) => prev.filter((e) => e.id !== tempId)); - enqueueSnackbar(t('Failed to add entry. Please try again.'), { - variant: 'error', - }); - } - }, [ - t, - calculation, - createHoursItem, - trackMutation, - entries.length, - enqueueSnackbar, - ]); - - const deleteEntry = useCallback( - async (id: string | number) => { - const entryId = id.toString(); - const previousEntries = entries; - const remainingEntries = entries.filter((entry) => entry.id !== entryId); - setEntries(remainingEntries); - - // Recalculate and autosave the average - const newTotalWeeks = remainingEntries.reduce( - (sum, e) => sum + e.weeks, - 0, - ); - const newTotalHours = remainingEntries.reduce( - (sum, e) => sum + e.hoursPerWeek * e.weeks, - 0, - ); - const newAverage = newTotalWeeks > 0 ? newTotalHours / newTotalWeeks : 0; - - try { - if (!entryId.startsWith('temp-') && !entryId.startsWith('default-')) { - await trackMutation( - deleteHoursItem({ - variables: { id: entryId }, - refetchQueries: ['PdsGoalCalculation'], - }), - ); - } - saveField({ - averageHoursPerWeek: newAverage, - }); - } catch { - setEntries(previousEntries); - enqueueSnackbar(t('Failed to delete entry. Please try again.'), { - variant: 'error', - }); - } - }, - [entries, deleteHoursItem, trackMutation, saveField, enqueueSnackbar, t], - ); - - const processRowUpdate = useCallback( - async (newRow: GridValidRowModel) => { - if (newRow.id === 'total') { - return newRow; - } - - const newWeeks = Number(newRow.weeks) || 0; - const otherWeeks = entries - .filter((e) => e.id !== newRow.id) - .reduce((sum, e) => sum + e.weeks, 0); - const clampedWeeks = Math.min(newWeeks, MAX_TOTAL_WEEKS - otherWeeks); - - const updatedEntry: HoursPerWeekEntry = { - id: newRow.id as string, - label: newRow.label as string, - hoursPerWeek: Number(newRow.hoursPerWeek) || 0, - weeks: Math.max(0, clampedWeeks), - canDelete: newRow.canDelete as boolean, - predefined: newRow.predefined as boolean, - position: Number(newRow.position) || 0, - }; - - updateEntry(newRow.id as string, { - label: updatedEntry.label, - hoursPerWeek: updatedEntry.hoursPerWeek, - weeks: updatedEntry.weeks, - }); - - await saveHoursItem(updatedEntry, entries); - - // Autosave the recalculated average to the calculation - const updatedEntries = entries.map((entry) => - entry.id === updatedEntry.id ? updatedEntry : entry, - ); - const newTotalWeeks = updatedEntries.reduce((sum, e) => sum + e.weeks, 0); - const newTotalHours = updatedEntries.reduce( - (sum, e) => sum + e.hoursPerWeek * e.weeks, - 0, - ); - const newAverage = newTotalWeeks > 0 ? newTotalHours / newTotalWeeks : 0; - saveField({ - averageHoursPerWeek: newAverage, - }); - - return { ...newRow, weeks: Math.max(0, clampedWeeks) }; - }, - [updateEntry, entries, saveHoursItem, saveField], - ); - - const dataWithTotal = useMemo( - () => [ - ...entries - .slice() - .sort((a, b) => a.position - b.position) - .map((entry) => ({ - ...entry, - totalHours: entry.hoursPerWeek * entry.weeks, - })), - { - id: 'total', - label: t('Total'), - hoursPerWeek: null, - weeks: totalWeeks, - totalHours: totalHours, - canDelete: false, - }, - ], - [entries, totalWeeks, totalHours, t], - ); + const { + totalHours, + averageHoursPerWeek, + weeksRemaining, + dataWithTotal, + addEntry, + deleteEntry, + processRowUpdate, + } = useHoursPerWeekGrid(); const columns: GridColDef[] = useMemo( () => [ @@ -429,7 +114,6 @@ export const HoursPerWeekGrid: React.FC = ({ return ( - {t('Hours Per Week Calculator')} {t( 'This calculator is based on a 52-week year. Weeks are capped at 52 and a warning will appear if the total falls short.', @@ -498,7 +182,7 @@ export const HoursPerWeekGrid: React.FC = ({ {weeksRemaining > 0 && ( {t('Weeks must add up to {{max}}. {{remaining}} week(s) remaining.', { - max: MAX_TOTAL_WEEKS, + max: 52, remaining: weeksRemaining, })} diff --git a/src/components/Reports/PdsGoalCalculator/Setup/HoursPerWeekGrid/useHoursPerWeekGrid.test.tsx b/src/components/Reports/PdsGoalCalculator/Setup/HoursPerWeekGrid/useHoursPerWeekGrid.test.tsx new file mode 100644 index 0000000000..70ff583857 --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/Setup/HoursPerWeekGrid/useHoursPerWeekGrid.test.tsx @@ -0,0 +1,425 @@ +import React from 'react'; +import { GridValidRowModel } from '@mui/x-data-grid'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { + PdsGoalCalculationMock, + PdsGoalCalculatorTestWrapper, +} from '../../PdsGoalCalculatorTestWrapper'; +import { useHoursPerWeekGrid } from './useHoursPerWeekGrid'; + +const defaultCalculationMock: PdsGoalCalculationMock = { + id: 'goal-1', + designationSupportHoursItems: [ + { + id: 'item-regular', + label: 'Regular Week', + hoursPerWeek: 40, + numberOfWeeks: 48, + name: 'regular', + position: 0, + predefined: true, + }, + { + id: 'item-travel', + label: 'Travel', + hoursPerWeek: 0, + numberOfWeeks: 0, + name: 'travel', + position: 1, + predefined: true, + }, + { + id: 'item-vacation', + label: 'Unpaid Vacation', + hoursPerWeek: 0, + numberOfWeeks: 0, + name: 'vacation', + position: 2, + predefined: true, + }, + ], +}; + +const mutationSpy = jest.fn(); + +const createWrapper = ( + calculationMock: PdsGoalCalculationMock = defaultCalculationMock, +): React.FC<{ children: React.ReactNode }> => { + const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + + ); + return Wrapper; +}; + +const waitForDataToLoad = async () => { + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('PdsGoalCalculation'), + ); +}; + +describe('useHoursPerWeekGrid', () => { + beforeEach(() => { + mutationSpy.mockClear(); + }); + + it('initializes entries from server data sorted by position', async () => { + const { result } = renderHook(useHoursPerWeekGrid, { + wrapper: createWrapper(), + }); + + await waitForDataToLoad(); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(3); + }); + + expect(result.current.entries[0]).toMatchObject({ + id: 'item-regular', + label: 'Regular Week', + hoursPerWeek: 40, + weeks: 48, + position: 0, + }); + expect(result.current.entries[1]).toMatchObject({ + id: 'item-travel', + label: 'Travel', + position: 1, + }); + expect(result.current.entries[2]).toMatchObject({ + id: 'item-vacation', + label: 'Unpaid Vacation', + position: 2, + }); + }); + + it('computes totalWeeks, totalHours, and averageHoursPerWeek', async () => { + const { result } = renderHook(useHoursPerWeekGrid, { + wrapper: createWrapper(), + }); + + await waitForDataToLoad(); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(3); + }); + + // Regular Week: 40 hrs * 48 wks = 1920 total hours + expect(result.current.totalWeeks).toBe(48); + expect(result.current.totalHours).toBe(1920); + expect(result.current.averageHoursPerWeek).toBe(40); + }); + + it('computes weeksRemaining as 52 minus totalWeeks', async () => { + const { result } = renderHook(useHoursPerWeekGrid, { + wrapper: createWrapper(), + }); + + await waitForDataToLoad(); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(3); + }); + + // 52 - 48 = 4 weeks remaining + expect(result.current.weeksRemaining).toBe(4); + }); + + it('returns 0 for averageHoursPerWeek when totalWeeks is 0', async () => { + const { result } = renderHook(useHoursPerWeekGrid, { + wrapper: createWrapper({ + id: 'goal-1', + designationSupportHoursItems: [ + { + id: 'item-1', + label: 'Entry', + hoursPerWeek: 10, + numberOfWeeks: 0, + name: 'entry', + position: 0, + predefined: false, + }, + ], + }), + }); + + await waitForDataToLoad(); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(1); + }); + + expect(result.current.averageHoursPerWeek).toBe(0); + }); + + it('builds dataWithTotal with a total row appended', async () => { + const { result } = renderHook(useHoursPerWeekGrid, { + wrapper: createWrapper(), + }); + + await waitForDataToLoad(); + + await waitFor(() => { + expect(result.current.dataWithTotal).toHaveLength(4); + }); + + const totalRow = + result.current.dataWithTotal[result.current.dataWithTotal.length - 1]; + expect(totalRow).toMatchObject({ + id: 'total', + label: 'Total', + weeks: 48, + totalHours: 1920, + canDelete: false, + }); + }); + + it('adds totalHours to each entry in dataWithTotal', async () => { + const { result } = renderHook(useHoursPerWeekGrid, { + wrapper: createWrapper(), + }); + + await waitForDataToLoad(); + + await waitFor(() => { + expect(result.current.dataWithTotal).toHaveLength(4); + }); + + // Regular Week: 40 * 48 = 1920 + expect(result.current.dataWithTotal[0]).toMatchObject({ + id: 'item-regular', + totalHours: 1920, + }); + // Travel: 0 * 0 = 0 + expect(result.current.dataWithTotal[1]).toMatchObject({ + id: 'item-travel', + totalHours: 0, + }); + }); + + it('addEntry creates a temporary entry and fires a create mutation', async () => { + const { result } = renderHook(useHoursPerWeekGrid, { + wrapper: createWrapper(), + }); + + await waitForDataToLoad(); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(3); + }); + + await act(async () => { + await result.current.addEntry(); + }); + + expect(result.current.entries).toHaveLength(4); + expect(result.current.entries[3]).toMatchObject({ + label: 'New Entry', + hoursPerWeek: 0, + weeks: 0, + canDelete: true, + predefined: false, + position: 3, + }); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation( + 'CreateDesignationSupportHoursItem', + { + attributes: { + designationSupportCalculationId: 'goal-1', + label: 'New Entry', + hoursPerWeek: 0, + numberOfWeeks: 0, + position: 3, + }, + }, + ), + ); + }); + + it('deleteEntry removes the entry and fires a delete mutation', async () => { + const { result } = renderHook(useHoursPerWeekGrid, { + wrapper: createWrapper(), + }); + + await waitForDataToLoad(); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(3); + }); + + // Add a custom entry first, then delete it + await act(async () => { + await result.current.addEntry(); + }); + + expect(result.current.entries).toHaveLength(4); + const newEntryId = result.current.entries[3].id; + + await act(async () => { + await result.current.deleteEntry(newEntryId); + }); + + expect(result.current.entries).toHaveLength(3); + }); + + it('deleteEntry autosaves the recalculated average', async () => { + const fullMock: PdsGoalCalculationMock = { + id: 'goal-1', + designationSupportHoursItems: [ + { + id: 'item-a', + label: 'A', + hoursPerWeek: 40, + numberOfWeeks: 26, + name: 'a', + position: 0, + predefined: false, + }, + { + id: 'item-b', + label: 'B', + hoursPerWeek: 20, + numberOfWeeks: 26, + name: 'b', + position: 1, + predefined: false, + }, + ], + }; + + const { result } = renderHook(useHoursPerWeekGrid, { + wrapper: createWrapper(fullMock), + }); + + await waitForDataToLoad(); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(2); + }); + + // Average before delete: (40*26 + 20*26) / 52 = 30 + expect(result.current.averageHoursPerWeek).toBe(30); + + await act(async () => { + await result.current.deleteEntry('item-b'); + }); + + // After deleting B: average = (40*26) / 26 = 40 + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation', { + attributes: { + averageHoursPerWeek: 40, + }, + }), + ); + }); + + it('processRowUpdate clamps weeks to not exceed 52 total', async () => { + const { result } = renderHook(useHoursPerWeekGrid, { + wrapper: createWrapper(), + }); + + await waitForDataToLoad(); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(3); + }); + + // Regular has 48 weeks. Try to set Travel to 10 weeks — should clamp to 4 + let updatedRow: GridValidRowModel | undefined; + await act(async () => { + updatedRow = await result.current.processRowUpdate({ + id: 'item-travel', + label: 'Travel', + hoursPerWeek: 5, + weeks: 10, + canDelete: true, + predefined: true, + position: 1, + }); + }); + + expect(updatedRow?.weeks).toBe(4); + }); + + it('processRowUpdate autosaves the recalculated average', async () => { + const { result } = renderHook(useHoursPerWeekGrid, { + wrapper: createWrapper(), + }); + + await waitForDataToLoad(); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(3); + }); + + await act(async () => { + await result.current.processRowUpdate({ + id: 'item-regular', + label: 'Regular Week', + hoursPerWeek: 20, + weeks: 48, + canDelete: false, + predefined: true, + position: 0, + }); + }); + + // Average = 20 * 48 / 48 = 20 + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation', { + attributes: { + averageHoursPerWeek: 20, + }, + }), + ); + }); + + it('processRowUpdate skips mutation for the total row', async () => { + const { result } = renderHook(useHoursPerWeekGrid, { + wrapper: createWrapper(), + }); + + await waitForDataToLoad(); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(3); + }); + + let returned: GridValidRowModel | undefined; + await act(async () => { + returned = await result.current.processRowUpdate({ + id: 'total', + label: 'Total', + hoursPerWeek: null, + weeks: 48, + }); + }); + + expect(returned).toMatchObject({ id: 'total' }); + }); + + it('sets predefined entries as non-deletable', async () => { + const { result } = renderHook(useHoursPerWeekGrid, { + wrapper: createWrapper(), + }); + + await waitForDataToLoad(); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(3); + }); + + // All default entries are predefined + result.current.entries.forEach((entry) => { + expect(entry.canDelete).toBe(false); + expect(entry.predefined).toBe(true); + }); + }); +}); diff --git a/src/components/Reports/PdsGoalCalculator/Setup/HoursPerWeekGrid/useHoursPerWeekGrid.ts b/src/components/Reports/PdsGoalCalculator/Setup/HoursPerWeekGrid/useHoursPerWeekGrid.ts new file mode 100644 index 0000000000..6f3a743e13 --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/Setup/HoursPerWeekGrid/useHoursPerWeekGrid.ts @@ -0,0 +1,280 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { GridValidRowModel } from '@mui/x-data-grid'; +import { useTranslation } from 'react-i18next'; +import { + useCreateDesignationSupportHoursItemMutation, + useDeleteDesignationSupportHoursItemMutation, + useUpdateDesignationSupportHoursItemMutation, +} from '../../GoalsList/PdsGoalCalculations.generated'; +import { useSaveField } from '../../Shared/Autosave/useSaveField'; +import { usePdsGoalCalculator } from '../../Shared/PdsGoalCalculatorContext'; + +export interface HoursPerWeekEntry { + id: string; + label: string; + hoursPerWeek: number; + weeks: number; + canDelete: boolean; + predefined: boolean; + position: number; +} + +const MAX_TOTAL_WEEKS = 52; + +export const useHoursPerWeekGrid = () => { + const { t } = useTranslation(); + const { calculation, trackMutation } = usePdsGoalCalculator(); + const [createHoursItem] = useCreateDesignationSupportHoursItemMutation(); + const [updateHoursItem] = useUpdateDesignationSupportHoursItemMutation(); + const [deleteHoursItem] = useDeleteDesignationSupportHoursItemMutation(); + const saveField = useSaveField(); + + const [entries, setEntries] = useState([]); + const initializedRef = useRef(false); + const nextEntryIdRef = useRef(0); + + useEffect(() => { + const items = calculation?.designationSupportHoursItems; + if (!initializedRef.current && items && items.length > 0) { + initializedRef.current = true; + setEntries( + items + .slice() + .sort( + (hoursItemA, hoursItemB) => + (hoursItemA.position ?? 0) - (hoursItemB.position ?? 0), + ) + .map((item) => ({ + id: item.id, + label: item.label, + hoursPerWeek: item.hoursPerWeek ?? 0, + weeks: item.numberOfWeeks ?? 0, + canDelete: !item.predefined, + predefined: item.predefined, + position: item.position ?? 0, + })), + ); + } + }, [calculation?.designationSupportHoursItems]); + + const totalWeeks = useMemo( + () => entries.reduce((sum, entry) => sum + entry.weeks, 0), + [entries], + ); + + const totalHours = useMemo( + () => + entries.reduce((sum, entry) => sum + entry.hoursPerWeek * entry.weeks, 0), + [entries], + ); + + const averageHoursPerWeek = useMemo( + () => (totalWeeks > 0 ? totalHours / totalWeeks : 0), + [totalHours, totalWeeks], + ); + + const weeksRemaining = MAX_TOTAL_WEEKS - totalWeeks; + + const saveHoursItem = useCallback( + async (entry: HoursPerWeekEntry) => { + if (!calculation) { + return; + } + + try { + await trackMutation( + updateHoursItem({ + variables: { + attributes: { + id: entry.id, + designationSupportCalculationId: calculation.id, + label: entry.label, + hoursPerWeek: entry.hoursPerWeek, + numberOfWeeks: entry.weeks, + }, + }, + refetchQueries: ['PdsGoalCalculation'], + }), + ); + } catch { + throw new Error(t('Failed to save hours entry.')); + } + }, + [calculation, createHoursItem, updateHoursItem, trackMutation, t], + ); + + const updateEntry = useCallback( + (id: string, updates: Partial) => { + setEntries((prev) => + prev.map((entry) => + entry.id === id ? { ...entry, ...updates } : entry, + ), + ); + }, + [], + ); + + const addEntry = useCallback(async () => { + if (!calculation) { + return; + } + + const tempId = `temp-${nextEntryIdRef.current++}`; + const newPosition = + entries.length > 0 ? Math.max(...entries.map((e) => e.position)) + 1 : 0; + const newEntry: HoursPerWeekEntry = { + id: tempId, + label: t('New Entry'), + hoursPerWeek: 0, + weeks: 0, + canDelete: true, + predefined: false, + position: newPosition, + }; + setEntries((prev) => [...prev, newEntry]); + + try { + const result = await trackMutation( + createHoursItem({ + variables: { + attributes: { + designationSupportCalculationId: calculation.id, + label: t('New Entry'), + hoursPerWeek: 0, + numberOfWeeks: 0, + position: newPosition, + }, + }, + refetchQueries: ['PdsGoalCalculation'], + }), + ); + const created = + result.data?.createDesignationSupportHoursItem + ?.designationSupportHoursItem; + if (created) { + setEntries((prev) => + prev.map((e) => (e.id === tempId ? { ...e, id: created.id } : e)), + ); + } + } catch { + setEntries((prev) => prev.filter((e) => e.id !== tempId)); + } + }, [t, calculation, createHoursItem, trackMutation, entries.length]); + + const deleteEntry = useCallback( + async (id: string | number) => { + const entryId = id.toString(); + const previousEntries = entries; + const remainingEntries = entries.filter((entry) => entry.id !== entryId); + setEntries(remainingEntries); + + const newTotalWeeks = remainingEntries.reduce( + (sum, e) => sum + e.weeks, + 0, + ); + const newTotalHours = remainingEntries.reduce( + (sum, e) => sum + e.hoursPerWeek * e.weeks, + 0, + ); + const newAverage = newTotalWeeks > 0 ? newTotalHours / newTotalWeeks : 0; + + try { + if (!entryId.startsWith('temp-') && !entryId.startsWith('default-')) { + await trackMutation( + deleteHoursItem({ + variables: { id: entryId }, + refetchQueries: ['PdsGoalCalculation'], + }), + ); + } + saveField({ + averageHoursPerWeek: newAverage, + }); + } catch { + setEntries(previousEntries); + } + }, + [entries, deleteHoursItem, trackMutation, saveField], + ); + + const processRowUpdate = useCallback( + async (newRow: GridValidRowModel) => { + if (newRow.id === 'total') { + return newRow; + } + + const newWeeks = Number(newRow.weeks) || 0; + const otherWeeks = entries + .filter((e) => e.id !== newRow.id) + .reduce((sum, e) => sum + e.weeks, 0); + const clampedWeeks = Math.min(newWeeks, MAX_TOTAL_WEEKS - otherWeeks); + + const updatedEntry: HoursPerWeekEntry = { + id: newRow.id as string, + label: newRow.label as string, + hoursPerWeek: Number(newRow.hoursPerWeek) || 0, + weeks: Math.max(0, clampedWeeks), + canDelete: newRow.canDelete as boolean, + predefined: newRow.predefined as boolean, + position: Number(newRow.position) || 0, + }; + + updateEntry(newRow.id as string, { + label: updatedEntry.label, + hoursPerWeek: updatedEntry.hoursPerWeek, + weeks: updatedEntry.weeks, + }); + + await saveHoursItem(updatedEntry); + + const updatedEntries = entries.map((entry) => + entry.id === updatedEntry.id ? updatedEntry : entry, + ); + const newTotalWeeks = updatedEntries.reduce((sum, e) => sum + e.weeks, 0); + const newTotalHours = updatedEntries.reduce( + (sum, e) => sum + e.hoursPerWeek * e.weeks, + 0, + ); + const newAverage = newTotalWeeks > 0 ? newTotalHours / newTotalWeeks : 0; + saveField({ + averageHoursPerWeek: newAverage, + }); + + return { ...newRow, weeks: Math.max(0, clampedWeeks) }; + }, + [updateEntry, entries, saveHoursItem, saveField], + ); + + const dataWithTotal = useMemo( + () => [ + ...entries + .slice() + .sort((a, b) => a.position - b.position) + .map((entry) => ({ + ...entry, + totalHours: entry.hoursPerWeek * entry.weeks, + })), + { + id: 'total', + label: t('Total'), + hoursPerWeek: null, + weeks: totalWeeks, + totalHours: totalHours, + canDelete: false, + }, + ], + [entries, totalWeeks, totalHours, t], + ); + + return { + entries, + totalWeeks, + totalHours, + averageHoursPerWeek, + weeksRemaining, + dataWithTotal, + addEntry, + deleteEntry, + processRowUpdate, + }; +}; diff --git a/src/components/Reports/PdsGoalCalculator/Setup/SetupStep.test.tsx b/src/components/Reports/PdsGoalCalculator/Setup/SetupStep.test.tsx index 7edf35e4e5..d56fff27aa 100644 --- a/src/components/Reports/PdsGoalCalculator/Setup/SetupStep.test.tsx +++ b/src/components/Reports/PdsGoalCalculator/Setup/SetupStep.test.tsx @@ -284,10 +284,18 @@ describe('SetupStep', () => { , ); - expect(queryByText('Hours Per Week Calculator')).not.toBeInTheDocument(); + expect( + queryByText( + 'This calculator is based on a 52-week year. Weeks are capped at 52 and a warning will appear if the total falls short.', + ), + ).not.toBeInTheDocument(); userEvent.click(await findByLabelText('Open hours per week calculator')); - expect(await findByText('Hours Per Week Calculator')).toBeInTheDocument(); + expect( + await findByText( + 'This calculator is based on a 52-week year. Weeks are capped at 52 and a warning will appear if the total falls short.', + ), + ).toBeInTheDocument(); }); }); diff --git a/src/components/Reports/PdsGoalCalculator/Setup/SetupStep.tsx b/src/components/Reports/PdsGoalCalculator/Setup/SetupStep.tsx index ec8806f314..4a87f11718 100644 --- a/src/components/Reports/PdsGoalCalculator/Setup/SetupStep.tsx +++ b/src/components/Reports/PdsGoalCalculator/Setup/SetupStep.tsx @@ -35,7 +35,7 @@ import { HoursPerWeekGrid } from './HoursPerWeekGrid/HoursPerWeekGrid'; export const SetupStep: React.FC = () => { const { t } = useTranslation(); const theme = useTheme(); - const { calculation, hcmUser, setRightPanelContent } = usePdsGoalCalculator(); + const { calculation, hcmUser, setRightPanel } = usePdsGoalCalculator(); const { data: userData } = useGetUserQuery(); const fourOThreeB = hcmUser?.fourOThreeB; const totalFourOThreeBContributionPercentage = fourOThreeB @@ -81,7 +81,8 @@ export const SetupStep: React.FC = () => { : t('Enter hourly rate'); const handleOpenHoursCalculator = () => { - setRightPanelContent( + setRightPanel( + t('Hours Per Week Calculator'), { saveField({ hoursWorkedPerWeek: average }); diff --git a/src/components/Reports/PdsGoalCalculator/Shared/Autosave/useSaveField.ts b/src/components/Reports/PdsGoalCalculator/Shared/Autosave/useSaveField.ts index c6242873ef..2a8ce9e6cb 100644 --- a/src/components/Reports/PdsGoalCalculator/Shared/Autosave/useSaveField.ts +++ b/src/components/Reports/PdsGoalCalculator/Shared/Autosave/useSaveField.ts @@ -1,6 +1,4 @@ import { useCallback } from 'react'; -import { useSnackbar } from 'notistack'; -import { useTranslation } from 'react-i18next'; import { usePdsGoalCalculator } from 'src/components/Reports/PdsGoalCalculator/Shared/PdsGoalCalculatorContext'; import { DesignationSupportCalculationUpdateInput } from 'src/graphql/types.generated'; import { useUpdatePdsGoalCalculationMutation } from '../../GoalsList/PdsGoalCalculations.generated'; @@ -8,8 +6,6 @@ import { useUpdatePdsGoalCalculationMutation } from '../../GoalsList/PdsGoalCalc export const useSaveField = () => { const { calculation, trackMutation } = usePdsGoalCalculator(); const [updatePdsGoalCalculation] = useUpdatePdsGoalCalculationMutation(); - const { enqueueSnackbar } = useSnackbar(); - const { t } = useTranslation(); const saveField = useCallback( async (attributes: Partial) => { @@ -24,35 +20,29 @@ export const useSaveField = () => { return; } - try { - return await trackMutation( - updatePdsGoalCalculation({ - variables: { - attributes: { - id: calculation.id, - ...attributes, - }, + return trackMutation( + updatePdsGoalCalculation({ + variables: { + attributes: { + id: calculation.id, + ...attributes, }, - optimisticResponse: { - updateDesignationSupportCalculation: { - __typename: - 'DesignationSupportCalculationUpdateMutationPayload', - designationSupportCalculation: { - __typename: 'DesignationSupportCalculation', - ...calculation, - ...attributes, - }, + }, + optimisticResponse: { + updateDesignationSupportCalculation: { + __typename: + 'DesignationSupportCalculationUpdateMutationPayload', + designationSupportCalculation: { + __typename: 'DesignationSupportCalculation', + ...calculation, + ...attributes, }, }, - }), - ); - } catch { - enqueueSnackbar(t('Failed to save changes. Please try again.'), { - variant: 'error', - }); - } + }, + }), + ); }, - [calculation, trackMutation, updatePdsGoalCalculation, enqueueSnackbar, t], + [calculation, trackMutation, updatePdsGoalCalculation], ); return saveField; diff --git a/src/components/Reports/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx b/src/components/Reports/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx index 73a3eeb240..7db3690a7b 100644 --- a/src/components/Reports/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx +++ b/src/components/Reports/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx @@ -25,7 +25,8 @@ export type PdsGoalCalculatorType = { trackMutation: (mutation: Promise) => Promise; rightPanelContent: React.ReactNode; - setRightPanelContent: (content: React.ReactNode) => void; + rightPanelTitle: string | null; + setRightPanel: (title: string, content: React.ReactNode) => void; closeRightPanel: () => void; stepIndex: number; @@ -75,6 +76,7 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { const [stepIndex, setStepIndex] = useState(0); const [rightPanelContent, setRightPanelContent] = useState(null); + const [rightPanelTitle, setRightPanelTitle] = useState(null); const [isDrawerOpen, setIsDrawerOpen] = useState(true); const { trackMutation, isMutating } = useTrackMutation(); @@ -106,8 +108,17 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { } }, [stepIndex]); + const setRightPanel = useCallback( + (title: string, content: React.ReactNode) => { + setRightPanelTitle(title); + setRightPanelContent(content); + }, + [], + ); + const closeRightPanel = useCallback(() => { setRightPanelContent(null); + setRightPanelTitle(null); }, []); const toggleDrawer = useCallback(() => { @@ -125,11 +136,12 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { trackMutation, hcmUser, rightPanelContent, + rightPanelTitle, isDrawerOpen, handleStepChange, handleContinue, handlePreviousStep, - setRightPanelContent, + setRightPanel, closeRightPanel, toggleDrawer, setDrawerOpen: setIsDrawerOpen, @@ -144,11 +156,12 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { trackMutation, hcmUser, rightPanelContent, + rightPanelTitle, isDrawerOpen, handleStepChange, handleContinue, handlePreviousStep, - setRightPanelContent, + setRightPanel, closeRightPanel, toggleDrawer, setIsDrawerOpen,