feat(project-finance): enhance fixed cost handling and add silent refresh functionality
- Updated the fixed cost calculation logic to rely on backend values, avoiding unnecessary recalculations in the frontend. - Introduced a new `fetchProjectFinancesSilent` thunk for refreshing project finance data without altering the loading state. - Implemented debounced and immediate save functions for fixed cost changes in the FinanceTable component, improving user experience and data accuracy. - Adjusted the UI to reflect changes in fixed cost handling, ensuring accurate display of task variances.
This commit is contained in:
@@ -22,13 +22,10 @@ const calculateTaskCosts = (task: IProjectFinanceTask) => {
|
|||||||
const timeLoggedHours = secondsToHours(task.total_time_logged_seconds || 0);
|
const timeLoggedHours = secondsToHours(task.total_time_logged_seconds || 0);
|
||||||
const fixedCost = task.fixed_cost || 0;
|
const fixedCost = task.fixed_cost || 0;
|
||||||
|
|
||||||
// Calculate total budget (estimated hours * rate + fixed cost)
|
// For fixed cost updates, we'll rely on the backend values
|
||||||
const totalBudget = task.estimated_cost + fixedCost;
|
// and trigger a re-fetch to ensure accuracy
|
||||||
|
const totalBudget = (task.estimated_cost || 0) + fixedCost;
|
||||||
// Calculate total actual (time logged * rate + fixed cost)
|
|
||||||
const totalActual = task.total_actual || 0;
|
const totalActual = task.total_actual || 0;
|
||||||
|
|
||||||
// Calculate variance (total actual - total budget)
|
|
||||||
const variance = totalActual - totalBudget;
|
const variance = totalActual - totalBudget;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -80,6 +77,14 @@ export const fetchProjectFinances = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const fetchProjectFinancesSilent = createAsyncThunk(
|
||||||
|
'projectFinances/fetchProjectFinancesSilent',
|
||||||
|
async ({ projectId, groupBy }: { projectId: string; groupBy: GroupTypes }) => {
|
||||||
|
const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy);
|
||||||
|
return response.body;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const updateTaskFixedCostAsync = createAsyncThunk(
|
export const updateTaskFixedCostAsync = createAsyncThunk(
|
||||||
'projectFinances/updateTaskFixedCostAsync',
|
'projectFinances/updateTaskFixedCostAsync',
|
||||||
async ({ taskId, groupId, fixedCost }: { taskId: string; groupId: string; fixedCost: number }) => {
|
async ({ taskId, groupId, fixedCost }: { taskId: string; groupId: string; fixedCost: number }) => {
|
||||||
@@ -105,11 +110,7 @@ export const projectFinancesSlice = createSlice({
|
|||||||
const task = group.tasks.find(t => t.id === taskId);
|
const task = group.tasks.find(t => t.id === taskId);
|
||||||
if (task) {
|
if (task) {
|
||||||
task.fixed_cost = fixedCost;
|
task.fixed_cost = fixedCost;
|
||||||
// Recalculate task costs after updating fixed cost
|
// Don't recalculate here - let the backend handle it and we'll refresh
|
||||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
|
||||||
task.total_budget = totalBudget;
|
|
||||||
task.total_actual = totalActual;
|
|
||||||
task.variance = variance;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -158,6 +159,11 @@ export const projectFinancesSlice = createSlice({
|
|||||||
.addCase(fetchProjectFinances.rejected, (state) => {
|
.addCase(fetchProjectFinances.rejected, (state) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
})
|
})
|
||||||
|
.addCase(fetchProjectFinancesSilent.fulfilled, (state, action) => {
|
||||||
|
// Update data without changing loading state for silent refresh
|
||||||
|
state.taskGroups = action.payload.groups;
|
||||||
|
state.projectRateCards = action.payload.project_rate_cards;
|
||||||
|
})
|
||||||
.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);
|
||||||
@@ -165,11 +171,7 @@ export const projectFinancesSlice = createSlice({
|
|||||||
const task = group.tasks.find(t => t.id === taskId);
|
const task = group.tasks.find(t => t.id === taskId);
|
||||||
if (task) {
|
if (task) {
|
||||||
task.fixed_cost = fixedCost;
|
task.fixed_cost = fixedCost;
|
||||||
// Recalculate task costs after updating fixed cost
|
// Don't recalculate here - trigger a refresh instead for accuracy
|
||||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
|
||||||
task.total_budget = totalBudget;
|
|
||||||
task.total_actual = totalActual;
|
|
||||||
task.variance = variance;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Checkbox, Flex, Input, InputNumber, Skeleton, Tooltip, Typography } from 'antd';
|
import { Checkbox, Flex, Input, InputNumber, Skeleton, Tooltip, Typography } from 'antd';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import {
|
import {
|
||||||
DollarCircleOutlined,
|
DollarCircleOutlined,
|
||||||
@@ -11,7 +11,7 @@ import { colors } from '@/styles/colors';
|
|||||||
import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns';
|
import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns';
|
||||||
import Avatars from '@/components/avatars/avatars';
|
import Avatars from '@/components/avatars/avatars';
|
||||||
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
|
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
|
||||||
import { updateTaskFixedCostAsync, updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice';
|
import { updateTaskFixedCostAsync, updateTaskFixedCost, fetchProjectFinancesSilent } from '@/features/projects/finance/project-finance.slice';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice';
|
import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
@@ -33,11 +33,14 @@ const FinanceTable = ({
|
|||||||
}: FinanceTableProps) => {
|
}: FinanceTableProps) => {
|
||||||
const [isCollapse, setIsCollapse] = useState<boolean>(false);
|
const [isCollapse, setIsCollapse] = useState<boolean>(false);
|
||||||
const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
|
const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
|
||||||
|
const [editingFixedCostValue, setEditingFixedCostValue] = useState<number | null>(null);
|
||||||
const [tasks, setTasks] = useState<IProjectFinanceTask[]>(table.tasks);
|
const [tasks, setTasks] = useState<IProjectFinanceTask[]>(table.tasks);
|
||||||
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
// Get the latest task groups from Redux store
|
// Get the latest task groups from Redux store
|
||||||
const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups);
|
const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups);
|
||||||
|
const activeGroup = useAppSelector((state) => state.projectFinances.activeGroup);
|
||||||
|
|
||||||
// Update local state when table.tasks or Redux store changes
|
// Update local state when table.tasks or Redux store changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,7 +56,13 @@ const FinanceTable = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (selectedTask && !(event.target as Element)?.closest('.fixed-cost-input')) {
|
if (selectedTask && !(event.target as Element)?.closest('.fixed-cost-input')) {
|
||||||
setSelectedTask(null);
|
// Save current value before closing
|
||||||
|
if (editingFixedCostValue !== null) {
|
||||||
|
immediateSaveFixedCost(editingFixedCostValue, selectedTask.id);
|
||||||
|
} else {
|
||||||
|
setSelectedTask(null);
|
||||||
|
setEditingFixedCostValue(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,7 +70,16 @@ const FinanceTable = ({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [selectedTask]);
|
}, [selectedTask, editingFixedCostValue]);
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// get theme data from theme reducer
|
// get theme data from theme reducer
|
||||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||||
@@ -99,14 +117,20 @@ const FinanceTable = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFixedCostChange = (value: number | null, taskId: string) => {
|
const handleFixedCostChange = async (value: number | null, taskId: string) => {
|
||||||
const fixedCost = value || 0;
|
const fixedCost = value || 0;
|
||||||
|
|
||||||
// Optimistic update for immediate UI feedback
|
try {
|
||||||
dispatch(updateTaskFixedCost({ taskId, groupId: table.group_id, fixedCost }));
|
// Make the API call to persist the change
|
||||||
|
await dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })).unwrap();
|
||||||
// Then make the API call to persist the change
|
|
||||||
dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost }));
|
// Silent refresh the data to get accurate calculations from backend without loading animation
|
||||||
|
if (projectId) {
|
||||||
|
dispatch(fetchProjectFinancesSilent({ projectId, groupBy: activeGroup }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update fixed cost:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
@@ -119,6 +143,38 @@ const FinanceTable = ({
|
|||||||
dispatch(fetchTask({ taskId, projectId }));
|
dispatch(fetchTask({ taskId, projectId }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Debounced save function for fixed cost
|
||||||
|
const debouncedSaveFixedCost = (value: number | null, taskId: string) => {
|
||||||
|
// Clear existing timeout
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timeout
|
||||||
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (value !== null) {
|
||||||
|
handleFixedCostChange(value, taskId);
|
||||||
|
setSelectedTask(null);
|
||||||
|
setEditingFixedCostValue(null);
|
||||||
|
}
|
||||||
|
}, 1000); // Save after 1 second of inactivity
|
||||||
|
};
|
||||||
|
|
||||||
|
// Immediate save function (for enter/blur)
|
||||||
|
const immediateSaveFixedCost = (value: number | null, taskId: string) => {
|
||||||
|
// Clear any pending debounced save
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
saveTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value !== null) {
|
||||||
|
handleFixedCostChange(value, taskId);
|
||||||
|
}
|
||||||
|
setSelectedTask(null);
|
||||||
|
setEditingFixedCostValue(null);
|
||||||
|
};
|
||||||
|
|
||||||
const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => {
|
const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => {
|
||||||
switch (columnKey) {
|
switch (columnKey) {
|
||||||
case FinanceTableColumnKeys.TASK:
|
case FinanceTableColumnKeys.TASK:
|
||||||
@@ -179,14 +235,19 @@ const FinanceTable = ({
|
|||||||
case FinanceTableColumnKeys.FIXED_COST:
|
case FinanceTableColumnKeys.FIXED_COST:
|
||||||
return selectedTask?.id === task.id ? (
|
return selectedTask?.id === task.id ? (
|
||||||
<InputNumber
|
<InputNumber
|
||||||
value={task.fixed_cost}
|
value={editingFixedCostValue !== null ? editingFixedCostValue : task.fixed_cost}
|
||||||
onBlur={(e) => {
|
onChange={(value) => {
|
||||||
handleFixedCostChange(Number(e.target.value), task.id);
|
setEditingFixedCostValue(value);
|
||||||
setSelectedTask(null);
|
// Trigger debounced save for up/down arrow clicks
|
||||||
|
debouncedSaveFixedCost(value, task.id);
|
||||||
}}
|
}}
|
||||||
onPressEnter={(e) => {
|
onBlur={() => {
|
||||||
handleFixedCostChange(Number((e.target as HTMLInputElement).value), task.id);
|
// Immediate save on blur
|
||||||
setSelectedTask(null);
|
immediateSaveFixedCost(editingFixedCostValue, task.id);
|
||||||
|
}}
|
||||||
|
onPressEnter={() => {
|
||||||
|
// Immediate save on enter
|
||||||
|
immediateSaveFixedCost(editingFixedCostValue, task.id);
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={{ width: '100%', textAlign: 'right' }}
|
style={{ width: '100%', textAlign: 'right' }}
|
||||||
@@ -202,6 +263,7 @@ const FinanceTable = ({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSelectedTask(task);
|
setSelectedTask(task);
|
||||||
|
setEditingFixedCostValue(task.fixed_cost);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatNumber(task.fixed_cost)}
|
{formatNumber(task.fixed_cost)}
|
||||||
@@ -211,10 +273,10 @@ const FinanceTable = ({
|
|||||||
return (
|
return (
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
style={{
|
style={{
|
||||||
color: formattedTotals.variance > 0 ? '#FF0000' : '#6DC376'
|
color: task.variance > 0 ? '#FF0000' : '#6DC376'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatNumber(formattedTotals.variance)}
|
{formatNumber(task.variance)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
);
|
);
|
||||||
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||||
|
|||||||
Reference in New Issue
Block a user