feat(project-finance): optimize task cost calculations and enhance UI responsiveness

- Implemented checks in the ProjectFinanceController to prevent fixed cost updates for parent tasks with subtasks, ensuring accurate financial data.
- Enhanced the project finance slice with memoization and optimized recursive calculations for task hierarchies, improving performance and reducing unnecessary API calls.
- Updated the FinanceTable component to reflect these changes, ensuring totals are calculated without double counting and providing immediate UI updates.
- Added a README to document the new optimized finance calculation system and its features.
This commit is contained in:
chamikaJ
2025-06-11 10:05:40 +05:30
parent e0a290c18f
commit 06488d80ff
6 changed files with 499 additions and 337 deletions

View File

@@ -399,6 +399,33 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
.send(new ServerResponse(false, null, "Invalid fixed cost value"));
}
// Check if the task has subtasks - parent tasks should not have editable fixed costs
const checkParentQuery = `
SELECT
t.id,
t.name,
(SELECT COUNT(*) FROM tasks st WHERE st.parent_task_id = t.id AND st.archived = false) as sub_tasks_count
FROM tasks t
WHERE t.id = $1 AND t.archived = false;
`;
const checkResult = await db.query(checkParentQuery, [taskId]);
if (checkResult.rows.length === 0) {
return res
.status(404)
.send(new ServerResponse(false, null, "Task not found"));
}
const task = checkResult.rows[0];
// Prevent updating fixed cost for parent tasks
if (task.sub_tasks_count > 0) {
return res
.status(400)
.send(new ServerResponse(false, null, "Cannot update fixed cost for parent tasks. Fixed cost is calculated from subtasks."));
}
const q = `
UPDATE tasks
SET fixed_cost = $1, updated_at = NOW()

View File

@@ -0,0 +1,83 @@
# Optimized Finance Calculation System
## Overview
This system provides efficient frontend recalculation of project finance data when fixed costs are updated, eliminating the need for API refetches and ensuring optimal performance even with deeply nested task hierarchies.
## Key Features
### 1. Hierarchical Recalculation
- When a nested subtask's fixed cost is updated, all parent tasks are automatically recalculated
- Parent task totals are aggregated from their subtasks to avoid double counting
- Calculations propagate up the entire task hierarchy efficiently
### 2. Performance Optimizations
- **Memoization**: Task calculations are cached to avoid redundant computations
- **Smart Cache Management**: Cache entries expire automatically and are cleaned up periodically
- **Selective Updates**: Only tasks that have actually changed trigger recalculations
### 3. Frontend-Only Updates
- No API refetches required for fixed cost updates
- Immediate UI responsiveness
- Reduced server load and network traffic
## How It Works
### Task Update Flow
1. User updates fixed cost in UI
2. `updateTaskFixedCostAsync` is dispatched
3. API call updates the backend
4. Redux reducer updates the task and triggers `recalculateTaskHierarchy`
5. All parent tasks are recalculated automatically
6. UI updates immediately with new values
### Calculation Logic
```typescript
// For parent tasks with subtasks
parentTask.fixed_cost = sum(subtask.fixed_cost)
parentTask.total_budget = parentTask.estimated_cost + parentTask.fixed_cost
parentTask.variance = parentTask.total_actual - parentTask.total_budget
// For leaf tasks
task.total_budget = task.estimated_cost + task.fixed_cost
task.variance = task.total_actual - task.total_budget
```
### Memoization Strategy
- Cache key includes all relevant financial fields
- Cache entries expire after 10 minutes
- Cache is cleared when fresh data is loaded from API
- Automatic cleanup prevents memory leaks
## Usage Examples
### Updating Fixed Cost
```typescript
// This will automatically recalculate all parent tasks
dispatch(updateTaskFixedCostAsync({
taskId: 'subtask-123',
groupId: 'group-456',
fixedCost: 1500
}));
```
### Budget Statistics
The budget statistics in the project overview are calculated efficiently:
- Avoids double counting in nested hierarchies
- Uses aggregated values from parent tasks
- Updates automatically when any task changes
## Performance Benefits
1. **Reduced API Calls**: No refetching required for fixed cost updates
2. **Faster UI Updates**: Immediate recalculation and display
3. **Memory Efficient**: Smart caching with automatic cleanup
4. **Scalable**: Handles deeply nested task hierarchies efficiently
## Cache Management
The system includes automatic cache management:
- Cache cleanup every 5 minutes
- Entries expire after 10 minutes
- Manual cache clearing when fresh data is loaded
- Memory-efficient with automatic garbage collection

View File

@@ -17,7 +17,7 @@ interface ProjectFinanceState {
project: IProjectFinanceProject | null;
}
// Utility functions for frontend calculations
// Enhanced utility functions for efficient frontend calculations
const secondsToHours = (seconds: number) => seconds / 3600;
const calculateTaskCosts = (task: IProjectFinanceTask) => {
@@ -25,8 +25,6 @@ const calculateTaskCosts = (task: IProjectFinanceTask) => {
const timeLoggedHours = secondsToHours(task.total_time_logged_seconds || 0);
const fixedCost = task.fixed_cost || 0;
// For fixed cost updates, we'll rely on the backend values
// and trigger a re-fetch to ensure accuracy
const totalBudget = (task.estimated_cost || 0) + fixedCost;
const totalActual = task.total_actual || 0;
const variance = totalActual - totalBudget;
@@ -40,30 +38,152 @@ const calculateTaskCosts = (task: IProjectFinanceTask) => {
};
};
const calculateGroupTotals = (tasks: IProjectFinanceTask[]) => {
return tasks.reduce(
(acc, task) => {
const { hours, timeLoggedHours, totalBudget, totalActual, variance } = calculateTaskCosts(task);
return {
hours: acc.hours + hours,
total_time_logged: acc.total_time_logged + timeLoggedHours,
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0),
total_budget: acc.total_budget + totalBudget,
total_actual: acc.total_actual + totalActual,
variance: acc.variance + variance
};
},
{
hours: 0,
total_time_logged: 0,
estimated_cost: 0,
total_budget: 0,
total_actual: 0,
variance: 0
// Memoization cache for task calculations to improve performance
const taskCalculationCache = new Map<string, {
task: IProjectFinanceTask;
result: IProjectFinanceTask;
timestamp: number;
}>();
// Cache cleanup interval (5 minutes)
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000;
const CACHE_MAX_AGE = 10 * 60 * 1000; // 10 minutes
// Periodic cache cleanup
setInterval(() => {
const now = Date.now();
Array.from(taskCalculationCache.entries()).forEach(([key, value]) => {
if (now - value.timestamp > CACHE_MAX_AGE) {
taskCalculationCache.delete(key);
}
});
}, CACHE_CLEANUP_INTERVAL);
// Generate cache key for task
const generateTaskCacheKey = (task: IProjectFinanceTask): string => {
return `${task.id}-${task.estimated_cost}-${task.fixed_cost}-${task.total_actual}-${task.estimated_seconds}-${task.total_time_logged_seconds}`;
};
// Check if task has changed significantly to warrant recalculation
const hasTaskChanged = (oldTask: IProjectFinanceTask, newTask: IProjectFinanceTask): boolean => {
return (
oldTask.estimated_cost !== newTask.estimated_cost ||
oldTask.fixed_cost !== newTask.fixed_cost ||
oldTask.total_actual !== newTask.total_actual ||
oldTask.estimated_seconds !== newTask.estimated_seconds ||
oldTask.total_time_logged_seconds !== newTask.total_time_logged_seconds
);
};
// Optimized recursive calculation for task hierarchy with memoization
const recalculateTaskHierarchy = (tasks: IProjectFinanceTask[]): IProjectFinanceTask[] => {
return tasks.map(task => {
const cacheKey = generateTaskCacheKey(task);
const cached = taskCalculationCache.get(cacheKey);
// If task has subtasks, first recalculate all subtasks recursively
if (task.sub_tasks && task.sub_tasks.length > 0) {
const updatedSubTasks = recalculateTaskHierarchy(task.sub_tasks);
// Calculate parent task totals from subtasks
const subtaskTotals = updatedSubTasks.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
const updatedTask = {
...task,
sub_tasks: updatedSubTasks,
estimated_cost: subtaskTotals.estimated_cost,
fixed_cost: subtaskTotals.fixed_cost,
total_actual: subtaskTotals.total_actual,
estimated_seconds: subtaskTotals.estimated_seconds,
total_time_logged_seconds: subtaskTotals.total_time_logged_seconds,
total_budget: subtaskTotals.estimated_cost + subtaskTotals.fixed_cost,
variance: subtaskTotals.total_actual - (subtaskTotals.estimated_cost + subtaskTotals.fixed_cost)
};
// Cache the result
taskCalculationCache.set(cacheKey, {
task: { ...task },
result: updatedTask,
timestamp: Date.now()
});
return updatedTask;
}
// For leaf tasks, check cache first
if (cached && !hasTaskChanged(cached.task, task)) {
return { ...cached.result, ...task }; // Merge with current task to preserve other properties
}
// For leaf tasks, just recalculate their own values
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
const updatedTask = {
...task,
total_budget: totalBudget,
total_actual: totalActual,
variance: variance
};
// Cache the result
taskCalculationCache.set(cacheKey, {
task: { ...task },
result: updatedTask,
timestamp: Date.now()
});
return updatedTask;
});
};
// Optimized function to find and update a specific task, then recalculate hierarchy
const updateTaskAndRecalculateHierarchy = (
tasks: IProjectFinanceTask[],
targetId: string,
updateFn: (task: IProjectFinanceTask) => IProjectFinanceTask
): { updated: boolean; tasks: IProjectFinanceTask[] } => {
let updated = false;
const updatedTasks = tasks.map(task => {
if (task.id === targetId) {
updated = true;
return updateFn(task);
}
// Search in subtasks recursively
if (task.sub_tasks && task.sub_tasks.length > 0) {
const result = updateTaskAndRecalculateHierarchy(task.sub_tasks, targetId, updateFn);
if (result.updated) {
updated = true;
return {
...task,
sub_tasks: result.tasks
};
}
}
return task;
});
// If a task was updated, recalculate the entire hierarchy to ensure parent totals are correct
return {
updated,
tasks: updated ? recalculateTaskHierarchy(updatedTasks) : updatedTasks
};
};
const initialState: ProjectFinanceState = {
activeTab: 'finance',
activeGroup: 'status',
@@ -106,25 +226,10 @@ 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 };
}
);
// Function to clear calculation cache (useful for testing or when data is refreshed)
const clearCalculationCache = () => {
taskCalculationCache.clear();
};
export const projectFinancesSlice = createSlice({
name: 'projectFinances',
@@ -144,24 +249,18 @@ export const projectFinancesSlice = createSlice({
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;
// Don't recalculate here - let the backend handle it and we'll refresh
return true;
}
// Search in subtasks recursively
if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) {
return true;
}
}
return false;
};
const result = updateTaskAndRecalculateHierarchy(
group.tasks,
taskId,
(task) => ({
...task,
fixed_cost: fixedCost
})
);
findAndUpdateTask(group.tasks, taskId);
if (result.updated) {
group.tasks = result.tasks;
}
}
},
updateTaskEstimatedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; estimatedCost: number }>) => {
@@ -169,58 +268,39 @@ export const projectFinancesSlice = createSlice({
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.estimated_cost = estimatedCost;
// Recalculate task costs after updating estimated cost
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
task.total_budget = totalBudget;
task.total_actual = totalActual;
task.variance = variance;
return true;
}
// Search in subtasks recursively
if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) {
return true;
}
}
return false;
};
const result = updateTaskAndRecalculateHierarchy(
group.tasks,
taskId,
(task) => ({
...task,
estimated_cost: estimatedCost
})
);
findAndUpdateTask(group.tasks, taskId);
if (result.updated) {
group.tasks = result.tasks;
}
}
},
updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLoggedSeconds: number; timeLoggedString: string }>) => {
const { taskId, groupId, timeLoggedSeconds, timeLoggedString } = action.payload;
updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLoggedSeconds: number; timeLoggedString: string; totalActual: number }>) => {
const { taskId, groupId, timeLoggedSeconds, timeLoggedString, totalActual } = 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.total_time_logged_seconds = timeLoggedSeconds;
task.total_time_logged = timeLoggedString;
// Recalculate task costs after updating time logged
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
task.total_budget = totalBudget;
task.total_actual = totalActual;
task.variance = variance;
return true;
}
// Search in subtasks recursively
if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) {
return true;
}
}
return false;
};
const result = updateTaskAndRecalculateHierarchy(
group.tasks,
taskId,
(task) => ({
...task,
total_time_logged_seconds: timeLoggedSeconds,
total_time_logged: timeLoggedString,
total_actual: totalActual
})
);
findAndUpdateTask(group.tasks, taskId);
if (result.updated) {
group.tasks = result.tasks;
}
}
},
toggleTaskExpansion: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => {
@@ -252,78 +332,6 @@ export const projectFinancesSlice = createSlice({
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) => {
builder
@@ -335,6 +343,8 @@ export const projectFinancesSlice = createSlice({
state.taskGroups = action.payload.groups;
state.projectRateCards = action.payload.project_rate_cards;
state.project = action.payload.project;
// Clear cache when fresh data is loaded
clearCalculationCache();
})
.addCase(fetchProjectFinances.rejected, (state) => {
state.loading = false;
@@ -375,6 +385,8 @@ export const projectFinancesSlice = createSlice({
state.taskGroups = updatedTaskGroups;
state.projectRateCards = action.payload.project_rate_cards;
state.project = action.payload.project;
// Clear cache when data is refreshed from backend
clearCalculationCache();
})
.addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => {
const { taskId, groupId, fixedCost } = action.payload;
@@ -407,37 +419,6 @@ export const projectFinancesSlice = createSlice({
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;
}
// Search in subtasks recursively
if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) {
return true;
}
}
return false;
};
findAndUpdateTask(group.tasks, taskId);
}
})
.addCase(fetchSubTasks.fulfilled, (state, action) => {
const { parentTaskId, subTasks } = action.payload;
@@ -481,8 +462,7 @@ export const {
updateTaskEstimatedCost,
updateTaskTimeLogged,
toggleTaskExpansion,
updateProjectFinanceCurrency,
updateParentTaskCalculations
updateProjectFinanceCurrency
} = projectFinancesSlice.actions;
export default projectFinancesSlice.reducer;

View File

@@ -8,7 +8,7 @@ import { openFinanceDrawer } from '@/features/finance/finance-slice';
import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns';
import FinanceTable from './finance-table';
import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer';
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
import { createPortal } from 'react-dom';
interface FinanceTableWrapperProps {
@@ -64,33 +64,37 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesL
// Use Redux store data for totals calculation to ensure reactivity
const totals = useMemo(() => {
return taskGroups.reduce(
(
acc: {
hours: number;
cost: number;
fixedCost: number;
totalBudget: number;
totalActual: number;
variance: number;
total_time_logged: number;
estimated_cost: number;
},
table: IProjectFinanceGroup
) => {
table.tasks.forEach((task) => {
acc.hours += (task.estimated_seconds) || 0;
acc.cost += ((task.total_actual || 0) - (task.fixed_cost || 0));
acc.fixedCost += task.fixed_cost || 0;
acc.totalBudget += task.total_budget || 0;
acc.totalActual += task.total_actual || 0;
acc.variance += task.variance || 0;
acc.total_time_logged += (task.total_time_logged_seconds) || 0;
acc.estimated_cost += task.estimated_cost || 0;
});
return acc;
},
{
// Recursive function to calculate totals from task hierarchy without double counting
const calculateTaskTotalsRecursively = (tasks: IProjectFinanceTask[]): any => {
return tasks.reduce((acc, task) => {
// For parent tasks with subtasks, only count the aggregated values (no double counting)
// For leaf tasks, count their individual values
if (task.sub_tasks && task.sub_tasks.length > 0) {
// Parent task - use its aggregated values which already include subtask totals
return {
hours: acc.hours + (task.estimated_seconds || 0),
cost: acc.cost + ((task.total_actual || 0) - (task.fixed_cost || 0)),
fixedCost: acc.fixedCost + (task.fixed_cost || 0),
totalBudget: acc.totalBudget + (task.total_budget || 0),
totalActual: acc.totalActual + (task.total_actual || 0),
variance: acc.variance + (task.variance || 0),
total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0),
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0)
};
} else {
// Leaf task - use its individual values
return {
hours: acc.hours + (task.estimated_seconds || 0),
cost: acc.cost + ((task.total_actual || 0) - (task.fixed_cost || 0)),
fixedCost: acc.fixedCost + (task.fixed_cost || 0),
totalBudget: acc.totalBudget + (task.total_budget || 0),
totalActual: acc.totalActual + (task.total_actual || 0),
variance: acc.variance + (task.variance || 0),
total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0),
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0)
};
}
}, {
hours: 0,
cost: 0,
fixedCost: 0,
@@ -98,9 +102,32 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesL
totalActual: 0,
variance: 0,
total_time_logged: 0,
estimated_cost: 0,
}
);
estimated_cost: 0
});
};
return taskGroups.reduce((acc, table: IProjectFinanceGroup) => {
const groupTotals = calculateTaskTotalsRecursively(table.tasks);
return {
hours: acc.hours + groupTotals.hours,
cost: acc.cost + groupTotals.cost,
fixedCost: acc.fixedCost + groupTotals.fixedCost,
totalBudget: acc.totalBudget + groupTotals.totalBudget,
totalActual: acc.totalActual + groupTotals.totalActual,
variance: acc.variance + groupTotals.variance,
total_time_logged: acc.total_time_logged + groupTotals.total_time_logged,
estimated_cost: acc.estimated_cost + groupTotals.estimated_cost
};
}, {
hours: 0,
cost: 0,
fixedCost: 0,
totalBudget: 0,
totalActual: 0,
variance: 0,
total_time_logged: 0,
estimated_cost: 0
});
}, [taskGroups]);
@@ -251,4 +278,4 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesL
);
};
export default FinanceTableWrapper;
export default FinanceTableWrapper;

View File

@@ -13,9 +13,6 @@ import Avatars from '@/components/avatars/avatars';
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
import {
updateTaskFixedCostAsync,
updateTaskFixedCostWithRecalculation,
updateParentTaskCalculations,
fetchProjectFinancesSilent,
toggleTaskExpansion,
fetchSubTasks
} from '@/features/projects/finance/project-finance.slice';
@@ -146,23 +143,35 @@ const FinanceTable = ({
const handleFixedCostChange = async (value: number | null, taskId: string) => {
const fixedCost = value || 0;
// Find the task to check if it's a parent task
const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => {
for (const task of tasks) {
if (task.id === id) return task;
if (task.sub_tasks) {
const found = findTask(task.sub_tasks, id);
if (found) return found;
}
}
return null;
};
const task = findTask(tasks, taskId);
if (!task) {
console.error('Task not found:', taskId);
return;
}
// Prevent editing fixed cost for parent tasks
if (task.sub_tasks_count > 0) {
console.warn('Cannot edit fixed cost for parent tasks. Fixed cost is calculated from subtasks.');
return;
}
try {
// First update the task fixed cost
// Update the task fixed cost - this will automatically trigger hierarchical recalculation
await dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })).unwrap();
// 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) {
setTimeout(() => {
dispatch(fetchProjectFinancesSilent({
projectId,
groupBy: activeGroup,
billableFilter
}));
}, 100); // Small delay to allow UI update to complete first
}
// No need for manual parent calculations or API refetch - the Redux slice handles it efficiently
} catch (error) {
console.error('Failed to update fixed cost:', error);
}
@@ -377,7 +386,11 @@ const FinanceTable = ({
case FinanceTableColumnKeys.ESTIMATED_COST:
return <Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>{formatNumber(task.estimated_cost)}</Typography.Text>;
case FinanceTableColumnKeys.FIXED_COST:
return selectedTask?.id === task.id && hasEditPermission ? (
// Parent tasks with subtasks should not be editable - they aggregate from subtasks
const isParentTask = task.sub_tasks_count > 0;
const canEditThisTask = hasEditPermission && !isParentTask;
return selectedTask?.id === task.id && canEditThisTask ? (
<InputNumber
value={editingFixedCostValue !== null ? editingFixedCostValue : task.fixed_cost}
onChange={(value) => {
@@ -404,17 +417,20 @@ const FinanceTable = ({
) : (
<Typography.Text
style={{
cursor: hasEditPermission ? 'pointer' : 'default',
cursor: canEditThisTask ? 'pointer' : 'default',
width: '100%',
display: 'block',
opacity: hasEditPermission ? 1 : 0.7,
fontSize: Math.max(12, 14 - level * 0.5)
opacity: canEditThisTask ? 1 : 0.7,
fontSize: Math.max(12, 14 - level * 0.5),
fontStyle: isParentTask ? 'italic' : 'normal',
color: isParentTask ? (themeMode === 'dark' ? '#888' : '#666') : 'inherit'
}}
onClick={hasEditPermission ? (e) => {
onClick={canEditThisTask ? (e) => {
e.stopPropagation();
setSelectedTask(task);
setEditingFixedCostValue(task.fixed_cost);
} : undefined}
title={isParentTask ? 'Fixed cost is calculated from subtasks' : undefined}
>
{formatNumber(task.fixed_cost)}
</Typography.Text>
@@ -471,57 +487,50 @@ const FinanceTable = ({
}, [tasks, selectedTask, editingFixedCostValue, hasEditPermission, themeMode, hoveredTaskId]);
// Calculate totals for the current table
// Recursively calculate totals including all subtasks
// Optimized calculation that avoids double counting in nested hierarchies
const totals = useMemo(() => {
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
const calculateTaskTotalsFlat = (taskList: IProjectFinanceTask[]): any => {
let totals = {
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
};
for (const task of taskList) {
// For parent tasks with subtasks, only count the aggregated values (no double counting)
// For leaf tasks, count their individual values
if (task.sub_tasks && task.sub_tasks.length > 0) {
// Parent task - use its aggregated values which already include subtask totals
totals.hours += task.estimated_seconds || 0;
totals.total_time_logged += task.total_time_logged_seconds || 0;
totals.estimated_cost += task.estimated_cost || 0;
totals.actual_cost_from_logs += (task.total_actual || 0) - (task.fixed_cost || 0);
totals.fixed_cost += task.fixed_cost || 0;
totals.total_budget += task.total_budget || 0;
totals.total_actual += task.total_actual || 0;
totals.variance += task.variance || 0;
} else {
// Leaf task - use its individual values
totals.hours += task.estimated_seconds || 0;
totals.total_time_logged += task.total_time_logged_seconds || 0;
totals.estimated_cost += task.estimated_cost || 0;
totals.actual_cost_from_logs += (task.total_actual || 0) - (task.fixed_cost || 0);
totals.fixed_cost += task.fixed_cost || 0;
totals.total_budget += task.total_budget || 0;
totals.total_actual += task.total_actual || 0;
totals.variance += task.variance || 0;
}
);
}
return totals;
};
return calculateTaskTotalsRecursively(tasks);
return calculateTaskTotalsFlat(tasks);
}, [tasks]);
// Format the totals for display
@@ -593,6 +602,42 @@ const FinanceTable = ({
{/* task rows with recursive hierarchy */}
{!isCollapse && flattenedTasks}
{/* Group totals row */}
{!isCollapse && tasks.length > 0 && (
<tr
style={{
height: 40,
backgroundColor: themeWiseColor('#f0f0f0', '#2a2a2a', themeMode),
fontWeight: 600,
borderTop: `1px solid ${themeMode === 'dark' ? '#404040' : '#e0e0e0'}`,
}}
className={`group-totals ${themeMode === 'dark' ? 'dark' : ''}`}
>
{financeTableColumns.map((col) => (
<td
key={`total-${col.key}`}
style={{
width: col.width,
paddingInline: 16,
textAlign: col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS ? 'left' : 'right',
backgroundColor: themeWiseColor('#f0f0f0', '#2a2a2a', themeMode),
fontSize: 14,
fontWeight: 600,
}}
className={customColumnStyles(col.key)}
>
{col.key === FinanceTableColumnKeys.TASK ? (
<Typography.Text style={{ fontSize: 14, fontWeight: 600 }}>
Group Total
</Typography.Text>
) : col.key === FinanceTableColumnKeys.MEMBERS ? null : (
renderFinancialTableHeaderContent(col.key)
)}
</td>
))}
</tr>
)}
</>
);
};

View File

@@ -55,41 +55,41 @@ const ProjectViewFinance = () => {
};
}
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;
}, {
// Optimized calculation that avoids double counting in nested hierarchies
const calculateTaskTotalsFlat = (tasks: any[]): any => {
let totals = {
totalEstimatedCost: 0,
totalFixedCost: 0,
totalBudget: 0,
totalActualCost: 0,
totalVariance: 0
});
};
for (const task of tasks) {
// For parent tasks with subtasks, only count the aggregated values (no double counting)
// For leaf tasks, count their individual values
if (task.sub_tasks && task.sub_tasks.length > 0) {
// Parent task - use its aggregated values which already include subtask totals
totals.totalEstimatedCost += task.estimated_cost || 0;
totals.totalFixedCost += task.fixed_cost || 0;
totals.totalBudget += task.total_budget || 0;
totals.totalActualCost += task.total_actual || 0;
totals.totalVariance += task.variance || 0;
} else {
// Leaf task - use its individual values
totals.totalEstimatedCost += task.estimated_cost || 0;
totals.totalFixedCost += task.fixed_cost || 0;
totals.totalBudget += task.total_budget || 0;
totals.totalActualCost += task.total_actual || 0;
totals.totalVariance += task.variance || 0;
}
}
return totals;
};
const totals = taskGroups.reduce((acc, group) => {
const groupTotals = calculateTaskTotalsRecursively(group.tasks);
const groupTotals = calculateTaskTotalsFlat(group.tasks);
return {
totalEstimatedCost: acc.totalEstimatedCost + groupTotals.totalEstimatedCost,
totalFixedCost: acc.totalFixedCost + groupTotals.totalFixedCost,