feat(task-breakdown-api): implement task financial breakdown API and related enhancements

- Added a new API endpoint `GET /api/project-finance/task/:id/breakdown` to retrieve detailed financial breakdown for individual tasks, including labor hours and costs grouped by job roles.
- Introduced a new SQL migration to add a `fixed_cost` column to the tasks table for improved financial calculations.
- Updated the project finance controller to handle task breakdown logic, including calculations for estimated and actual costs.
- Enhanced frontend components to integrate the new task breakdown API, providing real-time financial data in the finance drawer.
- Updated localization files to reflect changes in financial terminology across English, Spanish, and Portuguese.
- Implemented Redux state management for selected tasks in the finance drawer.
This commit is contained in:
chamikaJ
2025-05-26 16:36:25 +05:30
parent 399d8b420a
commit b0ed3f67e8
26 changed files with 1330 additions and 455 deletions

View File

@@ -1,17 +1,40 @@
import React, { useEffect, useState } from 'react';
import { Drawer, Typography } from 'antd';
import { Drawer, Typography, Spin } from 'antd';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { themeWiseColor } from '../../../utils/themeWiseColor';
import { toggleFinanceDrawer } from '../finance-slice';
import { closeFinanceDrawer } from '../finance-slice';
import { projectFinanceApiService } from '../../../api/project-finance-ratecard/project-finance.api.service';
import { ITaskBreakdownResponse } from '../../../types/project/project-finance.types';
const FinanceDrawer = ({ task }: { task: any }) => {
const [selectedTask, setSelectedTask] = useState(task);
const FinanceDrawer = () => {
const [taskBreakdown, setTaskBreakdown] = useState<ITaskBreakdownResponse | null>(null);
const [loading, setLoading] = useState(false);
// Get task and drawer state from Redux store
const selectedTask = useAppSelector((state) => state.financeReducer.selectedTask);
const isDrawerOpen = useAppSelector((state) => state.financeReducer.isFinanceDrawerOpen);
useEffect(() => {
setSelectedTask(task);
}, [task]);
if (selectedTask?.id && isDrawerOpen) {
fetchTaskBreakdown(selectedTask.id);
} else {
setTaskBreakdown(null);
}
}, [selectedTask, isDrawerOpen]);
const fetchTaskBreakdown = async (taskId: string) => {
try {
setLoading(true);
const response = await projectFinanceApiService.getTaskBreakdown(taskId);
setTaskBreakdown(response.body);
} catch (error) {
console.error('Error fetching task breakdown:', error);
} finally {
setLoading(false);
}
};
// localization
const { t } = useTranslation('project-view-finance');
@@ -19,9 +42,6 @@ const FinanceDrawer = ({ task }: { task: any }) => {
// get theme data from theme reducer
const themeMode = useAppSelector((state) => state.themeReducer.mode);
const isDrawerOpen = useAppSelector(
(state) => state.financeReducer.isFinanceDrawerOpen
);
const dispatch = useAppDispatch();
const currency = useAppSelector(
(state) => state.financeReducer.currency
@@ -29,41 +49,15 @@ const FinanceDrawer = ({ task }: { task: any }) => {
// function handle drawer close
const handleClose = () => {
setSelectedTask(null);
dispatch(toggleFinanceDrawer());
setTaskBreakdown(null);
dispatch(closeFinanceDrawer());
};
// group members by job roles and calculate labor hours and costs
const groupedMembers =
selectedTask?.members?.reduce((acc: any, member: any) => {
const memberHours = selectedTask.hours / selectedTask.members.length;
const memberCost = memberHours * member.hourlyRate;
if (!acc[member.jobRole]) {
acc[member.jobRole] = {
jobRole: member.jobRole,
laborHours: 0,
cost: 0,
members: [],
};
}
acc[member.jobRole].laborHours += memberHours;
acc[member.jobRole].cost += memberCost;
acc[member.jobRole].members.push({
name: member.name,
laborHours: memberHours,
cost: memberCost,
});
return acc;
}, {}) || {};
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{selectedTask?.task || t('noTaskSelected')}
{taskBreakdown?.task?.name || selectedTask?.name || t('noTaskSelected')}
</Typography.Text>
}
open={isDrawerOpen}
@@ -72,98 +66,77 @@ const FinanceDrawer = ({ task }: { task: any }) => {
width={480}
>
<div>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
marginBottom: '16px',
}}
>
<thead>
<tr
style={{
height: 48,
backgroundColor: themeWiseColor(
'#F5F5F5',
'#1d1d1d',
themeMode
),
}}
>
<th
{loading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin size="large" />
</div>
) : (
<table
style={{
width: '100%',
borderCollapse: 'collapse',
marginBottom: '16px',
}}
>
<thead>
<tr
style={{
textAlign: 'left',
padding: 8,
}}
></th>
<th
style={{
textAlign: 'right',
padding: 8,
height: 48,
backgroundColor: themeWiseColor(
'#F5F5F5',
'#1d1d1d',
themeMode
),
}}
>
{t('labourHoursColumn')}
</th>
<th
style={{
textAlign: 'right',
padding: 8,
}}
>
{t('costColumn')} ({currency})
</th>
</tr>
</thead>
<div className="mb-4"></div>
<tbody>
{Object.values(groupedMembers).map((group: any) => (
<React.Fragment key={group.jobRole}>
{/* Group Header */}
<tr
<th
style={{
backgroundColor: themeWiseColor(
'#D9D9D9',
'#000',
themeMode
),
height: 56,
textAlign: 'left',
padding: 8,
}}
></th>
<th
style={{
textAlign: 'right',
padding: 8,
}}
className="border-b-[1px] font-semibold"
>
<td style={{ padding: 8 }}>{group.jobRole}</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{group.laborHours}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{group.cost}
</td>
</tr>
{/* Member Rows */}
{group.members.map((member: any, index: number) => (
{t('labourHoursColumn')}
</th>
<th
style={{
textAlign: 'right',
padding: 8,
}}
>
{t('costColumn')} ({currency})
</th>
</tr>
</thead>
<tbody>
{taskBreakdown?.grouped_members?.map((group: any) => (
<React.Fragment key={group.jobRole}>
{/* Group Header */}
<tr
key={`${group.jobRole}-${index}`}
className="border-b-[1px]"
style={{ height: 56 }}
style={{
backgroundColor: themeWiseColor(
'#D9D9D9',
'#000',
themeMode
),
height: 56,
}}
className="border-b-[1px] font-semibold"
>
<td style={{ padding: 8 }}>{group.jobRole}</td>
<td
style={{
textAlign: 'right',
padding: 8,
paddingLeft: 32,
}}
>
{member.name}
{group.estimated_hours?.toFixed(2) || '0.00'}
</td>
<td
style={{
@@ -171,22 +144,47 @@ const FinanceDrawer = ({ task }: { task: any }) => {
padding: 8,
}}
>
{member.laborHours}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.cost}
{group.estimated_cost?.toFixed(2) || '0.00'}
</td>
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
{/* Member Rows */}
{group.members?.map((member: any, index: number) => (
<tr
key={`${group.jobRole}-${index}`}
className="border-b-[1px]"
style={{ height: 56 }}
>
<td
style={{
padding: 8,
paddingLeft: 32,
}}
>
{member.name}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.estimated_hours?.toFixed(2) || '0.00'}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.estimated_cost?.toFixed(2) || '0.00'}
</td>
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
)}
</div>
</Drawer>
);

View File

@@ -12,6 +12,7 @@ type financeState = {
isFinanceDrawerloading?: boolean;
drawerRatecard?: RatecardType | null;
ratecardsList?: RatecardType[] | null;
selectedTask?: any | null;
};
const initialState: financeState = {
@@ -23,6 +24,7 @@ const initialState: financeState = {
isFinanceDrawerloading: false,
drawerRatecard: null,
ratecardsList: null,
selectedTask: null,
};
interface FetchRateCardsParams {
index: number;
@@ -128,6 +130,17 @@ const financeSlice = createSlice({
toggleFinanceDrawer: (state) => {
state.isFinanceDrawerOpen = !state.isFinanceDrawerOpen;
},
openFinanceDrawer: (state, action: PayloadAction<any>) => {
state.isFinanceDrawerOpen = true;
state.selectedTask = action.payload;
},
closeFinanceDrawer: (state) => {
state.isFinanceDrawerOpen = false;
state.selectedTask = null;
},
setSelectedTask: (state, action: PayloadAction<any>) => {
state.selectedTask = action.payload;
},
toggleImportRatecardsDrawer: (state) => {
state.isImportRatecardsDrawerOpen = !state.isImportRatecardsDrawerOpen;
},
@@ -176,6 +189,9 @@ const financeSlice = createSlice({
export const {
toggleRatecardDrawer,
toggleFinanceDrawer,
openFinanceDrawer,
closeFinanceDrawer,
setSelectedTask,
toggleImportRatecardsDrawer,
changeCurrency,
ratecardDrawerLoading,

View File

@@ -90,6 +90,7 @@ const Navbar = () => {
}, [location]);
return (
<Col
style={{
width: '100%',
@@ -101,14 +102,14 @@ const Navbar = () => {
justifyContent: 'space-between',
}}
>
{daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && (
{/* {daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && (
<Alert
message={daysUntilExpiry > 0 ? `Your license will expire in ${daysUntilExpiry} days` : `Your license has expired ${Math.abs(daysUntilExpiry)} days ago`}
type="warning"
showIcon
style={{ width: '100%', marginTop: 12 }}
/>
)}
)} */}
<Flex
style={{
width: '100%',

View File

@@ -1,6 +1,6 @@
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
import { IProjectFinanceGroup, IProjectFinanceTask, IProjectRateCard } from '@/types/project/project-finance.types';
type FinanceTabType = 'finance' | 'ratecard';
type GroupTypes = 'status' | 'priority' | 'phases';
@@ -10,6 +10,7 @@ interface ProjectFinanceState {
activeGroup: GroupTypes;
loading: boolean;
taskGroups: IProjectFinanceGroup[];
projectRateCards: IProjectRateCard[];
}
// Utility functions for frontend calculations
@@ -67,6 +68,7 @@ const initialState: ProjectFinanceState = {
activeGroup: 'status',
loading: false,
taskGroups: [],
projectRateCards: [],
};
export const fetchProjectFinances = createAsyncThunk(
@@ -77,6 +79,14 @@ export const fetchProjectFinances = createAsyncThunk(
}
);
export const updateTaskFixedCostAsync = createAsyncThunk(
'projectFinances/updateTaskFixedCostAsync',
async ({ taskId, groupId, fixedCost }: { taskId: string; groupId: string; fixedCost: number }) => {
await projectFinanceApiService.updateTaskFixedCost(taskId, fixedCost);
return { taskId, groupId, fixedCost };
}
);
export const projectFinancesSlice = createSlice({
name: 'projectFinances',
initialState,
@@ -140,10 +150,26 @@ export const projectFinancesSlice = createSlice({
})
.addCase(fetchProjectFinances.fulfilled, (state, action) => {
state.loading = false;
state.taskGroups = action.payload;
state.taskGroups = action.payload.groups;
state.projectRateCards = action.payload.project_rate_cards;
})
.addCase(fetchProjectFinances.rejected, (state) => {
state.loading = false;
})
.addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => {
const { taskId, groupId, fixedCost } = action.payload;
const group = state.taskGroups.find(g => g.group_id === groupId);
if (group) {
const task = group.tasks.find(t => t.id === taskId);
if (task) {
task.fixed_cost = fixedCost;
// Recalculate task costs after updating fixed cost
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
task.total_budget = totalBudget;
task.total_actual = totalActual;
task.variance = variance;
}
}
});
},
});