Merge branch 'Worklenz:feature/project-finance' into feature/project-finance
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%',
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { IProjectFinanceGroup, IProjectFinanceTask, IProjectRateCard } from '@/types/project/project-finance.types';
|
||||
|
||||
type FinanceTabType = 'finance' | 'ratecard';
|
||||
type GroupTypes = 'status' | 'priority' | 'phases';
|
||||
|
||||
interface ProjectFinanceState {
|
||||
activeTab: FinanceTabType;
|
||||
activeGroup: GroupTypes;
|
||||
loading: boolean;
|
||||
taskGroups: IProjectFinanceGroup[];
|
||||
projectRateCards: IProjectRateCard[];
|
||||
}
|
||||
|
||||
// Utility functions for frontend calculations
|
||||
const minutesToHours = (minutes: number) => minutes / 60;
|
||||
|
||||
const calculateTaskCosts = (task: IProjectFinanceTask) => {
|
||||
const hours = minutesToHours(task.estimated_hours || 0);
|
||||
const timeLoggedHours = minutesToHours(task.total_time_logged || 0);
|
||||
const fixedCost = task.fixed_cost || 0;
|
||||
|
||||
// Calculate total budget (estimated hours * rate + fixed cost)
|
||||
const totalBudget = task.estimated_cost + fixedCost;
|
||||
|
||||
// Calculate total actual (time logged * rate + fixed cost)
|
||||
const totalActual = task.total_actual || 0;
|
||||
|
||||
// Calculate variance (total actual - total budget)
|
||||
const variance = totalActual - totalBudget;
|
||||
|
||||
return {
|
||||
hours,
|
||||
timeLoggedHours,
|
||||
totalBudget,
|
||||
totalActual,
|
||||
variance
|
||||
};
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const initialState: ProjectFinanceState = {
|
||||
activeTab: 'finance',
|
||||
activeGroup: 'status',
|
||||
loading: false,
|
||||
taskGroups: [],
|
||||
projectRateCards: [],
|
||||
};
|
||||
|
||||
export const fetchProjectFinances = createAsyncThunk(
|
||||
'projectFinances/fetchProjectFinances',
|
||||
async ({ projectId, groupBy }: { projectId: string; groupBy: GroupTypes }) => {
|
||||
const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy);
|
||||
return response.body;
|
||||
}
|
||||
);
|
||||
|
||||
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,
|
||||
reducers: {
|
||||
setActiveTab: (state, action: PayloadAction<FinanceTabType>) => {
|
||||
state.activeTab = action.payload;
|
||||
},
|
||||
setActiveGroup: (state, action: PayloadAction<GroupTypes>) => {
|
||||
state.activeGroup = action.payload;
|
||||
},
|
||||
updateTaskFixedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateTaskEstimatedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; estimatedCost: number }>) => {
|
||||
const { taskId, groupId, estimatedCost } = 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.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;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLogged: number }>) => {
|
||||
const { taskId, groupId, timeLogged } = 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.total_time_logged = timeLogged;
|
||||
// Recalculate task costs after updating time logged
|
||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||
task.total_budget = totalBudget;
|
||||
task.total_actual = totalActual;
|
||||
task.variance = variance;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchProjectFinances.pending, (state) => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(fetchProjectFinances.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setActiveTab,
|
||||
setActiveGroup,
|
||||
updateTaskFixedCost,
|
||||
updateTaskEstimatedCost,
|
||||
updateTaskTimeLogged
|
||||
} = projectFinancesSlice.actions;
|
||||
|
||||
export default projectFinancesSlice.reducer;
|
||||
Reference in New Issue
Block a user