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:
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
61
worklenz-frontend/src/lib/project/finance-table-wrapper.tsx
Normal file
61
worklenz-frontend/src/lib/project/finance-table-wrapper.tsx
Normal 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;
|
||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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' ? (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
7
worklenz-frontend/src/utils/format-hours-to-readable.ts
Normal file
7
worklenz-frontend/src/utils/format-hours-to-readable.ts
Normal 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`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user