Merge branch 'feature/project-finance' of https://github.com/Worklenz/worklenz into feature/project-finance

This commit is contained in:
root
2025-06-11 07:00:50 +00:00
7 changed files with 625 additions and 193 deletions

View File

@@ -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
@@ -352,6 +360,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
Number(task.total_time_logged_seconds) || 0
),
estimated_cost: Number(task.estimated_cost) || 0,
actual_cost_from_logs: Number(task.actual_cost_from_logs) || 0,
fixed_cost: Number(task.fixed_cost) || 0,
total_budget: Number(task.total_budget) || 0,
total_actual: Number(task.total_actual) || 0,
@@ -391,14 +400,42 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
.send(new ServerResponse(false, null, "Invalid fixed cost value"));
}
const q = `
// 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."));
}
// Update only the specific subtask's fixed cost
const updateQuery = `
UPDATE tasks
SET fixed_cost = $1, updated_at = NOW()
WHERE id = $2
RETURNING id, name, fixed_cost;
`;
const result = await db.query(q, [fixed_cost, taskId]);
const result = await db.query(updateQuery, [fixed_cost, taskId]);
if (result.rows.length === 0) {
return res
@@ -406,7 +443,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
.send(new ServerResponse(false, null, "Task not found"));
}
return res.status(200).send(new ServerResponse(true, result.rows[0]));
return res.status(200).send(new ServerResponse(true, {
updated_task: result.rows[0],
message: "Fixed cost updated successfully."
}));
}
@HandleExceptions()
@@ -688,7 +728,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
@@ -796,6 +844,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
Number(task.total_time_logged_seconds) || 0
),
estimated_cost: Number(task.estimated_cost) || 0,
actual_cost_from_logs: Number(task.actual_cost_from_logs) || 0,
fixed_cost: Number(task.fixed_cost) || 0,
total_budget: Number(task.total_budget) || 0,
total_actual: Number(task.total_actual) || 0,
@@ -932,7 +981,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
@@ -1110,6 +1167,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
Number(task.total_time_logged_seconds) || 0
),
estimated_cost: Number(task.estimated_cost) || 0,
actual_cost_from_logs: Number(task.actual_cost_from_logs) || 0,
fixed_cost: Number(task.fixed_cost) || 0,
total_budget: Number(task.total_budget) || 0,
total_actual: Number(task.total_actual) || 0,

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,165 @@ 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 => {
// If task has loaded subtasks, recalculate from subtasks
if (task.sub_tasks && task.sub_tasks.length > 0) {
const updatedSubTasks = recalculateTaskHierarchy(task.sub_tasks);
// Calculate totals from subtasks only (for time and costs from logs)
const subtaskTotals = updatedSubTasks.reduce((acc, subtask) => ({
estimated_cost: acc.estimated_cost + (subtask.estimated_cost || 0),
fixed_cost: acc.fixed_cost + (subtask.fixed_cost || 0),
actual_cost_from_logs: acc.actual_cost_from_logs + (subtask.actual_cost_from_logs || 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,
actual_cost_from_logs: 0,
estimated_seconds: 0,
total_time_logged_seconds: 0
});
// For parent tasks with loaded subtasks: use ONLY the subtask totals
// The parent's original values were backend-aggregated, now we use frontend subtask aggregation
const totalFixedCost = subtaskTotals.fixed_cost; // Only subtask fixed costs
const totalEstimatedCost = subtaskTotals.estimated_cost; // Only subtask estimated costs
const totalActualCostFromLogs = subtaskTotals.actual_cost_from_logs; // Only subtask logged costs
const totalActual = totalActualCostFromLogs + totalFixedCost;
// Update parent task with aggregated values
const updatedTask = {
...task,
sub_tasks: updatedSubTasks,
estimated_cost: totalEstimatedCost,
fixed_cost: totalFixedCost,
actual_cost_from_logs: totalActualCostFromLogs,
total_actual: totalActual,
estimated_seconds: subtaskTotals.estimated_seconds,
total_time_logged_seconds: subtaskTotals.total_time_logged_seconds,
total_budget: totalEstimatedCost + totalFixedCost,
variance: totalActual - (totalEstimatedCost + totalFixedCost)
};
return updatedTask;
}
// For parent tasks without loaded subtasks, trust backend-calculated values
if (task.sub_tasks_count > 0 && (!task.sub_tasks || task.sub_tasks.length === 0)) {
// Parent task with unloaded subtasks - backend has already calculated aggregated values
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
return {
...task,
total_budget: totalBudget,
total_actual: totalActual,
variance: variance
};
}
// For leaf tasks, check cache first
const cacheKey = generateTaskCacheKey(task);
const cached = taskCalculationCache.get(cacheKey);
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 only for leaf tasks
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,6 +239,11 @@ export const updateTaskFixedCostAsync = createAsyncThunk(
}
);
// Function to clear calculation cache (useful for testing or when data is refreshed)
const clearCalculationCache = () => {
taskCalculationCache.clear();
};
export const projectFinancesSlice = createSlice({
name: 'projectFinances',
initialState,
@@ -124,24 +262,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 }>) => {
@@ -149,58 +281,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 }>) => {
@@ -231,7 +344,7 @@ export const projectFinancesSlice = createSlice({
if (state.project) {
state.project.currency = action.payload;
}
}
},
},
extraReducers: (builder) => {
builder
@@ -240,42 +353,82 @@ export const projectFinancesSlice = createSlice({
})
.addCase(fetchProjectFinances.fulfilled, (state, action) => {
state.loading = false;
state.taskGroups = action.payload.groups;
// Apply hierarchy recalculation to ensure parent tasks show correct aggregated values
const recalculatedGroups = action.payload.groups.map(group => ({
...group,
tasks: recalculateTaskHierarchy(group.tasks)
}));
state.taskGroups = recalculatedGroups;
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;
})
.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 and applying hierarchy recalculation
const updatedTaskGroups = action.payload.groups.map(newGroup => {
const existingGroup = state.taskGroups.find(g => g.group_id === newGroup.group_id);
if (existingGroup) {
const tasksWithExpansion = preserveExpansionState(existingGroup.tasks, newGroup.tasks);
return {
...newGroup,
tasks: recalculateTaskHierarchy(tasksWithExpansion)
};
}
return {
...newGroup,
tasks: recalculateTaskHierarchy(newGroup.tasks)
};
});
// 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;
// Clear cache when data is refreshed from backend
clearCalculationCache();
})
.addCase(updateTaskFixedCostAsync.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;
// Don't recalculate here - trigger a refresh instead for accuracy
return true;
}
// Search in subtasks recursively
if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) {
return true;
}
}
return false;
};
// Update the specific task's fixed cost and recalculate the entire hierarchy
const result = updateTaskAndRecalculateHierarchy(
group.tasks,
taskId,
(task) => ({
...task,
fixed_cost: fixedCost
})
);
findAndUpdateTask(group.tasks, taskId);
if (result.updated) {
group.tasks = result.tasks;
clearCalculationCache();
}
}
})
.addCase(fetchSubTasks.fulfilled, (state, action) => {
@@ -306,6 +459,8 @@ export const projectFinancesSlice = createSlice({
// Find the parent task in any group and add the subtasks
for (const group of state.taskGroups) {
if (findAndUpdateTask(group.tasks, parentTaskId)) {
// Recalculate the hierarchy after adding subtasks to ensure parent values are correct
group.tasks = recalculateTaskHierarchy(group.tasks);
break;
}
}

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.actual_cost_from_logs || 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.actual_cost_from_logs || 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,7 +13,6 @@ import Avatars from '@/components/avatars/avatars';
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
import {
updateTaskFixedCostAsync,
fetchProjectFinancesSilent,
toggleTaskExpansion,
fetchSubTasks
} from '@/features/projects/finance/project-finance.slice';
@@ -49,7 +48,6 @@ const FinanceTable = ({
// Get the latest task groups from Redux store
const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups);
const activeGroup = useAppSelector((state) => state.projectFinances.activeGroup);
// Auth and permissions
const auth = useAuthService();
@@ -71,7 +69,7 @@ const FinanceTable = ({
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (selectedTask && !(event.target as Element)?.closest('.fixed-cost-input')) {
// Save current value before closing
// Save current value before closing if it has changed
if (editingFixedCostValue !== null) {
immediateSaveFixedCost(editingFixedCostValue, selectedTask.id);
} else {
@@ -85,7 +83,7 @@ const FinanceTable = ({
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [selectedTask, editingFixedCostValue]);
}, [selectedTask, editingFixedCostValue, tasks]);
// Cleanup timeout on unmount
useEffect(() => {
@@ -143,14 +141,37 @@ 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 {
// Make the API call to persist the change
// Update the task fixed cost - this will automatically trigger hierarchical recalculation
// The Redux slice handles parent task updates through recalculateTaskHierarchy
await dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })).unwrap();
// Silent refresh the data to get accurate calculations from backend without loading animation
if (projectId) {
dispatch(fetchProjectFinancesSilent({ projectId, groupBy: activeGroup }));
}
setSelectedTask(null);
setEditingFixedCostValue(null);
} catch (error) {
console.error('Failed to update fixed cost:', error);
}
@@ -190,7 +211,24 @@ const FinanceTable = ({
// Set new timeout
saveTimeoutRef.current = setTimeout(() => {
if (value !== null) {
// Find the current task to check if value actually changed
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 currentTask = findTask(tasks, taskId);
const currentFixedCost = currentTask?.fixed_cost || 0;
const newFixedCost = value || 0;
// Only save if the value actually changed
if (newFixedCost !== currentFixedCost && value !== null) {
handleFixedCostChange(value, taskId);
setSelectedTask(null);
setEditingFixedCostValue(null);
@@ -206,11 +244,30 @@ const FinanceTable = ({
saveTimeoutRef.current = null;
}
if (value !== null) {
// Find the current task to check if value actually changed
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 currentTask = findTask(tasks, taskId);
const currentFixedCost = currentTask?.fixed_cost || 0;
const newFixedCost = value || 0;
// Only save if the value actually changed
if (newFixedCost !== currentFixedCost && value !== null) {
handleFixedCostChange(value, taskId);
} else {
// Just close the editor without saving
setSelectedTask(null);
setEditingFixedCostValue(null);
}
setSelectedTask(null);
setEditingFixedCostValue(null);
};
// Calculate indentation based on nesting level
@@ -365,7 +422,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) => {
@@ -392,17 +453,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>
@@ -425,7 +489,7 @@ const FinanceTable = ({
case FinanceTableColumnKeys.TOTAL_ACTUAL:
return <Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>{formatNumber(task.total_actual)}</Typography.Text>;
case FinanceTableColumnKeys.COST:
return <Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>{formatNumber((task.total_actual || 0) - (task.fixed_cost || 0))}</Typography.Text>;
return <Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>{formatNumber(task.actual_cost_from_logs || 0)}</Typography.Text>;
default:
return null;
}
@@ -459,29 +523,10 @@ 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)
// Optimized calculation that avoids double counting in nested hierarchies
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)
};
},
{
const calculateTaskTotalsRecursive = (taskList: IProjectFinanceTask[]): any => {
let totals = {
hours: 0,
total_time_logged: 0,
estimated_cost: 0,
@@ -490,8 +535,38 @@ const FinanceTable = ({
total_budget: 0,
total_actual: 0,
variance: 0
};
for (const task of taskList) {
if (task.sub_tasks && task.sub_tasks.length > 0) {
// Parent task with loaded subtasks - only count the subtasks recursively
// This completely avoids the parent's aggregated values to prevent double counting
const subtaskTotals = calculateTaskTotalsRecursive(task.sub_tasks);
totals.hours += subtaskTotals.hours;
totals.total_time_logged += subtaskTotals.total_time_logged;
totals.estimated_cost += subtaskTotals.estimated_cost;
totals.actual_cost_from_logs += subtaskTotals.actual_cost_from_logs;
totals.fixed_cost += subtaskTotals.fixed_cost;
totals.total_budget += subtaskTotals.total_budget;
totals.total_actual += subtaskTotals.total_actual;
totals.variance += subtaskTotals.variance;
} else {
// Leaf task or parent task without loaded subtasks - use its values directly
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.actual_cost_from_logs || 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 calculateTaskTotalsRecursive(tasks);
}, [tasks]);
// Format the totals for display

View File

@@ -55,15 +55,48 @@ const ProjectViewFinance = () => {
};
}
// 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) => {
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;
});
return acc;
const groupTotals = calculateTaskTotalsFlat(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,

View File

@@ -34,6 +34,7 @@ export interface IProjectFinanceTask {
total_time_logged_seconds: number;
total_time_logged: string; // Formatted time string like "4h 30m 12s"
estimated_cost: number;
actual_cost_from_logs: number;
members: IProjectFinanceMember[];
billable: boolean;
fixed_cost: number;