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; 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 { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base"; import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions"; 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 { export default class ProjectfinanceController extends WorklenzControllerBase {
@HandleExceptions() @HandleExceptions()
@@ -12,124 +14,143 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
req: IWorkLenzRequest, req: IWorkLenzRequest,
res: IWorkLenzResponse res: IWorkLenzResponse
): Promise<IWorkLenzResponse> { ): Promise<IWorkLenzResponse> {
const { project_id } = req.params; const projectId = req.params.project_id;
const { group_by = "status" } = req.query; const groupBy = req.query.group || "status";
// Get all tasks with their financial data
const q = ` const q = `
WITH task_data AS ( WITH task_costs AS (
SELECT SELECT
t.id, t.id,
t.name, 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.status_id,
t.priority_id, t.priority_id,
tp.phase_id, (SELECT phase_id FROM task_phase WHERE task_id = t.id) as 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 get_task_assignees(t.id)) as assignees, (SELECT get_task_assignees(t.id)) as assignees,
json_agg( t.billable
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
FROM tasks t FROM tasks t
LEFT JOIN task_statuses s ON t.status_id = s.id WHERE t.project_id = $1 AND t.archived = false
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
) )
SELECT SELECT
CASE tc.*,
WHEN $2 = 'status' THEN status_id (tc.estimated_cost + tc.fixed_cost)::float as total_budget,
WHEN $2 = 'priority' THEN priority_id COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
WHEN $2 = 'phases' THEN phase_id FROM task_work_log twl
END as group_id, LEFT JOIN users u ON twl.user_id = u.id
CASE LEFT JOIN team_members tm ON u.id = tm.user_id
WHEN $2 = 'status' THEN status_name LEFT JOIN project_members pm ON tm.id = pm.team_member_id
WHEN $2 = 'priority' THEN priority_name LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
WHEN $2 = 'phases' THEN phase_name WHERE twl.task_id = tc.id), 0)::float + tc.fixed_cost as total_actual,
END as group_name, (COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
CASE FROM task_work_log twl
WHEN $2 = 'status' THEN status_color LEFT JOIN users u ON twl.user_id = u.id
WHEN $2 = 'priority' THEN priority_color LEFT JOIN team_members tm ON u.id = tm.user_id
WHEN $2 = 'phases' THEN phase_color LEFT JOIN project_members pm ON tm.id = pm.team_member_id
END as color_code, LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
CASE WHERE twl.task_id = tc.id), 0)::float + tc.fixed_cost) - (tc.estimated_cost + tc.fixed_cost)::float as variance
WHEN $2 = 'status' THEN status_color_dark FROM task_costs tc;
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;
`; `;
const result = await db.query(q, [project_id, group_by]); const result = await db.query(q, [projectId]);
return res.status(200).send(new ServerResponse(true, result.rows)); 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", "taskColumn": "Task",
"membersColumn": "Members", "membersColumn": "Members",
"hoursColumn": "Hours", "hoursColumn": "Hours",
"totalTimeLoggedColumn": "Total Time Logged",
"costColumn": "Cost", "costColumn": "Cost",
"estimatedCostColumn": "Estimated Cost",
"fixedCostColumn": "Fixed Cost", "fixedCostColumn": "Fixed Cost",
"totalBudgetedCostColumn": "Total Budgeted Cost", "totalBudgetedCostColumn": "Total Budgeted Cost",
"totalActualCostColumn": "Total Actual Cost", "totalActualCostColumn": "Total Actual Cost",

View File

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

View File

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

View File

@@ -16,6 +16,18 @@ export const projectFinanceApiService = {
params: { group_by: groupBy } 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; return response.data;
}, },
} }

View File

@@ -73,6 +73,7 @@ import financeReducer from '../features/finance/finance-slice';
import roadmapReducer from '../features/roadmap/roadmap-slice'; import roadmapReducer from '../features/roadmap/roadmap-slice';
import teamMembersReducer from '@features/team-members/team-members.slice'; import teamMembersReducer from '@features/team-members/team-members.slice';
import projectFinanceRateCardReducer from '../features/finance/project-finance-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 groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
import homePageApiService from '@/api/home-page/home-page.api.service'; import homePageApiService from '@/api/home-page/home-page.api.service';
import { projectsApi } from '@/api/projects/projects.v1.api.service'; import { projectsApi } from '@/api/projects/projects.v1.api.service';
@@ -158,6 +159,7 @@ export const store = configureStore({
timeReportsOverviewReducer: timeReportsOverviewReducer, timeReportsOverviewReducer: timeReportsOverviewReducer,
financeReducer: financeReducer, financeReducer: financeReducer,
projectFinanceRateCard: projectFinanceRateCardReducer, 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; name: string;
width: number; width: number;
type: 'string' | 'hours' | 'currency'; type: 'string' | 'hours' | 'currency';
render?: (value: any) => React.ReactNode;
}; };
// finance table columns // finance table columns
export const financeTableColumns: FinanceTableColumnsType[] = [ export const financeTableColumns: FinanceTableColumnsType[] = [
{ {
key: 'task', key: 'task',
name: 'task', name: 'taskColumn',
width: 240, width: 240,
type: 'string', type: 'string',
}, },
{ {
key: 'members', key: 'members',
name: 'members', name: 'membersColumn',
width: 160, width: 160,
type: 'string', type: 'string',
}, },
{ {
key: 'hours', key: 'hours',
name: 'hours', name: 'hoursColumn',
width: 80, width: 80,
type: 'hours', type: 'hours',
}, },
{
key: 'total_time_logged',
name: 'totalTimeLoggedColumn',
width: 120,
type: 'hours',
},
{
key: 'estimated_cost',
name: 'estimatedCostColumn',
width: 120,
type: 'currency',
},
{ {
key: 'cost', key: 'cost',
name: 'cost', name: 'costColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',
}, },
{ {
key: 'fixedCost', key: 'fixedCost',
name: 'fixedCost', name: 'fixedCostColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',
}, },
{ {
key: 'totalBudget', key: 'totalBudget',
name: 'totalBudgetedCost', name: 'totalBudgetedCostColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',
}, },
{ {
key: 'totalActual', key: 'totalActual',
name: 'totalActualCost', name: 'totalActualCostColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',
}, },
{ {
key: 'variance', key: 'variance',
name: 'variance', name: 'varianceColumn',
width: 120, width: 120,
type: 'currency', type: 'currency',
}, },

View File

@@ -10,26 +10,28 @@ interface FinanceTabProps {
const FinanceTab = ({ const FinanceTab = ({
groupType, groupType,
taskGroups, taskGroups = [],
loading loading
}: FinanceTabProps) => { }: FinanceTabProps) => {
// Transform taskGroups into the format expected by FinanceTableWrapper // Transform taskGroups into the format expected by FinanceTableWrapper
const activeTablesList = taskGroups.map(group => ({ const activeTablesList = (taskGroups || []).map(group => ({
id: group.group_id, group_id: group.group_id,
name: group.group_name, group_name: group.group_name,
color_code: group.color_code, color_code: group.color_code,
color_code_dark: group.color_code_dark, color_code_dark: group.color_code_dark,
tasks: group.tasks.map(task => ({ tasks: (group.tasks || []).map(task => ({
taskId: task.id, id: task.id,
task: task.name, name: task.name,
hours: task.estimated_hours || 0, hours: task.estimated_hours || 0,
cost: 0, // TODO: Calculate based on rate and hours cost: 0, // TODO: Calculate based on rate and hours
fixedCost: 0, // TODO: Add fixed cost field fixedCost: 0, // TODO: Add fixed cost field
totalBudget: 0, // TODO: Calculate total budget totalBudget: 0, // TODO: Calculate total budget
totalActual: task.actual_hours || 0, totalActual: task.total_actual || 0,
variance: 0, // TODO: Calculate variance variance: 0, // TODO: Calculate variance
members: task.members || [], 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 React from "react";
import { Card, Col, Row, Spin } from "antd"; import { Card, Col, Row } from "antd";
import { useThemeContext } from "../../../../../context/theme-context";
import { FinanceTable } from "./finance-table";
import { IFinanceTable } from "./finance-table.interface";
import { IProjectFinanceGroup } from "../../../../../types/project/project-finance.types"; import { IProjectFinanceGroup } from "../../../../../types/project/project-finance.types";
import FinanceTable from "./finance-table/finance-table";
interface Props { interface Props {
activeTablesList: IProjectFinanceGroup[]; activeTablesList: IProjectFinanceGroup[];
@@ -32,7 +31,7 @@ export const FinanceTableWrapper: React.FC<Props> = ({ activeTablesList, loading
<h3>{table.group_name}</h3> <h3>{table.group_name}</h3>
</div> </div>
<FinanceTable <FinanceTable
table={table as unknown as IFinanceTable} table={table}
loading={loading} loading={loading}
/> />
</Card> </Card>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; 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 { themeWiseColor } from '../../../../../../utils/themeWiseColor';
import { useAppSelector } from '../../../../../../hooks/useAppSelector'; import { useAppSelector } from '../../../../../../hooks/useAppSelector';
import { useTranslation } from 'react-i18next'; 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 { financeTableColumns } from '@/lib/project/project-view-finance-table-columns';
import FinanceTable from './finance-table'; import FinanceTable from './finance-table';
import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer'; import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer';
import { convertToHoursMinutes, formatHoursToReadable } from '@/utils/format-hours-to-readable';
interface FinanceTableWrapperProps { interface FinanceTableWrapperProps {
activeTablesList: { activeTablesList: {
@@ -26,6 +27,8 @@ interface FinanceTableWrapperProps {
variance: number; variance: number;
members: any[]; members: any[];
isbBillable: boolean; isbBillable: boolean;
total_time_logged: number;
estimated_cost: number;
}[]; }[];
}[]; }[];
loading: boolean; loading: boolean;
@@ -72,6 +75,8 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
totalBudget: number; totalBudget: number;
totalActual: number; totalActual: number;
variance: number; variance: number;
total_time_logged: number;
estimated_cost: number;
}, },
table: { tasks: any[] } table: { tasks: any[] }
) => { ) => {
@@ -82,6 +87,8 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
acc.totalBudget += task.totalBudget || 0; acc.totalBudget += task.totalBudget || 0;
acc.totalActual += task.totalActual || 0; acc.totalActual += task.totalActual || 0;
acc.variance += task.variance || 0; acc.variance += task.variance || 0;
acc.total_time_logged += task.total_time_logged || 0;
acc.estimated_cost += task.estimated_cost || 0;
}); });
return acc; return acc;
}, },
@@ -92,15 +99,21 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
totalBudget: 0, totalBudget: 0,
totalActual: 0, totalActual: 0,
variance: 0, variance: 0,
total_time_logged: 0,
estimated_cost: 0,
} }
); );
console.log("totals", totals);
const renderFinancialTableHeaderContent = (columnKey: any) => { const renderFinancialTableHeaderContent = (columnKey: any) => {
switch (columnKey) { switch (columnKey) {
case 'hours': case 'hours':
return ( return (
<Typography.Text style={{ fontSize: 18 }}> <Typography.Text style={{ fontSize: 18 }}>
{totals.hours} <Tooltip title={convertToHoursMinutes(totals.hours)}>
{formatHoursToReadable(totals.hours)}
</Tooltip>
</Typography.Text> </Typography.Text>
); );
case 'cost': case 'cost':
@@ -138,6 +151,18 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
{totals.variance} {totals.variance}
</Typography.Text> </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: default:
return null; 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'}`} 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> <Typography.Text>
{t(`${col.name}Column`)}{' '} {t(`${col.name}`)}{' '}
{col.type === 'currency' && `(${currency.toUpperCase()})`} {col.type === 'currency' && `(${currency.toUpperCase()})`}
</Typography.Text> </Typography.Text>
</td> </td>

View File

@@ -1,283 +1,250 @@
import { Avatar, Checkbox, Flex, Input, Tooltip, Typography } from 'antd'; import { Checkbox, Flex, Input, InputNumber, Skeleton, Tooltip, Typography } from 'antd';
import React, { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import CustomAvatar from '../../../../../../components/CustomAvatar'; import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
import { import {
DollarCircleOutlined, DollarCircleOutlined,
DownOutlined, DownOutlined,
RightOutlined, RightOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { themeWiseColor } from '../../../../../../utils/themeWiseColor'; import { themeWiseColor } from '@/utils/themeWiseColor';
import { colors } from '../../../../../../styles/colors'; import { colors } from '@/styles/colors';
import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns'; import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns';
import Avatars from '@/components/avatars/avatars'; import Avatars from '@/components/avatars/avatars';
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
import { updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
type FinanceTableProps = { type FinanceTableProps = {
table: any; table: IProjectFinanceGroup;
isScrolling: boolean; loading: boolean;
onTaskClick: (task: any) => void;
}; };
const FinanceTable = ({ const FinanceTable = ({
table, table,
isScrolling, loading,
onTaskClick,
}: FinanceTableProps) => { }: FinanceTableProps) => {
const [isCollapse, setIsCollapse] = useState<boolean>(false); 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 // get theme data from theme reducer
const themeMode = useAppSelector((state) => state.themeReducer.mode); const themeMode = useAppSelector((state) => state.themeReducer.mode);
// totals of the current table const formatNumber = (value: number | undefined | null) => {
const totals = useMemo( if (value === undefined || value === null) return '0.00';
() => ({ return value.toFixed(2);
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]
);
useEffect(() => { const renderFinancialTableHeaderContent = (columnKey: string) => {
console.log('Selected Task:', selectedTask);
}, [selectedTask]);
const renderFinancialTableHeaderContent = (columnKey: any) => {
switch (columnKey) { switch (columnKey) {
case 'hours': case 'hours':
return ( return <Typography.Text>{formatNumber(totals.hours)}</Typography.Text>;
<Typography.Text style={{ color: colors.darkGray }}> case 'total_time_logged':
{totals.hours} return <Typography.Text>{formatNumber(totals.total_time_logged)}</Typography.Text>;
</Typography.Text> case 'estimated_cost':
); return <Typography.Text>{formatNumber(totals.estimated_cost)}</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>
);
case 'totalBudget': case 'totalBudget':
return ( return <Typography.Text>{formatNumber(totals.total_budget)}</Typography.Text>;
<Typography.Text style={{ color: colors.darkGray }}>
{totals.totalBudget}
</Typography.Text>
);
case 'totalActual': case 'totalActual':
return ( return <Typography.Text>{formatNumber(totals.total_actual)}</Typography.Text>;
<Typography.Text style={{ color: colors.darkGray }}>
{totals.totalActual}
</Typography.Text>
);
case 'variance': case 'variance':
return ( return <Typography.Text>{formatNumber(totals.variance)}</Typography.Text>;
<Typography.Text
style={{
color:
totals.variance < 0
? '#FF0000'
: themeWiseColor('#6DC376', colors.darkGray, themeMode),
}}
>
{totals.variance}
</Typography.Text>
);
default: default:
return null; 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) { switch (columnKey) {
case 'task': case 'task':
return ( return (
<Tooltip title={task.task}> <Tooltip title={task.name}>
<Flex gap={8} align="center"> <Flex gap={8} align="center">
<Typography.Text <Typography.Text
ellipsis={{ expanded: false }} ellipsis={{ expanded: false }}
style={{ maxWidth: 160 }} style={{ maxWidth: 160 }}
> >
{task.task} {task.name}
</Typography.Text> </Typography.Text>
{task.billable && <DollarCircleOutlined />}
{task.isbBillable && <DollarCircleOutlined />}
</Flex> </Flex>
</Tooltip> </Tooltip>
); );
case 'members': case 'members':
return ( return task.members && (
task?.assignees && <Avatars members={task.assignees} /> <Avatars
members={task.members.map(member => ({
...member,
avatar_url: member.avatar_url || undefined
}))}
/>
); );
case 'hours': case 'hours':
return <Typography.Text>{task.hours}</Typography.Text>; return <Typography.Text>{formatNumber(task.estimated_hours / 60)}</Typography.Text>;
case 'cost': case 'total_time_logged':
return <Typography.Text>{task.cost}</Typography.Text>; 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': case 'fixedCost':
return ( return selectedTask?.id === task.id ? (
<Input <InputNumber
value={task.fixedCost} value={task.fixed_cost}
style={{ onBlur={(e) => {
background: 'transparent', handleFixedCostChange(Number(e.target.value), task.id);
border: 'none', setSelectedTask(null);
boxShadow: 'none',
textAlign: 'right',
padding: 0,
}} }}
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': case 'variance':
return ( return <Typography.Text>{formatNumber(task.variance)}</Typography.Text>;
<Typography.Text case 'totalBudget':
style={{ return <Typography.Text>{formatNumber(task.total_budget)}</Typography.Text>;
color: task.variance < 0 ? '#FF0000' : '#6DC376', case 'totalActual':
}} return <Typography.Text>{formatNumber(task.total_actual)}</Typography.Text>;
> case 'cost':
{task.variance} return <Typography.Text>{formatNumber(task.cost || 0)}</Typography.Text>;
</Typography.Text>
);
default: default:
return null; return null;
} }
}; };
// layout styles for table and the columns // Calculate totals for the current table
const customColumnHeaderStyles = (key: string) => const totals = useMemo(() => {
`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' : ''}`}`; return tasks.reduce(
(acc, task) => ({
const customColumnStyles = (key: string) => hours: acc.hours + (task.estimated_hours / 60),
`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]'}`; 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 ( return (
<> <Skeleton active loading={loading}>
{/* header row */} <>
<tr {/* header row */}
style={{ <tr
height: 40,
backgroundColor: themeWiseColor(
table.color_code,
table.color_code_dark,
themeMode
),
fontWeight: 600,
}}
className="group"
>
<td
colSpan={3}
style={{ style={{
width: 48, height: 40,
textTransform: 'capitalize',
textAlign: 'left',
paddingInline: 16,
backgroundColor: themeWiseColor( backgroundColor: themeWiseColor(
table.color_code, table.color_code,
table.color_code_dark, table.color_code_dark,
themeMode themeMode
), ),
cursor: 'pointer', fontWeight: 600,
}} }}
className={customColumnHeaderStyles('tableTitle')} className="group"
onClick={(e) => setIsCollapse((prev) => !prev)}
> >
<Flex gap={8} align="center" style={{ color: colors.darkGray }}> <td
{isCollapse ? <RightOutlined /> : <DownOutlined />} colSpan={3}
{table.name} ({table.tasks.length}) style={{
</Flex> width: 48,
</td> 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( {financeTableColumns.map(
(col) => (col) =>
col.key !== 'task' && col.key !== 'task' &&
col.key !== 'members' && ( 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 <td
key={col.key} key={`${task.id}-${col.key}`}
style={{ style={{
width: col.width, width: col.width,
paddingInline: 16, paddingInline: 16,
textAlign: 'end', textAlign: col.type === 'string' ? 'left' : 'right',
}} }}
> >
{renderFinancialTableHeaderContent(col.key)} {renderFinancialTableColumnContent(col.key, task)}
</td> </td>
) ))}
)} </tr>
</tr> ))}
</>
{/* task rows */} </Skeleton>
{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>
))}
</>
); );
}; };

View File

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

View File

@@ -1,61 +1,33 @@
import { Flex } from 'antd'; import { Flex } from 'antd';
import React, { useState, useEffect } from 'react'; import { useEffect } from 'react';
import { useParams } from 'react-router-dom'; 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 ProjectViewFinanceHeader from './project-view-finance-header/project-view-finance-header';
import FinanceTab from './finance-tab/finance-tab'; import FinanceTab from './finance-tab/finance-tab';
import RatecardTab from './ratecard-tab/ratecard-tab'; import RatecardTab from './ratecard-tab/ratecard-tab';
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; import { fetchProjectFinances, setActiveTab, setActiveGroup } from '@/features/projects/finance/project-finance.slice';
import { IProjectFinanceGroup } from '@/types/project/project-finance.types'; import { RootState } from '@/app/store';
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;
}
const ProjectViewFinance = () => { const ProjectViewFinance = () => {
const { projectId } = useParams<{ projectId: string }>(); const { projectId } = useParams<{ projectId: string }>();
const [activeTab, setActiveTab] = useState<FinanceTabType>('finance'); const dispatch = useAppDispatch();
const [activeGroup, setActiveGroup] = useState<GroupTypes>('status');
const [loading, setLoading] = useState(false);
const [taskGroups, setTaskGroups] = useState<IProjectFinanceGroup[]>([]);
const fetchTasks = async () => { const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances);
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);
}
};
useEffect(() => { useEffect(() => {
fetchTasks(); if (projectId) {
}, [projectId, activeGroup]); dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup }));
}
}, [projectId, activeGroup, dispatch]);
return ( return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}> <Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
<ProjectViewFinanceHeader <ProjectViewFinanceHeader
activeTab={activeTab} activeTab={activeTab}
setActiveTab={setActiveTab} setActiveTab={(tab) => dispatch(setActiveTab(tab))}
activeGroup={activeGroup} activeGroup={activeGroup}
setActiveGroup={setActiveGroup} setActiveGroup={(group) => dispatch(setActiveGroup(group))}
/> />
{activeTab === 'finance' ? ( {activeTab === 'finance' ? (

View File

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