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

@@ -6372,3 +6372,44 @@ BEGIN
);
END;
$$;
CREATE OR REPLACE VIEW project_finance_view AS
SELECT
t.id,
t.name,
t.total_minutes / 3600.0 as estimated_hours,
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0 as total_time_logged,
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
FROM task_work_log twl
LEFT JOIN users u ON twl.user_id = u.id
LEFT JOIN team_members tm ON u.id = tm.user_id
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
WHERE twl.task_id = t.id), 0) as estimated_cost,
0 as fixed_cost, -- Default to 0 since the column doesn't exist
COALESCE(t.total_minutes / 3600.0 *
(SELECT rate FROM finance_project_rate_card_roles
WHERE project_id = t.project_id
AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1)
LIMIT 1), 0) as total_budgeted_cost,
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
FROM task_work_log twl
LEFT JOIN users u ON twl.user_id = u.id
LEFT JOIN team_members tm ON u.id = tm.user_id
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
WHERE twl.task_id = t.id), 0) as total_actual_cost,
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
FROM task_work_log twl
LEFT JOIN users u ON twl.user_id = u.id
LEFT JOIN team_members tm ON u.id = tm.user_id
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
WHERE twl.task_id = t.id), 0) -
COALESCE(t.total_minutes / 3600.0 *
(SELECT rate FROM finance_project_rate_card_roles
WHERE project_id = t.project_id
AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1)
LIMIT 1), 0) as variance,
t.project_id
FROM tasks t;

View File

@@ -5,6 +5,8 @@ import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
import { getColor } from "../shared/utils";
export default class ProjectfinanceController extends WorklenzControllerBase {
@HandleExceptions()
@@ -12,124 +14,143 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const { project_id } = req.params;
const { group_by = "status" } = req.query;
const projectId = req.params.project_id;
const groupBy = req.query.group || "status";
// Get all tasks with their financial data
const q = `
WITH task_data AS (
WITH task_costs AS (
SELECT
t.id,
t.name,
COALESCE(t.total_minutes, 0)::float as estimated_hours,
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0::float as total_time_logged,
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
FROM task_work_log twl
LEFT JOIN users u ON twl.user_id = u.id
LEFT JOIN team_members tm ON u.id = tm.user_id
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
WHERE twl.task_id = t.id), 0)::float as estimated_cost,
COALESCE(t.fixed_cost, 0)::float as fixed_cost,
t.project_id,
t.status_id,
t.priority_id,
tp.phase_id,
(t.total_minutes / 3600.0) as estimated_hours,
(COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0) as actual_hours,
t.completed_at,
t.created_at,
t.updated_at,
t.billable,
s.name as status_name,
p.name as priority_name,
ph.name as phase_name,
(SELECT color_code FROM sys_task_status_categories WHERE id = s.category_id) as status_color,
(SELECT color_code_dark FROM sys_task_status_categories WHERE id = s.category_id) as status_color_dark,
(SELECT color_code FROM task_priorities WHERE id = t.priority_id) as priority_color,
(SELECT color_code FROM project_phases WHERE id = tp.phase_id) as phase_color,
(SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id,
(SELECT get_task_assignees(t.id)) as assignees,
json_agg(
json_build_object(
'name', u.name,
'avatar_url', u.avatar_url,
'team_member_id', tm.id,
'color_code', '#1890ff'
)
) FILTER (WHERE u.id IS NOT NULL) as members
t.billable
FROM tasks t
LEFT JOIN task_statuses s ON t.status_id = s.id
LEFT JOIN task_priorities p ON t.priority_id = p.id
LEFT JOIN task_phase tp ON t.id = tp.task_id
LEFT JOIN project_phases ph ON tp.phase_id = ph.id
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
LEFT JOIN project_members pm ON ta.project_member_id = pm.id
LEFT JOIN team_members tm ON pm.team_member_id = tm.id
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
LEFT JOIN users u ON tm.user_id = u.id
LEFT JOIN job_titles jt ON tm.job_title_id = jt.id
WHERE t.project_id = $1
GROUP BY
t.id,
s.name,
p.name,
ph.name,
tp.phase_id,
s.category_id,
t.priority_id
WHERE t.project_id = $1 AND t.archived = false
)
SELECT
CASE
WHEN $2 = 'status' THEN status_id
WHEN $2 = 'priority' THEN priority_id
WHEN $2 = 'phases' THEN phase_id
END as group_id,
CASE
WHEN $2 = 'status' THEN status_name
WHEN $2 = 'priority' THEN priority_name
WHEN $2 = 'phases' THEN phase_name
END as group_name,
CASE
WHEN $2 = 'status' THEN status_color
WHEN $2 = 'priority' THEN priority_color
WHEN $2 = 'phases' THEN phase_color
END as color_code,
CASE
WHEN $2 = 'status' THEN status_color_dark
WHEN $2 = 'priority' THEN priority_color
WHEN $2 = 'phases' THEN phase_color
END as color_code_dark,
json_agg(
json_build_object(
'id', id,
'name', name,
'status_id', status_id,
'priority_id', priority_id,
'phase_id', phase_id,
'estimated_hours', estimated_hours,
'actual_hours', actual_hours,
'completed_at', completed_at,
'created_at', created_at,
'updated_at', updated_at,
'billable', billable,
'assignees', assignees,
'members', members
)
) as tasks
FROM task_data
GROUP BY
CASE
WHEN $2 = 'status' THEN status_id
WHEN $2 = 'priority' THEN priority_id
WHEN $2 = 'phases' THEN phase_id
END,
CASE
WHEN $2 = 'status' THEN status_name
WHEN $2 = 'priority' THEN priority_name
WHEN $2 = 'phases' THEN phase_name
END,
CASE
WHEN $2 = 'status' THEN status_color
WHEN $2 = 'priority' THEN priority_color
WHEN $2 = 'phases' THEN phase_color
END,
CASE
WHEN $2 = 'status' THEN status_color_dark
WHEN $2 = 'priority' THEN priority_color
WHEN $2 = 'phases' THEN phase_color
END
ORDER BY group_name;
tc.*,
(tc.estimated_cost + tc.fixed_cost)::float as total_budget,
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
FROM task_work_log twl
LEFT JOIN users u ON twl.user_id = u.id
LEFT JOIN team_members tm ON u.id = tm.user_id
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
WHERE twl.task_id = tc.id), 0)::float + tc.fixed_cost as total_actual,
(COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
FROM task_work_log twl
LEFT JOIN users u ON twl.user_id = u.id
LEFT JOIN team_members tm ON u.id = tm.user_id
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
WHERE twl.task_id = tc.id), 0)::float + tc.fixed_cost) - (tc.estimated_cost + tc.fixed_cost)::float as variance
FROM task_costs tc;
`;
const result = await db.query(q, [project_id, group_by]);
return res.status(200).send(new ServerResponse(true, result.rows));
const result = await db.query(q, [projectId]);
const tasks = result.rows;
// Add color_code to each assignee
for (const task of tasks) {
if (Array.isArray(task.assignees)) {
for (const assignee of task.assignees) {
assignee.color_code = getColor(assignee.name);
}
}
}
// Get groups based on groupBy parameter
let groups: Array<{ id: string; group_name: string; color_code: string; color_code_dark: string }> = [];
if (groupBy === "status") {
const q = `
SELECT
ts.id,
ts.name as group_name,
stsc.color_code::text,
stsc.color_code_dark::text
FROM task_statuses ts
INNER JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE ts.project_id = $1
ORDER BY ts.sort_order;
`;
groups = (await db.query(q, [projectId])).rows;
} else if (groupBy === "priority") {
const q = `
SELECT
id,
name as group_name,
color_code::text,
color_code_dark::text
FROM task_priorities
ORDER BY value;
`;
groups = (await db.query(q)).rows;
} else if (groupBy === "phases") {
const q = `
SELECT
id,
name as group_name,
color_code::text,
color_code::text as color_code_dark
FROM project_phases
WHERE project_id = $1
ORDER BY sort_index;
`;
groups = (await db.query(q, [projectId])).rows;
// Add TASK_STATUS_COLOR_ALPHA to color codes
for (const group of groups) {
group.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA;
group.color_code_dark = group.color_code_dark + TASK_STATUS_COLOR_ALPHA;
}
}
// Group tasks by the selected criteria
const groupedTasks = groups.map(group => {
const groupTasks = tasks.filter(task => {
if (groupBy === "status") return task.status_id === group.id;
if (groupBy === "priority") return task.priority_id === group.id;
if (groupBy === "phases") return task.phase_id === group.id;
return false;
});
return {
group_id: group.id,
group_name: group.group_name,
color_code: group.color_code,
color_code_dark: group.color_code_dark,
tasks: groupTasks.map(task => ({
id: task.id,
name: task.name,
estimated_hours: Number(task.estimated_hours) || 0,
total_time_logged: Number(task.total_time_logged) || 0,
estimated_cost: Number(task.estimated_cost) || 0,
fixed_cost: Number(task.fixed_cost) || 0,
total_budget: Number(task.total_budget) || 0,
total_actual: Number(task.total_actual) || 0,
variance: Number(task.variance) || 0,
members: task.assignees,
billable: task.billable
}))
};
});
return res.status(200).send(new ServerResponse(true, groupedTasks));
}
}

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`;
};