feat(project-finance): enhance project finance view and calculations

- Added a new SQL view `project_finance_view` to aggregate project financial data.
- Updated `project-finance-controller.ts` to fetch and group tasks by status, priority, or phases, including financial calculations for estimated costs, actual costs, and variances.
- Enhanced frontend components to display total time logged, estimated costs, and fixed costs in the finance table.
- Introduced new utility functions for formatting hours and calculating totals.
- Updated localization files to include new financial columns in English, Spanish, and Portuguese.
- Implemented Redux slice for managing project finance state and actions for updating task costs.
This commit is contained in:
chamikaJ
2025-05-23 08:32:48 +05:30
parent 096163d9c0
commit b320a7b260
18 changed files with 683 additions and 395 deletions

View File

@@ -12,7 +12,9 @@
"taskColumn": "Task",
"membersColumn": "Members",
"hoursColumn": "Hours",
"totalTimeLoggedColumn": "Total Time Logged",
"costColumn": "Cost",
"estimatedCostColumn": "Estimated Cost",
"fixedCostColumn": "Fixed Cost",
"totalBudgetedCostColumn": "Total Budgeted Cost",
"totalActualCostColumn": "Total Actual Cost",

View File

@@ -12,7 +12,9 @@
"taskColumn": "Tarea",
"membersColumn": "Miembros",
"hoursColumn": "Horas",
"totalTimeLoggedColumn": "Tiempo Total Registrado",
"costColumn": "Costo",
"estimatedCostColumn": "Costo Estimado",
"fixedCostColumn": "Costo Fijo",
"totalBudgetedCostColumn": "Costo Total Presupuestado",
"totalActualCostColumn": "Costo Total Real",

View File

@@ -12,7 +12,9 @@
"taskColumn": "Tarefa",
"membersColumn": "Membros",
"hoursColumn": "Horas",
"totalTimeLoggedColumn": "Tempo Total Registrado",
"costColumn": "Custo",
"estimatedCostColumn": "Custo Estimado",
"fixedCostColumn": "Custo Fixo",
"totalBudgetedCostColumn": "Custo Total Orçado",
"totalActualCostColumn": "Custo Total Real",

View File

@@ -16,6 +16,18 @@ export const projectFinanceApiService = {
params: { group_by: groupBy }
}
);
console.log(response.data);
return response.data;
},
updateTaskFixedCost: async (
taskId: string,
fixedCost: number
): Promise<IServerResponse<any>> => {
const response = await apiClient.put<IServerResponse<any>>(
`${rootUrl}/task/${taskId}/fixed-cost`,
{ fixed_cost: fixedCost }
);
return response.data;
},
}

View File

@@ -73,6 +73,7 @@ import financeReducer from '../features/finance/finance-slice';
import roadmapReducer from '../features/roadmap/roadmap-slice';
import teamMembersReducer from '@features/team-members/team-members.slice';
import projectFinanceRateCardReducer from '../features/finance/project-finance-slice';
import projectFinancesReducer from '../features/projects/finance/project-finance.slice';
import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
import homePageApiService from '@/api/home-page/home-page.api.service';
import { projectsApi } from '@/api/projects/projects.v1.api.service';
@@ -158,6 +159,7 @@ export const store = configureStore({
timeReportsOverviewReducer: timeReportsOverviewReducer,
financeReducer: financeReducer,
projectFinanceRateCard: projectFinanceRateCardReducer,
projectFinances: projectFinancesReducer,
},
});

View File

@@ -0,0 +1,159 @@
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';
type FinanceTabType = 'finance' | 'ratecard';
type GroupTypes = 'status' | 'priority' | 'phases';
interface ProjectFinanceState {
activeTab: FinanceTabType;
activeGroup: GroupTypes;
loading: boolean;
taskGroups: IProjectFinanceGroup[];
}
// 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: [],
};
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 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;
})
.addCase(fetchProjectFinances.rejected, (state) => {
state.loading = false;
});
},
});
export const {
setActiveTab,
setActiveGroup,
updateTaskFixedCost,
updateTaskEstimatedCost,
updateTaskTimeLogged
} = projectFinancesSlice.actions;
export default projectFinancesSlice.reducer;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { Table } from 'antd';
import { useTranslation } from 'react-i18next';
import { financeTableColumns } from './project-view-finance-table-columns';
interface IFinanceTableData {
id: string;
name: string;
estimated_hours: number;
estimated_cost: number;
fixed_cost: number;
total_budgeted_cost: number;
total_actual_cost: number;
variance: number;
total_time_logged: number;
assignees: Array<{
team_member_id: string;
project_member_id: string;
name: string;
avatar_url: string;
}>;
}
interface FinanceTableWrapperProps {
data: IFinanceTableData[];
loading?: boolean;
}
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ data, loading }) => {
const { t } = useTranslation();
const columns = financeTableColumns.map(col => ({
...col,
title: t(`projectViewFinance.${col.name}`),
dataIndex: col.key,
key: col.key,
width: col.width,
render: col.render || ((value: any) => {
if (col.type === 'hours') {
return value ? value.toFixed(2) : '0.00';
}
if (col.type === 'currency') {
return value ? `$${value.toFixed(2)}` : '$0.00';
}
return value;
})
}));
return (
<Table
dataSource={data}
columns={columns}
loading={loading}
pagination={false}
rowKey="id"
scroll={{ x: 'max-content' }}
/>
);
};
export default FinanceTableWrapper;

View File

@@ -3,55 +3,68 @@ type FinanceTableColumnsType = {
name: string;
width: number;
type: 'string' | 'hours' | 'currency';
render?: (value: any) => React.ReactNode;
};
// finance table columns
export const financeTableColumns: FinanceTableColumnsType[] = [
{
key: 'task',
name: 'task',
name: 'taskColumn',
width: 240,
type: 'string',
},
{
key: 'members',
name: 'members',
name: 'membersColumn',
width: 160,
type: 'string',
},
{
key: 'hours',
name: 'hours',
name: 'hoursColumn',
width: 80,
type: 'hours',
},
{
key: 'total_time_logged',
name: 'totalTimeLoggedColumn',
width: 120,
type: 'hours',
},
{
key: 'estimated_cost',
name: 'estimatedCostColumn',
width: 120,
type: 'currency',
},
{
key: 'cost',
name: 'cost',
name: 'costColumn',
width: 120,
type: 'currency',
},
{
key: 'fixedCost',
name: 'fixedCost',
name: 'fixedCostColumn',
width: 120,
type: 'currency',
},
{
key: 'totalBudget',
name: 'totalBudgetedCost',
name: 'totalBudgetedCostColumn',
width: 120,
type: 'currency',
},
{
key: 'totalActual',
name: 'totalActualCost',
name: 'totalActualCostColumn',
width: 120,
type: 'currency',
},
{
key: 'variance',
name: 'variance',
name: 'varianceColumn',
width: 120,
type: 'currency',
},

View File

@@ -10,26 +10,28 @@ interface FinanceTabProps {
const FinanceTab = ({
groupType,
taskGroups,
taskGroups = [],
loading
}: FinanceTabProps) => {
// Transform taskGroups into the format expected by FinanceTableWrapper
const activeTablesList = taskGroups.map(group => ({
id: group.group_id,
name: group.group_name,
const activeTablesList = (taskGroups || []).map(group => ({
group_id: group.group_id,
group_name: group.group_name,
color_code: group.color_code,
color_code_dark: group.color_code_dark,
tasks: group.tasks.map(task => ({
taskId: task.id,
task: task.name,
tasks: (group.tasks || []).map(task => ({
id: task.id,
name: task.name,
hours: task.estimated_hours || 0,
cost: 0, // TODO: Calculate based on rate and hours
fixedCost: 0, // TODO: Add fixed cost field
totalBudget: 0, // TODO: Calculate total budget
totalActual: task.actual_hours || 0,
totalActual: task.total_actual || 0,
variance: 0, // TODO: Calculate variance
members: task.members || [],
isbBillable: task.billable
isbBillable: task.billable,
total_time_logged: task.total_time_logged || 0,
estimated_cost: task.estimated_cost || 0
}))
}));

View File

@@ -1,9 +1,8 @@
import React from "react";
import { Card, Col, Row, Spin } from "antd";
import { useThemeContext } from "../../../../../context/theme-context";
import { FinanceTable } from "./finance-table";
import { IFinanceTable } from "./finance-table.interface";
import { Card, Col, Row } from "antd";
import { IProjectFinanceGroup } from "../../../../../types/project/project-finance.types";
import FinanceTable from "./finance-table/finance-table";
interface Props {
activeTablesList: IProjectFinanceGroup[];
@@ -32,7 +31,7 @@ export const FinanceTableWrapper: React.FC<Props> = ({ activeTablesList, loading
<h3>{table.group_name}</h3>
</div>
<FinanceTable
table={table as unknown as IFinanceTable}
table={table}
loading={loading}
/>
</Card>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Checkbox, Flex, Typography } from 'antd';
import { Checkbox, Flex, Tooltip, Typography } from 'antd';
import { themeWiseColor } from '../../../../../../utils/themeWiseColor';
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
@@ -8,6 +8,7 @@ import { toggleFinanceDrawer } from '@/features/finance/finance-slice';
import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns';
import FinanceTable from './finance-table';
import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer';
import { convertToHoursMinutes, formatHoursToReadable } from '@/utils/format-hours-to-readable';
interface FinanceTableWrapperProps {
activeTablesList: {
@@ -26,6 +27,8 @@ interface FinanceTableWrapperProps {
variance: number;
members: any[];
isbBillable: boolean;
total_time_logged: number;
estimated_cost: number;
}[];
}[];
loading: boolean;
@@ -72,6 +75,8 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
totalBudget: number;
totalActual: number;
variance: number;
total_time_logged: number;
estimated_cost: number;
},
table: { tasks: any[] }
) => {
@@ -82,6 +87,8 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
acc.totalBudget += task.totalBudget || 0;
acc.totalActual += task.totalActual || 0;
acc.variance += task.variance || 0;
acc.total_time_logged += task.total_time_logged || 0;
acc.estimated_cost += task.estimated_cost || 0;
});
return acc;
},
@@ -92,15 +99,21 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
totalBudget: 0,
totalActual: 0,
variance: 0,
total_time_logged: 0,
estimated_cost: 0,
}
);
console.log("totals", totals);
const renderFinancialTableHeaderContent = (columnKey: any) => {
switch (columnKey) {
case 'hours':
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.hours}
<Tooltip title={convertToHoursMinutes(totals.hours)}>
{formatHoursToReadable(totals.hours)}
</Tooltip>
</Typography.Text>
);
case 'cost':
@@ -138,6 +151,18 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
{totals.variance}
</Typography.Text>
);
case 'total_time_logged':
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.total_time_logged?.toFixed(2)}
</Typography.Text>
);
case 'estimated_cost':
return (
<Typography.Text style={{ fontSize: 18 }}>
{`${currency.toUpperCase()} ${totals.estimated_cost?.toFixed(2)}`}
</Typography.Text>
);
default:
return null;
}
@@ -189,7 +214,7 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
className={`${customColumnHeaderStyles(col.key)} before:constent relative before:absolute before:left-0 before:top-1/2 before:h-[36px] before:w-0.5 before:-translate-y-1/2 ${themeMode === 'dark' ? 'before:bg-white/10' : 'before:bg-black/5'}`}
>
<Typography.Text>
{t(`${col.name}Column`)}{' '}
{t(`${col.name}`)}{' '}
{col.type === 'currency' && `(${currency.toUpperCase()})`}
</Typography.Text>
</td>

View File

@@ -1,283 +1,250 @@
import { Avatar, Checkbox, Flex, Input, Tooltip, Typography } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import CustomAvatar from '../../../../../../components/CustomAvatar';
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
import { Checkbox, Flex, Input, InputNumber, Skeleton, Tooltip, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import {
DollarCircleOutlined,
DownOutlined,
RightOutlined,
} from '@ant-design/icons';
import { themeWiseColor } from '../../../../../../utils/themeWiseColor';
import { colors } from '../../../../../../styles/colors';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { colors } from '@/styles/colors';
import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns';
import Avatars from '@/components/avatars/avatars';
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
import { updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
type FinanceTableProps = {
table: any;
isScrolling: boolean;
onTaskClick: (task: any) => void;
table: IProjectFinanceGroup;
loading: boolean;
};
const FinanceTable = ({
table,
isScrolling,
onTaskClick,
loading,
}: FinanceTableProps) => {
const [isCollapse, setIsCollapse] = useState<boolean>(false);
const [selectedTask, setSelectedTask] = useState(null);
const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
const [tasks, setTasks] = useState<IProjectFinanceTask[]>(table.tasks);
const dispatch = useAppDispatch();
// Get the latest task groups from Redux store
const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups);
// Update local state when table.tasks or Redux store changes
useEffect(() => {
const updatedGroup = taskGroups.find(g => g.group_id === table.group_id);
if (updatedGroup) {
setTasks(updatedGroup.tasks);
} else {
setTasks(table.tasks);
}
}, [table.tasks, taskGroups, table.group_id]);
// get theme data from theme reducer
const themeMode = useAppSelector((state) => state.themeReducer.mode);
// totals of the current table
const totals = useMemo(
() => ({
hours: (table?.tasks || []).reduce(
(sum: any, task: { hours: any }) => sum + task.hours,
0
),
cost: (table?.tasks || []).reduce(
(sum: any, task: { cost: any }) => sum + task.cost,
0
),
fixedCost: (table?.tasks || []).reduce(
(sum: any, task: { fixedCost: any }) => sum + task.fixedCost,
0
),
totalBudget: (table?.tasks || []).reduce(
(sum: any, task: { totalBudget: any }) => sum + task.totalBudget,
0
),
totalActual: (table?.tasks || []).reduce(
(sum: any, task: { totalActual: any }) => sum + task.totalActual,
0
),
variance: (table?.tasks || []).reduce(
(sum: any, task: { variance: any }) => sum + task.variance,
0
),
}),
[table]
);
const formatNumber = (value: number | undefined | null) => {
if (value === undefined || value === null) return '0.00';
return value.toFixed(2);
};
useEffect(() => {
console.log('Selected Task:', selectedTask);
}, [selectedTask]);
const renderFinancialTableHeaderContent = (columnKey: any) => {
const renderFinancialTableHeaderContent = (columnKey: string) => {
switch (columnKey) {
case 'hours':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.hours}
</Typography.Text>
);
case 'cost':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.cost}
</Typography.Text>
);
case 'fixedCost':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.fixedCost}
</Typography.Text>
);
return <Typography.Text>{formatNumber(totals.hours)}</Typography.Text>;
case 'total_time_logged':
return <Typography.Text>{formatNumber(totals.total_time_logged)}</Typography.Text>;
case 'estimated_cost':
return <Typography.Text>{formatNumber(totals.estimated_cost)}</Typography.Text>;
case 'totalBudget':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.totalBudget}
</Typography.Text>
);
return <Typography.Text>{formatNumber(totals.total_budget)}</Typography.Text>;
case 'totalActual':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.totalActual}
</Typography.Text>
);
return <Typography.Text>{formatNumber(totals.total_actual)}</Typography.Text>;
case 'variance':
return (
<Typography.Text
style={{
color:
totals.variance < 0
? '#FF0000'
: themeWiseColor('#6DC376', colors.darkGray, themeMode),
}}
>
{totals.variance}
</Typography.Text>
);
return <Typography.Text>{formatNumber(totals.variance)}</Typography.Text>;
default:
return null;
}
};
const renderFinancialTableColumnContent = (columnKey: any, task: any) => {
const handleFixedCostChange = (value: number | null, taskId: string) => {
dispatch(updateTaskFixedCost({ taskId, groupId: table.group_id, fixedCost: value || 0 }));
};
const renderFinancialTableColumnContent = (columnKey: string, task: IProjectFinanceTask) => {
switch (columnKey) {
case 'task':
return (
<Tooltip title={task.task}>
<Tooltip title={task.name}>
<Flex gap={8} align="center">
<Typography.Text
ellipsis={{ expanded: false }}
style={{ maxWidth: 160 }}
>
{task.task}
{task.name}
</Typography.Text>
{task.isbBillable && <DollarCircleOutlined />}
{task.billable && <DollarCircleOutlined />}
</Flex>
</Tooltip>
);
case 'members':
return (
task?.assignees && <Avatars members={task.assignees} />
return task.members && (
<Avatars
members={task.members.map(member => ({
...member,
avatar_url: member.avatar_url || undefined
}))}
/>
);
case 'hours':
return <Typography.Text>{task.hours}</Typography.Text>;
case 'cost':
return <Typography.Text>{task.cost}</Typography.Text>;
return <Typography.Text>{formatNumber(task.estimated_hours / 60)}</Typography.Text>;
case 'total_time_logged':
return <Typography.Text>{formatNumber(task.total_time_logged / 60)}</Typography.Text>;
case 'estimated_cost':
return <Typography.Text>{formatNumber(task.estimated_cost)}</Typography.Text>;
case 'fixedCost':
return (
<Input
value={task.fixedCost}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
textAlign: 'right',
padding: 0,
return selectedTask?.id === task.id ? (
<InputNumber
value={task.fixed_cost}
onBlur={(e) => {
handleFixedCostChange(Number(e.target.value), task.id);
setSelectedTask(null);
}}
autoFocus
style={{ width: '100%', textAlign: 'right' }}
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(value) => Number(value!.replace(/\$\s?|(,*)/g, ''))}
min={0}
precision={2}
/>
) : (
<Typography.Text>{formatNumber(task.fixed_cost)}</Typography.Text>
);
case 'totalBudget':
return (
<Input
value={task.totalBudget}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
textAlign: 'right',
padding: 0,
}}
/>
);
case 'totalActual':
return <Typography.Text>{task.totalActual}</Typography.Text>;
case 'variance':
return (
<Typography.Text
style={{
color: task.variance < 0 ? '#FF0000' : '#6DC376',
}}
>
{task.variance}
</Typography.Text>
);
return <Typography.Text>{formatNumber(task.variance)}</Typography.Text>;
case 'totalBudget':
return <Typography.Text>{formatNumber(task.total_budget)}</Typography.Text>;
case 'totalActual':
return <Typography.Text>{formatNumber(task.total_actual)}</Typography.Text>;
case 'cost':
return <Typography.Text>{formatNumber(task.cost || 0)}</Typography.Text>;
default:
return null;
}
};
// layout styles for table and the columns
const customColumnHeaderStyles = (key: string) =>
`px-2 text-left ${key === 'tableTitle' && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`}`;
const customColumnStyles = (key: string) =>
`px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[52px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
// Calculate totals for the current table
const totals = useMemo(() => {
return tasks.reduce(
(acc, task) => ({
hours: acc.hours + (task.estimated_hours / 60),
total_time_logged: acc.total_time_logged + (task.total_time_logged / 60),
estimated_cost: acc.estimated_cost + (task.estimated_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)
}),
{
hours: 0,
total_time_logged: 0,
estimated_cost: 0,
total_budget: 0,
total_actual: 0,
variance: 0
}
);
}, [tasks]);
return (
<>
{/* header row */}
<tr
style={{
height: 40,
backgroundColor: themeWiseColor(
table.color_code,
table.color_code_dark,
themeMode
),
fontWeight: 600,
}}
className="group"
>
<td
colSpan={3}
<Skeleton active loading={loading}>
<>
{/* header row */}
<tr
style={{
width: 48,
textTransform: 'capitalize',
textAlign: 'left',
paddingInline: 16,
height: 40,
backgroundColor: themeWiseColor(
table.color_code,
table.color_code_dark,
themeMode
),
cursor: 'pointer',
fontWeight: 600,
}}
className={customColumnHeaderStyles('tableTitle')}
onClick={(e) => setIsCollapse((prev) => !prev)}
className="group"
>
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
{isCollapse ? <RightOutlined /> : <DownOutlined />}
{table.name} ({table.tasks.length})
</Flex>
</td>
<td
colSpan={3}
style={{
width: 48,
textTransform: 'capitalize',
textAlign: 'left',
paddingInline: 16,
backgroundColor: themeWiseColor(
table.color_code,
table.color_code_dark,
themeMode
),
cursor: 'pointer',
}}
onClick={() => setIsCollapse((prev) => !prev)}
>
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
{isCollapse ? <RightOutlined /> : <DownOutlined />}
{table.group_name} ({tasks.length})
</Flex>
</td>
{financeTableColumns.map(
(col) =>
col.key !== 'task' &&
col.key !== 'members' && (
{financeTableColumns.map(
(col) =>
col.key !== 'task' &&
col.key !== 'members' && (
<td
key={`header-${col.key}`}
style={{
width: col.width,
paddingInline: 16,
textAlign: 'end',
}}
>
{renderFinancialTableHeaderContent(col.key)}
</td>
)
)}
</tr>
{/* task rows */}
{!isCollapse && tasks.map((task, idx) => (
<tr
key={task.id}
style={{
height: 40,
background: idx % 2 === 0 ? '#232323' : '#181818',
transition: 'background 0.2s',
cursor: 'pointer'
}}
onMouseEnter={e => e.currentTarget.style.background = '#333'}
onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? '#232323' : '#181818'}
onClick={() => setSelectedTask(task)}
>
<td style={{ width: 48, paddingInline: 16 }}>
<Checkbox />
</td>
{financeTableColumns.map((col) => (
<td
key={col.key}
key={`${task.id}-${col.key}`}
style={{
width: col.width,
paddingInline: 16,
textAlign: 'end',
textAlign: col.type === 'string' ? 'left' : 'right',
}}
>
{renderFinancialTableHeaderContent(col.key)}
{renderFinancialTableColumnContent(col.key, task)}
</td>
)
)}
</tr>
{/* task rows */}
{table.tasks.map((task: any) => (
<tr
key={task.taskId}
style={{ height: 52 }}
className={`${isCollapse ? 'hidden' : 'static'} cursor-pointer border-b-[1px] ${themeMode === 'dark' ? 'hover:bg-[#000000]' : 'hover:bg-[#f8f7f9]'} `}
onClick={() => onTaskClick(task)}
>
<td
style={{ paddingInline: 16 }}
className={customColumnStyles('selector')}
>
<Checkbox />
</td>
{financeTableColumns.map((col) => (
<td
key={col.key}
className={customColumnStyles(col.key)}
style={{
width: col.width,
paddingInline: 16,
textAlign:
col.type === 'hours' || col.type === 'currency'
? 'end'
: 'start',
}}
>
{renderFinancialTableColumnContent(col.key, task)}
</td>
))}
</tr>
))}
</>
))}
</tr>
))}
</>
</Skeleton>
);
};

View File

@@ -1,5 +1,4 @@
import { Button, ConfigProvider, Flex, Select, Typography } from 'antd';
import React from 'react';
import GroupByFilterDropdown from './group-by-filter-dropdown';
import { DownOutlined } from '@ant-design/icons';
import { useAppDispatch } from '../../../../../hooks/useAppDispatch';

View File

@@ -1,61 +1,33 @@
import { Flex } from 'antd';
import React, { useState, useEffect } from 'react';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import ProjectViewFinanceHeader from './project-view-finance-header/project-view-finance-header';
import FinanceTab from './finance-tab/finance-tab';
import RatecardTab from './ratecard-tab/ratecard-tab';
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
type FinanceTabType = 'finance' | 'ratecard';
type GroupTypes = 'status' | 'priority' | 'phases';
interface TaskGroup {
group_id: string;
group_name: string;
tasks: any[];
}
interface FinanceTabProps {
groupType: GroupTypes;
taskGroups: TaskGroup[];
loading: boolean;
}
import { fetchProjectFinances, setActiveTab, setActiveGroup } from '@/features/projects/finance/project-finance.slice';
import { RootState } from '@/app/store';
const ProjectViewFinance = () => {
const { projectId } = useParams<{ projectId: string }>();
const [activeTab, setActiveTab] = useState<FinanceTabType>('finance');
const [activeGroup, setActiveGroup] = useState<GroupTypes>('status');
const [loading, setLoading] = useState(false);
const [taskGroups, setTaskGroups] = useState<IProjectFinanceGroup[]>([]);
const fetchTasks = async () => {
if (!projectId) return;
try {
setLoading(true);
const response = await projectFinanceApiService.getProjectTasks(projectId, activeGroup);
if (response.done) {
setTaskGroups(response.body);
}
} catch (error) {
console.error('Error fetching tasks:', error);
} finally {
setLoading(false);
}
};
const dispatch = useAppDispatch();
const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances);
useEffect(() => {
fetchTasks();
}, [projectId, activeGroup]);
if (projectId) {
dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup }));
}
}, [projectId, activeGroup, dispatch]);
return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
<ProjectViewFinanceHeader
activeTab={activeTab}
setActiveTab={setActiveTab}
setActiveTab={(tab) => dispatch(setActiveTab(tab))}
activeGroup={activeGroup}
setActiveGroup={setActiveGroup}
setActiveGroup={(group) => dispatch(setActiveGroup(group))}
/>
{activeTab === 'finance' ? (

View File

@@ -10,28 +10,30 @@ export interface IProjectFinanceJobTitle {
}
export interface IProjectFinanceMember {
id: string;
team_member_id: string;
job_title_id: string;
rate: number | null;
user: IProjectFinanceUser;
job_title: IProjectFinanceJobTitle;
project_member_id: string;
name: string;
email_notifications_enabled: boolean;
avatar_url: string | null;
user_id: string;
email: string;
socket_id: string;
team_id: string;
}
export interface IProjectFinanceTask {
id: string;
name: string;
status_id: string;
priority_id: string;
phase_id: string;
estimated_hours: number;
actual_hours: number;
completed_at: string | null;
created_at: string;
updated_at: string;
billable: boolean;
assignees: any[]; // Using any[] since we don't have the assignee structure yet
total_time_logged: number;
estimated_cost: number;
members: IProjectFinanceMember[];
billable: boolean;
fixed_cost?: number;
variance?: number;
total_budget?: number;
total_actual?: number;
cost?: number;
}
export interface IProjectFinanceGroup {

View File

@@ -0,0 +1,7 @@
export const formatHoursToReadable = (hours: number) => {
return hours / 60;
};
export const convertToHoursMinutes = (hours: number) => {
return `${Math.floor(hours / 60)} h ${hours % 60} min`;
};