diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index 773b5ae7..91da1b2e 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -174,7 +174,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase { tc.phase_id, tc.assignees, tc.billable, - tc.fixed_cost, + -- Fixed cost aggregation: include current task + all descendants + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.fixed_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.fixed_cost + END as fixed_cost, tc.sub_tasks_count, -- For parent tasks, sum values from descendants only (exclude parent task itself) CASE @@ -688,7 +696,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase { tc.phase_id, tc.assignees, tc.billable, - tc.fixed_cost, + -- Fixed cost aggregation: include current task + all descendants + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.fixed_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.fixed_cost + END as fixed_cost, tc.sub_tasks_count, -- For subtasks that have their own sub-subtasks, sum values from descendants only CASE @@ -932,7 +948,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase { tc.phase_id, tc.assignees, tc.billable, - tc.fixed_cost, + -- Fixed cost aggregation: include current task + all descendants + CASE + WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN ( + SELECT SUM(sub_tc.fixed_cost) + FROM task_costs sub_tc + WHERE sub_tc.root_id = tc.id + ) + ELSE tc.fixed_cost + END as fixed_cost, tc.sub_tasks_count, -- For parent tasks, sum values from descendants only (exclude parent task itself) CASE diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index 42581a93..444fc9fb 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -106,6 +106,26 @@ export const updateTaskFixedCostAsync = createAsyncThunk( } ); +export const updateTaskFixedCostWithRecalculation = createAsyncThunk( + 'projectFinances/updateTaskFixedCostWithRecalculation', + async ({ taskId, groupId, fixedCost, projectId, groupBy, billableFilter }: { + taskId: string; + groupId: string; + fixedCost: number; + projectId: string; + groupBy: GroupTypes; + billableFilter?: BillableFilterType; + }, { dispatch }) => { + // Update the fixed cost + await projectFinanceApiService.updateTaskFixedCost(taskId, fixedCost); + + // Trigger a silent refresh to get accurate calculations from backend + dispatch(fetchProjectFinancesSilent({ projectId, groupBy, billableFilter })); + + return { taskId, groupId, fixedCost }; + } +); + export const projectFinancesSlice = createSlice({ name: 'projectFinances', initialState, @@ -231,6 +251,78 @@ export const projectFinancesSlice = createSlice({ if (state.project) { state.project.currency = action.payload; } + }, + updateParentTaskCalculations: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => { + const { taskId, groupId } = action.payload; + const group = state.taskGroups.find(g => g.group_id === groupId); + + if (group) { + // Recursive function to recalculate parent task totals + const recalculateParentTotals = (tasks: IProjectFinanceTask[], targetId: string): boolean => { + for (const task of tasks) { + if (task.id === targetId) { + // If this task has subtasks, recalculate its totals from subtasks + if (task.sub_tasks && task.sub_tasks.length > 0) { + const subtaskTotals = task.sub_tasks.reduce((acc, subtask) => ({ + estimated_cost: acc.estimated_cost + (subtask.estimated_cost || 0), + fixed_cost: acc.fixed_cost + (subtask.fixed_cost || 0), + total_actual: acc.total_actual + (subtask.total_actual || 0), + estimated_seconds: acc.estimated_seconds + (subtask.estimated_seconds || 0), + total_time_logged_seconds: acc.total_time_logged_seconds + (subtask.total_time_logged_seconds || 0) + }), { + estimated_cost: 0, + fixed_cost: 0, + total_actual: 0, + estimated_seconds: 0, + total_time_logged_seconds: 0 + }); + + // Update parent task with aggregated values + task.estimated_cost = subtaskTotals.estimated_cost; + task.fixed_cost = subtaskTotals.fixed_cost; + task.total_actual = subtaskTotals.total_actual; + task.estimated_seconds = subtaskTotals.estimated_seconds; + task.total_time_logged_seconds = subtaskTotals.total_time_logged_seconds; + task.total_budget = task.estimated_cost + task.fixed_cost; + task.variance = task.total_actual - task.total_budget; + } + return true; + } + + // Search in subtasks recursively and recalculate if found + if (task.sub_tasks && recalculateParentTotals(task.sub_tasks, targetId)) { + // After updating subtask, recalculate this parent's totals + if (task.sub_tasks && task.sub_tasks.length > 0) { + const subtaskTotals = task.sub_tasks.reduce((acc, subtask) => ({ + estimated_cost: acc.estimated_cost + (subtask.estimated_cost || 0), + fixed_cost: acc.fixed_cost + (subtask.fixed_cost || 0), + total_actual: acc.total_actual + (subtask.total_actual || 0), + estimated_seconds: acc.estimated_seconds + (subtask.estimated_seconds || 0), + total_time_logged_seconds: acc.total_time_logged_seconds + (subtask.total_time_logged_seconds || 0) + }), { + estimated_cost: 0, + fixed_cost: 0, + total_actual: 0, + estimated_seconds: 0, + total_time_logged_seconds: 0 + }); + + task.estimated_cost = subtaskTotals.estimated_cost; + task.fixed_cost = subtaskTotals.fixed_cost; + task.total_actual = subtaskTotals.total_actual; + task.estimated_seconds = subtaskTotals.estimated_seconds; + task.total_time_logged_seconds = subtaskTotals.total_time_logged_seconds; + task.total_budget = task.estimated_cost + task.fixed_cost; + task.variance = task.total_actual - task.total_budget; + } + return true; + } + } + return false; + }; + + recalculateParentTotals(group.tasks, taskId); + } } }, extraReducers: (builder) => { @@ -248,8 +340,39 @@ export const projectFinancesSlice = createSlice({ state.loading = false; }) .addCase(fetchProjectFinancesSilent.fulfilled, (state, action) => { + // Helper function to preserve expansion state and sub_tasks during updates + const preserveExpansionState = (existingTasks: IProjectFinanceTask[], newTasks: IProjectFinanceTask[]): IProjectFinanceTask[] => { + return newTasks.map(newTask => { + const existingTask = existingTasks.find(t => t.id === newTask.id); + if (existingTask) { + // Preserve expansion state and subtasks + const updatedTask = { + ...newTask, + show_sub_tasks: existingTask.show_sub_tasks, + sub_tasks: existingTask.sub_tasks ? + preserveExpansionState(existingTask.sub_tasks, newTask.sub_tasks || []) : + newTask.sub_tasks + }; + return updatedTask; + } + return newTask; + }); + }; + + // Update groups while preserving expansion state + const updatedTaskGroups = action.payload.groups.map(newGroup => { + const existingGroup = state.taskGroups.find(g => g.group_id === newGroup.group_id); + if (existingGroup) { + return { + ...newGroup, + tasks: preserveExpansionState(existingGroup.tasks, newGroup.tasks) + }; + } + return newGroup; + }); + // Update data without changing loading state for silent refresh - state.taskGroups = action.payload.groups; + state.taskGroups = updatedTaskGroups; state.projectRateCards = action.payload.project_rate_cards; state.project = action.payload.project; }) @@ -263,7 +386,44 @@ export const projectFinancesSlice = createSlice({ for (const task of tasks) { if (task.id === targetId) { task.fixed_cost = fixedCost; - // Don't recalculate here - trigger a refresh instead for accuracy + // Recalculate financial values immediately for UI responsiveness + const totalBudget = (task.estimated_cost || 0) + fixedCost; + const totalActual = task.total_actual || 0; + const variance = totalActual - totalBudget; + + task.total_budget = totalBudget; + task.variance = variance; + return true; + } + + // Search in subtasks recursively + if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) { + return true; + } + } + return false; + }; + + findAndUpdateTask(group.tasks, taskId); + } + }) + .addCase(updateTaskFixedCostWithRecalculation.fulfilled, (state, action) => { + const { taskId, groupId, fixedCost } = action.payload; + const group = state.taskGroups.find(g => g.group_id === groupId); + + if (group) { + // Recursive function to find and update a task in the hierarchy + const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => { + for (const task of tasks) { + if (task.id === targetId) { + task.fixed_cost = fixedCost; + // Immediate calculation for UI responsiveness + const totalBudget = (task.estimated_cost || 0) + fixedCost; + const totalActual = task.total_actual || 0; + const variance = totalActual - totalBudget; + + task.total_budget = totalBudget; + task.variance = variance; return true; } @@ -321,7 +481,8 @@ export const { updateTaskEstimatedCost, updateTaskTimeLogged, toggleTaskExpansion, - updateProjectFinanceCurrency + updateProjectFinanceCurrency, + updateParentTaskCalculations } = projectFinancesSlice.actions; export default projectFinancesSlice.reducer; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx index 968956a0..95236d51 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx @@ -13,6 +13,8 @@ import Avatars from '@/components/avatars/avatars'; import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; import { updateTaskFixedCostAsync, + updateTaskFixedCostWithRecalculation, + updateParentTaskCalculations, fetchProjectFinancesSilent, toggleTaskExpansion, fetchSubTasks @@ -50,6 +52,7 @@ const FinanceTable = ({ // Get the latest task groups from Redux store const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups); const activeGroup = useAppSelector((state) => state.projectFinances.activeGroup); + const billableFilter = useAppSelector((state) => state.projectFinances.billableFilter); // Auth and permissions const auth = useAuthService(); @@ -144,12 +147,21 @@ const FinanceTable = ({ const fixedCost = value || 0; try { - // Make the API call to persist the change + // First update the task fixed cost await dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })).unwrap(); - // Silent refresh the data to get accurate calculations from backend without loading animation + // Then update parent task calculations to reflect the change + dispatch(updateParentTaskCalculations({ taskId, groupId: table.group_id })); + + // Finally, trigger a silent refresh to ensure backend consistency if (projectId) { - dispatch(fetchProjectFinancesSilent({ projectId, groupBy: activeGroup })); + setTimeout(() => { + dispatch(fetchProjectFinancesSilent({ + projectId, + groupBy: activeGroup, + billableFilter + })); + }, 100); // Small delay to allow UI update to complete first } } catch (error) { console.error('Failed to update fixed cost:', error); @@ -459,39 +471,57 @@ const FinanceTable = ({ }, [tasks, selectedTask, editingFixedCostValue, hasEditPermission, themeMode, hoveredTaskId]); // Calculate totals for the current table - // Since the backend already aggregates subtask values into parent tasks, - // we only need to sum the parent tasks (tasks without is_sub_task flag) + // Recursively calculate totals including all subtasks const totals = useMemo(() => { - return tasks.reduce( - (acc, task) => { - // Calculate actual cost from logs (total_actual - fixed_cost) - const actualCostFromLogs = (task.total_actual || 0) - (task.fixed_cost || 0); - - // The backend already handles aggregation for parent tasks with subtasks - // Parent tasks contain the sum of their subtasks' values - // So we can safely sum all parent tasks (which are the tasks in this array) - return { - hours: acc.hours + (task.estimated_seconds || 0), - total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), - estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), - actual_cost_from_logs: acc.actual_cost_from_logs + actualCostFromLogs, - fixed_cost: acc.fixed_cost + (task.fixed_cost || 0), - total_budget: acc.total_budget + (task.total_budget || 0), - total_actual: acc.total_actual + (task.total_actual || 0), - variance: acc.variance + (task.variance || 0) - }; - }, - { - hours: 0, - total_time_logged: 0, - estimated_cost: 0, - actual_cost_from_logs: 0, - fixed_cost: 0, - total_budget: 0, - total_actual: 0, - variance: 0 - } - ); + const calculateTaskTotalsRecursively = (taskList: IProjectFinanceTask[]): any => { + return taskList.reduce( + (acc, task) => { + // Calculate actual cost from logs (total_actual - fixed_cost) + const actualCostFromLogs = (task.total_actual || 0) - (task.fixed_cost || 0); + + // Add current task values + const taskTotals = { + hours: acc.hours + (task.estimated_seconds || 0), + total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), + estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), + actual_cost_from_logs: acc.actual_cost_from_logs + actualCostFromLogs, + fixed_cost: acc.fixed_cost + (task.fixed_cost || 0), + total_budget: acc.total_budget + (task.total_budget || 0), + total_actual: acc.total_actual + (task.total_actual || 0), + variance: acc.variance + (task.variance || 0) + }; + + // If task has subtasks, recursively add their totals + if (task.sub_tasks && task.sub_tasks.length > 0) { + const subTaskTotals = calculateTaskTotalsRecursively(task.sub_tasks); + return { + hours: taskTotals.hours + subTaskTotals.hours, + total_time_logged: taskTotals.total_time_logged + subTaskTotals.total_time_logged, + estimated_cost: taskTotals.estimated_cost + subTaskTotals.estimated_cost, + actual_cost_from_logs: taskTotals.actual_cost_from_logs + subTaskTotals.actual_cost_from_logs, + fixed_cost: taskTotals.fixed_cost + subTaskTotals.fixed_cost, + total_budget: taskTotals.total_budget + subTaskTotals.total_budget, + total_actual: taskTotals.total_actual + subTaskTotals.total_actual, + variance: taskTotals.variance + subTaskTotals.variance + }; + } + + return taskTotals; + }, + { + hours: 0, + total_time_logged: 0, + estimated_cost: 0, + actual_cost_from_logs: 0, + fixed_cost: 0, + total_budget: 0, + total_actual: 0, + variance: 0 + } + ); + }; + + return calculateTaskTotalsRecursively(tasks); }, [tasks]); // Format the totals for display diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx index 4b75d264..50a8ed81 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx @@ -55,15 +55,48 @@ const ProjectViewFinance = () => { }; } - const totals = taskGroups.reduce((acc, group) => { - group.tasks.forEach(task => { - acc.totalEstimatedCost += task.estimated_cost || 0; - acc.totalFixedCost += task.fixed_cost || 0; - acc.totalBudget += task.total_budget || 0; - acc.totalActualCost += task.total_actual || 0; - acc.totalVariance += task.variance || 0; + const calculateTaskTotalsRecursively = (tasks: any[]): any => { + return tasks.reduce((acc, task) => { + // Add current task values + const taskTotals = { + totalEstimatedCost: acc.totalEstimatedCost + (task.estimated_cost || 0), + totalFixedCost: acc.totalFixedCost + (task.fixed_cost || 0), + totalBudget: acc.totalBudget + (task.total_budget || 0), + totalActualCost: acc.totalActualCost + (task.total_actual || 0), + totalVariance: acc.totalVariance + (task.variance || 0) + }; + + // If task has subtasks, recursively add their totals + if (task.sub_tasks && task.sub_tasks.length > 0) { + const subTaskTotals = calculateTaskTotalsRecursively(task.sub_tasks); + return { + totalEstimatedCost: taskTotals.totalEstimatedCost + subTaskTotals.totalEstimatedCost, + totalFixedCost: taskTotals.totalFixedCost + subTaskTotals.totalFixedCost, + totalBudget: taskTotals.totalBudget + subTaskTotals.totalBudget, + totalActualCost: taskTotals.totalActualCost + subTaskTotals.totalActualCost, + totalVariance: taskTotals.totalVariance + subTaskTotals.totalVariance + }; + } + + return taskTotals; + }, { + totalEstimatedCost: 0, + totalFixedCost: 0, + totalBudget: 0, + totalActualCost: 0, + totalVariance: 0 }); - return acc; + }; + + const totals = taskGroups.reduce((acc, group) => { + const groupTotals = calculateTaskTotalsRecursively(group.tasks); + return { + totalEstimatedCost: acc.totalEstimatedCost + groupTotals.totalEstimatedCost, + totalFixedCost: acc.totalFixedCost + groupTotals.totalFixedCost, + totalBudget: acc.totalBudget + groupTotals.totalBudget, + totalActualCost: acc.totalActualCost + groupTotals.totalActualCost, + totalVariance: acc.totalVariance + groupTotals.totalVariance + }; }, { totalEstimatedCost: 0, totalFixedCost: 0,