Merge branch 'feature/project-finance' of https://github.com/Worklenz/worklenz into feature/project-finance
This commit is contained in:
@@ -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,
|
||||
|
||||
83
worklenz-frontend/src/features/projects/finance/README.md
Normal file
83
worklenz-frontend/src/features/projects/finance/README.md
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user