refactor(project-finance): improve task cost calculations and UI hierarchy
- Updated SQL queries in the ProjectFinanceController to exclude parent tasks from descendant cost calculations, ensuring accurate financial data aggregation. - Refactored the project finance slice to implement recursive task updates for fixed costs, estimated costs, and time logged, enhancing task management efficiency. - Enhanced the FinanceTable component to visually represent task hierarchy with improved indentation and hover effects, improving user experience and clarity. - Added responsive styles for nested tasks and adjusted task name styling for better readability across different levels.
This commit is contained in:
@@ -176,12 +176,12 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
tc.billable,
|
tc.billable,
|
||||||
tc.fixed_cost,
|
tc.fixed_cost,
|
||||||
tc.sub_tasks_count,
|
tc.sub_tasks_count,
|
||||||
-- For parent tasks, sum values from all descendants including self
|
-- For parent tasks, sum values from descendants only (exclude parent task itself)
|
||||||
CASE
|
CASE
|
||||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||||
SELECT SUM(sub_tc.estimated_seconds)
|
SELECT SUM(sub_tc.estimated_seconds)
|
||||||
FROM task_costs sub_tc
|
FROM task_costs sub_tc
|
||||||
WHERE sub_tc.root_id = tc.id
|
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||||
)
|
)
|
||||||
ELSE tc.estimated_seconds
|
ELSE tc.estimated_seconds
|
||||||
END as estimated_seconds,
|
END as estimated_seconds,
|
||||||
@@ -189,7 +189,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||||
SELECT SUM(sub_tc.total_time_logged_seconds)
|
SELECT SUM(sub_tc.total_time_logged_seconds)
|
||||||
FROM task_costs sub_tc
|
FROM task_costs sub_tc
|
||||||
WHERE sub_tc.root_id = tc.id
|
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||||
)
|
)
|
||||||
ELSE tc.total_time_logged_seconds
|
ELSE tc.total_time_logged_seconds
|
||||||
END as total_time_logged_seconds,
|
END as total_time_logged_seconds,
|
||||||
@@ -197,7 +197,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||||
SELECT SUM(sub_tc.estimated_cost)
|
SELECT SUM(sub_tc.estimated_cost)
|
||||||
FROM task_costs sub_tc
|
FROM task_costs sub_tc
|
||||||
WHERE sub_tc.root_id = tc.id
|
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||||
)
|
)
|
||||||
ELSE tc.estimated_cost
|
ELSE tc.estimated_cost
|
||||||
END as estimated_cost,
|
END as estimated_cost,
|
||||||
@@ -205,7 +205,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||||
SELECT SUM(sub_tc.actual_cost_from_logs)
|
SELECT SUM(sub_tc.actual_cost_from_logs)
|
||||||
FROM task_costs sub_tc
|
FROM task_costs sub_tc
|
||||||
WHERE sub_tc.root_id = tc.id
|
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||||
)
|
)
|
||||||
ELSE tc.actual_cost_from_logs
|
ELSE tc.actual_cost_from_logs
|
||||||
END as actual_cost_from_logs
|
END as actual_cost_from_logs
|
||||||
@@ -860,12 +860,12 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
tc.billable,
|
tc.billable,
|
||||||
tc.fixed_cost,
|
tc.fixed_cost,
|
||||||
tc.sub_tasks_count,
|
tc.sub_tasks_count,
|
||||||
-- For parent tasks, sum values from all descendants including self
|
-- For parent tasks, sum values from descendants only (exclude parent task itself)
|
||||||
CASE
|
CASE
|
||||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||||
SELECT SUM(sub_tc.estimated_seconds)
|
SELECT SUM(sub_tc.estimated_seconds)
|
||||||
FROM task_costs sub_tc
|
FROM task_costs sub_tc
|
||||||
WHERE sub_tc.root_id = tc.id
|
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||||
)
|
)
|
||||||
ELSE tc.estimated_seconds
|
ELSE tc.estimated_seconds
|
||||||
END as estimated_seconds,
|
END as estimated_seconds,
|
||||||
@@ -873,7 +873,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||||
SELECT SUM(sub_tc.total_time_logged_seconds)
|
SELECT SUM(sub_tc.total_time_logged_seconds)
|
||||||
FROM task_costs sub_tc
|
FROM task_costs sub_tc
|
||||||
WHERE sub_tc.root_id = tc.id
|
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||||
)
|
)
|
||||||
ELSE tc.total_time_logged_seconds
|
ELSE tc.total_time_logged_seconds
|
||||||
END as total_time_logged_seconds,
|
END as total_time_logged_seconds,
|
||||||
@@ -881,7 +881,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||||
SELECT SUM(sub_tc.estimated_cost)
|
SELECT SUM(sub_tc.estimated_cost)
|
||||||
FROM task_costs sub_tc
|
FROM task_costs sub_tc
|
||||||
WHERE sub_tc.root_id = tc.id
|
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||||
)
|
)
|
||||||
ELSE tc.estimated_cost
|
ELSE tc.estimated_cost
|
||||||
END as estimated_cost,
|
END as estimated_cost,
|
||||||
@@ -889,7 +889,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||||
SELECT SUM(sub_tc.actual_cost_from_logs)
|
SELECT SUM(sub_tc.actual_cost_from_logs)
|
||||||
FROM task_costs sub_tc
|
FROM task_costs sub_tc
|
||||||
WHERE sub_tc.root_id = tc.id
|
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||||
)
|
)
|
||||||
ELSE tc.actual_cost_from_logs
|
ELSE tc.actual_cost_from_logs
|
||||||
END as actual_cost_from_logs
|
END as actual_cost_from_logs
|
||||||
|
|||||||
@@ -122,53 +122,109 @@ export const projectFinancesSlice = createSlice({
|
|||||||
updateTaskFixedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>) => {
|
updateTaskFixedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>) => {
|
||||||
const { taskId, groupId, fixedCost } = action.payload;
|
const { taskId, groupId, fixedCost } = action.payload;
|
||||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||||
|
|
||||||
if (group) {
|
if (group) {
|
||||||
const task = group.tasks.find(t => t.id === taskId);
|
// Recursive function to find and update a task in the hierarchy
|
||||||
if (task) {
|
const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => {
|
||||||
task.fixed_cost = fixedCost;
|
for (const task of tasks) {
|
||||||
// Don't recalculate here - let the backend handle it and we'll refresh
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
findAndUpdateTask(group.tasks, taskId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateTaskEstimatedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; estimatedCost: number }>) => {
|
updateTaskEstimatedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; estimatedCost: number }>) => {
|
||||||
const { taskId, groupId, estimatedCost } = action.payload;
|
const { taskId, groupId, estimatedCost } = action.payload;
|
||||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||||
|
|
||||||
if (group) {
|
if (group) {
|
||||||
const task = group.tasks.find(t => t.id === taskId);
|
// Recursive function to find and update a task in the hierarchy
|
||||||
if (task) {
|
const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => {
|
||||||
task.estimated_cost = estimatedCost;
|
for (const task of tasks) {
|
||||||
// Recalculate task costs after updating estimated cost
|
if (task.id === targetId) {
|
||||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
task.estimated_cost = estimatedCost;
|
||||||
task.total_budget = totalBudget;
|
// Recalculate task costs after updating estimated cost
|
||||||
task.total_actual = totalActual;
|
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||||
task.variance = variance;
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
findAndUpdateTask(group.tasks, taskId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLoggedSeconds: number; timeLoggedString: string }>) => {
|
updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLoggedSeconds: number; timeLoggedString: string }>) => {
|
||||||
const { taskId, groupId, timeLoggedSeconds, timeLoggedString } = action.payload;
|
const { taskId, groupId, timeLoggedSeconds, timeLoggedString } = action.payload;
|
||||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||||
|
|
||||||
if (group) {
|
if (group) {
|
||||||
const task = group.tasks.find(t => t.id === taskId);
|
// Recursive function to find and update a task in the hierarchy
|
||||||
if (task) {
|
const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => {
|
||||||
task.total_time_logged_seconds = timeLoggedSeconds;
|
for (const task of tasks) {
|
||||||
task.total_time_logged = timeLoggedString;
|
if (task.id === targetId) {
|
||||||
// Recalculate task costs after updating time logged
|
task.total_time_logged_seconds = timeLoggedSeconds;
|
||||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
task.total_time_logged = timeLoggedString;
|
||||||
task.total_budget = totalBudget;
|
// Recalculate task costs after updating time logged
|
||||||
task.total_actual = totalActual;
|
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||||
task.variance = variance;
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
findAndUpdateTask(group.tasks, taskId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggleTaskExpansion: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => {
|
toggleTaskExpansion: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => {
|
||||||
const { taskId, groupId } = action.payload;
|
const { taskId, groupId } = action.payload;
|
||||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||||
|
|
||||||
if (group) {
|
if (group) {
|
||||||
const task = group.tasks.find(t => t.id === taskId);
|
// Recursive function to find and toggle a task in the hierarchy
|
||||||
if (task) {
|
const findAndToggleTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => {
|
||||||
task.show_sub_tasks = !task.show_sub_tasks;
|
for (const task of tasks) {
|
||||||
}
|
if (task.id === targetId) {
|
||||||
|
task.show_sub_tasks = !task.show_sub_tasks;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in subtasks recursively
|
||||||
|
if (task.sub_tasks && findAndToggleTask(task.sub_tasks, targetId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
findAndToggleTask(group.tasks, taskId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateProjectFinanceCurrency: (state, action: PayloadAction<string>) => {
|
updateProjectFinanceCurrency: (state, action: PayloadAction<string>) => {
|
||||||
@@ -200,26 +256,56 @@ export const projectFinancesSlice = createSlice({
|
|||||||
.addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => {
|
.addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => {
|
||||||
const { taskId, groupId, fixedCost } = action.payload;
|
const { taskId, groupId, fixedCost } = action.payload;
|
||||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||||
|
|
||||||
if (group) {
|
if (group) {
|
||||||
const task = group.tasks.find(t => t.id === taskId);
|
// Recursive function to find and update a task in the hierarchy
|
||||||
if (task) {
|
const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => {
|
||||||
task.fixed_cost = fixedCost;
|
for (const task of tasks) {
|
||||||
// Don't recalculate here - trigger a refresh instead for accuracy
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
findAndUpdateTask(group.tasks, taskId);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.addCase(fetchSubTasks.fulfilled, (state, action) => {
|
.addCase(fetchSubTasks.fulfilled, (state, action) => {
|
||||||
const { parentTaskId, subTasks } = action.payload;
|
const { parentTaskId, subTasks } = action.payload;
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// Found the parent task, add subtasks
|
||||||
|
task.sub_tasks = subTasks.map(subTask => ({
|
||||||
|
...subTask,
|
||||||
|
is_sub_task: true,
|
||||||
|
parent_task_id: targetId
|
||||||
|
}));
|
||||||
|
task.show_sub_tasks = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in subtasks recursively
|
||||||
|
if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
// Find the parent task in any group and add the subtasks
|
// Find the parent task in any group and add the subtasks
|
||||||
for (const group of state.taskGroups) {
|
for (const group of state.taskGroups) {
|
||||||
const parentTask = group.tasks.find(t => t.id === parentTaskId);
|
if (findAndUpdateTask(group.tasks, parentTaskId)) {
|
||||||
if (parentTask) {
|
|
||||||
parentTask.sub_tasks = subTasks.map(subTask => ({
|
|
||||||
...subTask,
|
|
||||||
is_sub_task: true,
|
|
||||||
parent_task_id: parentTaskId
|
|
||||||
}));
|
|
||||||
parentTask.show_sub_tasks = true;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,63 @@
|
|||||||
/* Finance Table Styles */
|
/* Finance Table Styles */
|
||||||
|
|
||||||
|
/* Enhanced hierarchy visual indicators */
|
||||||
|
.finance-table-task-row {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .finance-table-task-row {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effect is now handled by inline styles in the component for consistency */
|
||||||
|
|
||||||
|
/* Nested task styling */
|
||||||
|
.finance-table-nested-task {
|
||||||
|
/* No visual connectors, just clean indentation */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expand/collapse button styling */
|
||||||
|
.finance-table-expand-btn {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-table-expand-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .finance-table-expand-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task name styling for different levels */
|
||||||
|
.finance-table-task-name {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-table-task-name:hover {
|
||||||
|
color: #40a9ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fixed cost input styling */
|
||||||
|
.fixed-cost-input {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed-cost-input:focus {
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for nested content */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.finance-table-nested-task {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-table-task-name {
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Flex, InputNumber, Skeleton, Tooltip, Typography } from 'antd';
|
import { Flex, InputNumber, Skeleton, Tooltip, Typography } from 'antd';
|
||||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import {
|
import {
|
||||||
DollarCircleOutlined,
|
DollarCircleOutlined,
|
||||||
@@ -24,6 +24,8 @@ import { useParams } from 'react-router-dom';
|
|||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
import { canEditFixedCost } from '@/utils/finance-permissions';
|
import { canEditFixedCost } from '@/utils/finance-permissions';
|
||||||
import './finance-table.css';
|
import './finance-table.css';
|
||||||
|
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||||
|
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||||
|
|
||||||
type FinanceTableProps = {
|
type FinanceTableProps = {
|
||||||
table: IProjectFinanceGroup;
|
table: IProjectFinanceGroup;
|
||||||
@@ -41,6 +43,7 @@ const FinanceTable = ({
|
|||||||
const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
|
const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
|
||||||
const [editingFixedCostValue, setEditingFixedCostValue] = useState<number | null>(null);
|
const [editingFixedCostValue, setEditingFixedCostValue] = useState<number | null>(null);
|
||||||
const [tasks, setTasks] = useState<IProjectFinanceTask[]>(table.tasks);
|
const [tasks, setTasks] = useState<IProjectFinanceTask[]>(table.tasks);
|
||||||
|
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
@@ -159,8 +162,10 @@ const FinanceTable = ({
|
|||||||
if (!taskId || !projectId) return;
|
if (!taskId || !projectId) return;
|
||||||
|
|
||||||
dispatch(setSelectedTaskId(taskId));
|
dispatch(setSelectedTaskId(taskId));
|
||||||
dispatch(setShowTaskDrawer(true));
|
dispatch(fetchPhasesByProjectId(projectId));
|
||||||
|
dispatch(fetchPriorities());
|
||||||
dispatch(fetchTask({ taskId, projectId }));
|
dispatch(fetchTask({ taskId, projectId }));
|
||||||
|
dispatch(setShowTaskDrawer(true));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle task expansion/collapse
|
// Handle task expansion/collapse
|
||||||
@@ -208,35 +213,112 @@ const FinanceTable = ({
|
|||||||
setEditingFixedCostValue(null);
|
setEditingFixedCostValue(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => {
|
// Calculate indentation based on nesting level
|
||||||
|
const getTaskIndentation = (level: number) => level * 32; // 32px per level for better visibility
|
||||||
|
|
||||||
|
// Recursive function to render task hierarchy
|
||||||
|
const renderTaskHierarchy = (task: IProjectFinanceTask, level: number = 0): React.ReactElement[] => {
|
||||||
|
const elements: React.ReactElement[] = [];
|
||||||
|
|
||||||
|
// Add the current task
|
||||||
|
const isHovered = hoveredTaskId === task.id;
|
||||||
|
const rowIndex = elements.length;
|
||||||
|
const defaultBg = rowIndex % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode);
|
||||||
|
const hoverBg = themeMode === 'dark' ? 'rgba(64, 169, 255, 0.08)' : 'rgba(24, 144, 255, 0.04)';
|
||||||
|
|
||||||
|
elements.push(
|
||||||
|
<tr
|
||||||
|
key={task.id}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
background: isHovered ? hoverBg : defaultBg,
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
className={`finance-table-task-row ${level > 0 ? 'finance-table-nested-task' : ''} ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||||
|
onMouseEnter={() => setHoveredTaskId(task.id)}
|
||||||
|
onMouseLeave={() => setHoveredTaskId(null)}
|
||||||
|
>
|
||||||
|
{financeTableColumns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={`${task.id}-${col.key}`}
|
||||||
|
style={{
|
||||||
|
width: col.width,
|
||||||
|
paddingInline: 16,
|
||||||
|
textAlign: col.type === 'string' ? 'left' : 'right',
|
||||||
|
backgroundColor: (col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS) ?
|
||||||
|
(isHovered ? hoverBg : defaultBg) :
|
||||||
|
(isHovered ? hoverBg : 'transparent'),
|
||||||
|
cursor: 'default'
|
||||||
|
}}
|
||||||
|
className={customColumnStyles(col.key)}
|
||||||
|
onClick={
|
||||||
|
col.key === FinanceTableColumnKeys.FIXED_COST
|
||||||
|
? (e) => e.stopPropagation()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{renderFinancialTableColumnContent(col.key, task, level)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add subtasks recursively if they are expanded and loaded
|
||||||
|
if (task.show_sub_tasks && task.sub_tasks) {
|
||||||
|
task.sub_tasks.forEach(subTask => {
|
||||||
|
elements.push(...renderTaskHierarchy(subTask, level + 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask, level: number = 0) => {
|
||||||
switch (columnKey) {
|
switch (columnKey) {
|
||||||
case FinanceTableColumnKeys.TASK:
|
case FinanceTableColumnKeys.TASK:
|
||||||
return (
|
return (
|
||||||
<Tooltip title={task.name}>
|
<Tooltip title={task.name}>
|
||||||
<Flex gap={8} align="center">
|
<Flex gap={8} align="center" style={{ paddingLeft: getTaskIndentation(level) }}>
|
||||||
{/* Indentation for subtasks */}
|
|
||||||
{task.is_sub_task && <div style={{ width: 20 }} />}
|
|
||||||
|
|
||||||
{/* Expand/collapse icon for parent tasks */}
|
{/* Expand/collapse icon for parent tasks */}
|
||||||
{task.sub_tasks_count > 0 && (
|
{task.sub_tasks_count > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{ cursor: 'pointer', width: 16, display: 'flex', justifyContent: 'center' }}
|
className="finance-table-expand-btn"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleTaskExpansion(task);
|
handleTaskExpansion(task);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{task.show_sub_tasks ? <DownOutlined /> : <RightOutlined />}
|
{task.show_sub_tasks ? <DownOutlined style={{ fontSize: 12 }} /> : <RightOutlined style={{ fontSize: 12 }} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Spacer for tasks without subtasks to align with those that have expand icons */}
|
||||||
|
{task.sub_tasks_count === 0 && level > 0 && (
|
||||||
|
<div style={{ width: 18, height: 18, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Task name */}
|
{/* Task name */}
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
|
className="finance-table-task-name"
|
||||||
ellipsis={{ expanded: false }}
|
ellipsis={{ expanded: false }}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: task.is_sub_task ? 140 : (task.sub_tasks_count > 0 ? 140 : 160),
|
maxWidth: Math.max(100, 200 - getTaskIndentation(level) - (task.sub_tasks_count > 0 ? 26 : 18)),
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: '#1890ff'
|
color: '#1890ff',
|
||||||
|
fontSize: Math.max(12, 14 - level * 0.3), // Slightly smaller font for deeper levels
|
||||||
|
opacity: Math.max(0.85, 1 - level * 0.03), // Slightly faded for deeper levels
|
||||||
|
fontWeight: level > 0 ? 400 : 500 // Slightly lighter weight for nested tasks
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -251,7 +333,7 @@ const FinanceTable = ({
|
|||||||
>
|
>
|
||||||
{task.name}
|
{task.name}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
{task.billable && <DollarCircleOutlined />}
|
{task.billable && <DollarCircleOutlined style={{ fontSize: 12, flexShrink: 0 }} />}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -277,11 +359,11 @@ const FinanceTable = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case FinanceTableColumnKeys.HOURS:
|
case FinanceTableColumnKeys.HOURS:
|
||||||
return <Typography.Text>{task.estimated_hours}</Typography.Text>;
|
return <Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>{task.estimated_hours}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||||
return <Typography.Text>{task.total_time_logged}</Typography.Text>;
|
return <Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>{task.total_time_logged}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||||
return <Typography.Text>{formatNumber(task.estimated_cost)}</Typography.Text>;
|
return <Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>{formatNumber(task.estimated_cost)}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.FIXED_COST:
|
case FinanceTableColumnKeys.FIXED_COST:
|
||||||
return selectedTask?.id === task.id && hasEditPermission ? (
|
return selectedTask?.id === task.id && hasEditPermission ? (
|
||||||
<InputNumber
|
<InputNumber
|
||||||
@@ -300,7 +382,7 @@ const FinanceTable = ({
|
|||||||
immediateSaveFixedCost(editingFixedCostValue, task.id);
|
immediateSaveFixedCost(editingFixedCostValue, task.id);
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={{ width: '100%', textAlign: 'right' }}
|
style={{ width: '100%', textAlign: 'right', fontSize: Math.max(12, 14 - level * 0.5) }}
|
||||||
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||||
parser={(value) => Number(value!.replace(/\$\s?|(,*)/g, ''))}
|
parser={(value) => Number(value!.replace(/\$\s?|(,*)/g, ''))}
|
||||||
min={0}
|
min={0}
|
||||||
@@ -313,7 +395,8 @@ const FinanceTable = ({
|
|||||||
cursor: hasEditPermission ? 'pointer' : 'default',
|
cursor: hasEditPermission ? 'pointer' : 'default',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
opacity: hasEditPermission ? 1 : 0.7
|
opacity: hasEditPermission ? 1 : 0.7,
|
||||||
|
fontSize: Math.max(12, 14 - level * 0.5)
|
||||||
}}
|
}}
|
||||||
onClick={hasEditPermission ? (e) => {
|
onClick={hasEditPermission ? (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -328,7 +411,8 @@ const FinanceTable = ({
|
|||||||
return (
|
return (
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
style={{
|
style={{
|
||||||
color: task.variance > 0 ? '#FF0000' : '#6DC376'
|
color: task.variance > 0 ? '#FF0000' : '#6DC376',
|
||||||
|
fontSize: Math.max(12, 14 - level * 0.5)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{task.variance < 0 ? '+' + formatNumber(Math.abs(task.variance)) :
|
{task.variance < 0 ? '+' + formatNumber(Math.abs(task.variance)) :
|
||||||
@@ -337,11 +421,11 @@ const FinanceTable = ({
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
);
|
);
|
||||||
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||||
return <Typography.Text>{formatNumber(task.total_budget)}</Typography.Text>;
|
return <Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>{formatNumber(task.total_budget)}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
||||||
return <Typography.Text>{formatNumber(task.total_actual)}</Typography.Text>;
|
return <Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>{formatNumber(task.total_actual)}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.COST:
|
case FinanceTableColumnKeys.COST:
|
||||||
return <Typography.Text>{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.total_actual || 0) - (task.fixed_cost || 0))}</Typography.Text>;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -363,32 +447,29 @@ const FinanceTable = ({
|
|||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Flatten tasks to include subtasks for rendering
|
// Generate flattened task list with all nested levels
|
||||||
const flattenedTasks = useMemo(() => {
|
const flattenedTasks = useMemo(() => {
|
||||||
const flattened: IProjectFinanceTask[] = [];
|
const flattened: React.ReactElement[] = [];
|
||||||
|
|
||||||
tasks.forEach(task => {
|
tasks.forEach(task => {
|
||||||
// Add the parent task
|
flattened.push(...renderTaskHierarchy(task, 0));
|
||||||
flattened.push(task);
|
|
||||||
|
|
||||||
// Add subtasks if they are expanded and loaded
|
|
||||||
if (task.show_sub_tasks && task.sub_tasks) {
|
|
||||||
task.sub_tasks.forEach(subTask => {
|
|
||||||
flattened.push(subTask);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return flattened;
|
return flattened;
|
||||||
}, [tasks]);
|
}, [tasks, selectedTask, editingFixedCostValue, hasEditPermission, themeMode, hoveredTaskId]);
|
||||||
|
|
||||||
// Calculate totals for the current table (only count parent tasks to avoid double counting)
|
// 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)
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
return tasks.reduce(
|
return tasks.reduce(
|
||||||
(acc, task) => {
|
(acc, task) => {
|
||||||
// Calculate actual cost from logs (total_actual - fixed_cost)
|
// Calculate actual cost from logs (total_actual - fixed_cost)
|
||||||
const actualCostFromLogs = (task.total_actual || 0) - (task.fixed_cost || 0);
|
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 {
|
return {
|
||||||
hours: acc.hours + (task.estimated_seconds || 0),
|
hours: acc.hours + (task.estimated_seconds || 0),
|
||||||
total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0),
|
total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0),
|
||||||
@@ -480,43 +561,8 @@ const FinanceTable = ({
|
|||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* task rows */}
|
{/* task rows with recursive hierarchy */}
|
||||||
{!isCollapse && flattenedTasks.map((task, idx) => (
|
{!isCollapse && flattenedTasks}
|
||||||
<tr
|
|
||||||
key={task.id}
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
background: idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode),
|
|
||||||
transition: 'background 0.2s',
|
|
||||||
}}
|
|
||||||
className={themeMode === 'dark' ? 'dark' : ''}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = themeWiseColor('#f0f0f0', '#333', themeMode)}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)}
|
|
||||||
>
|
|
||||||
{financeTableColumns.map((col) => (
|
|
||||||
<td
|
|
||||||
key={`${task.id}-${col.key}`}
|
|
||||||
style={{
|
|
||||||
width: col.width,
|
|
||||||
paddingInline: 16,
|
|
||||||
textAlign: col.type === 'string' ? 'left' : 'right',
|
|
||||||
backgroundColor: (col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS) ?
|
|
||||||
(idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)) :
|
|
||||||
'transparent',
|
|
||||||
cursor: 'default'
|
|
||||||
}}
|
|
||||||
className={customColumnStyles(col.key)}
|
|
||||||
onClick={
|
|
||||||
col.key === FinanceTableColumnKeys.FIXED_COST
|
|
||||||
? (e) => e.stopPropagation()
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{renderFinancialTableColumnContent(col.key, task)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user