diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index 9c9cc820..8dbece0e 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -6372,3 +6372,44 @@ BEGIN ); END; $$; + +CREATE OR REPLACE VIEW project_finance_view AS +SELECT + t.id, + t.name, + t.total_minutes / 3600.0 as estimated_hours, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0 as total_time_logged, + COALESCE((SELECT SUM(rate * (time_spent / 3600.0)) + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + LEFT JOIN project_members pm ON tm.id = pm.team_member_id + LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id + WHERE twl.task_id = t.id), 0) as estimated_cost, + 0 as fixed_cost, -- Default to 0 since the column doesn't exist + COALESCE(t.total_minutes / 3600.0 * + (SELECT rate FROM finance_project_rate_card_roles + WHERE project_id = t.project_id + AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1) + LIMIT 1), 0) as total_budgeted_cost, + COALESCE((SELECT SUM(rate * (time_spent / 3600.0)) + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + LEFT JOIN project_members pm ON tm.id = pm.team_member_id + LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id + WHERE twl.task_id = t.id), 0) as total_actual_cost, + COALESCE((SELECT SUM(rate * (time_spent / 3600.0)) + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + LEFT JOIN project_members pm ON tm.id = pm.team_member_id + LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id + WHERE twl.task_id = t.id), 0) - + COALESCE(t.total_minutes / 3600.0 * + (SELECT rate FROM finance_project_rate_card_roles + WHERE project_id = t.project_id + AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1) + LIMIT 1), 0) as variance, + t.project_id +FROM tasks t; diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index d5e3160b..df602b15 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -5,6 +5,8 @@ import db from "../config/db"; import { ServerResponse } from "../models/server-response"; import WorklenzControllerBase from "./worklenz-controller-base"; import HandleExceptions from "../decorators/handle-exceptions"; +import { TASK_STATUS_COLOR_ALPHA } from "../shared/constants"; +import { getColor } from "../shared/utils"; export default class ProjectfinanceController extends WorklenzControllerBase { @HandleExceptions() @@ -12,124 +14,143 @@ export default class ProjectfinanceController extends WorklenzControllerBase { req: IWorkLenzRequest, res: IWorkLenzResponse ): Promise { - const { project_id } = req.params; - const { group_by = "status" } = req.query; + const projectId = req.params.project_id; + const groupBy = req.query.group || "status"; + // Get all tasks with their financial data const q = ` - WITH task_data AS ( + WITH task_costs AS ( SELECT t.id, t.name, + COALESCE(t.total_minutes, 0)::float as estimated_hours, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0::float as total_time_logged, + COALESCE((SELECT SUM(rate * (time_spent / 3600.0)) + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + LEFT JOIN project_members pm ON tm.id = pm.team_member_id + LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id + WHERE twl.task_id = t.id), 0)::float as estimated_cost, + COALESCE(t.fixed_cost, 0)::float as fixed_cost, + t.project_id, t.status_id, t.priority_id, - tp.phase_id, - (t.total_minutes / 3600.0) as estimated_hours, - (COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0) as actual_hours, - t.completed_at, - t.created_at, - t.updated_at, - t.billable, - s.name as status_name, - p.name as priority_name, - ph.name as phase_name, - (SELECT color_code FROM sys_task_status_categories WHERE id = s.category_id) as status_color, - (SELECT color_code_dark FROM sys_task_status_categories WHERE id = s.category_id) as status_color_dark, - (SELECT color_code FROM task_priorities WHERE id = t.priority_id) as priority_color, - (SELECT color_code FROM project_phases WHERE id = tp.phase_id) as phase_color, + (SELECT phase_id FROM task_phase WHERE task_id = t.id) as phase_id, (SELECT get_task_assignees(t.id)) as assignees, - json_agg( - json_build_object( - 'name', u.name, - 'avatar_url', u.avatar_url, - 'team_member_id', tm.id, - 'color_code', '#1890ff' - ) - ) FILTER (WHERE u.id IS NOT NULL) as members + t.billable FROM tasks t - LEFT JOIN task_statuses s ON t.status_id = s.id - LEFT JOIN task_priorities p ON t.priority_id = p.id - LEFT JOIN task_phase tp ON t.id = tp.task_id - LEFT JOIN project_phases ph ON tp.phase_id = ph.id - LEFT JOIN tasks_assignees ta ON t.id = ta.task_id - LEFT JOIN project_members pm ON ta.project_member_id = pm.id - LEFT JOIN team_members tm ON pm.team_member_id = tm.id - LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id - LEFT JOIN users u ON tm.user_id = u.id - LEFT JOIN job_titles jt ON tm.job_title_id = jt.id - WHERE t.project_id = $1 - GROUP BY - t.id, - s.name, - p.name, - ph.name, - tp.phase_id, - s.category_id, - t.priority_id + WHERE t.project_id = $1 AND t.archived = false ) SELECT - CASE - WHEN $2 = 'status' THEN status_id - WHEN $2 = 'priority' THEN priority_id - WHEN $2 = 'phases' THEN phase_id - END as group_id, - CASE - WHEN $2 = 'status' THEN status_name - WHEN $2 = 'priority' THEN priority_name - WHEN $2 = 'phases' THEN phase_name - END as group_name, - CASE - WHEN $2 = 'status' THEN status_color - WHEN $2 = 'priority' THEN priority_color - WHEN $2 = 'phases' THEN phase_color - END as color_code, - CASE - WHEN $2 = 'status' THEN status_color_dark - WHEN $2 = 'priority' THEN priority_color - WHEN $2 = 'phases' THEN phase_color - END as color_code_dark, - json_agg( - json_build_object( - 'id', id, - 'name', name, - 'status_id', status_id, - 'priority_id', priority_id, - 'phase_id', phase_id, - 'estimated_hours', estimated_hours, - 'actual_hours', actual_hours, - 'completed_at', completed_at, - 'created_at', created_at, - 'updated_at', updated_at, - 'billable', billable, - 'assignees', assignees, - 'members', members - ) - ) as tasks - FROM task_data - GROUP BY - CASE - WHEN $2 = 'status' THEN status_id - WHEN $2 = 'priority' THEN priority_id - WHEN $2 = 'phases' THEN phase_id - END, - CASE - WHEN $2 = 'status' THEN status_name - WHEN $2 = 'priority' THEN priority_name - WHEN $2 = 'phases' THEN phase_name - END, - CASE - WHEN $2 = 'status' THEN status_color - WHEN $2 = 'priority' THEN priority_color - WHEN $2 = 'phases' THEN phase_color - END, - CASE - WHEN $2 = 'status' THEN status_color_dark - WHEN $2 = 'priority' THEN priority_color - WHEN $2 = 'phases' THEN phase_color - END - ORDER BY group_name; + tc.*, + (tc.estimated_cost + tc.fixed_cost)::float as total_budget, + COALESCE((SELECT SUM(rate * (time_spent / 3600.0)) + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + LEFT JOIN project_members pm ON tm.id = pm.team_member_id + LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id + WHERE twl.task_id = tc.id), 0)::float + tc.fixed_cost as total_actual, + (COALESCE((SELECT SUM(rate * (time_spent / 3600.0)) + FROM task_work_log twl + LEFT JOIN users u ON twl.user_id = u.id + LEFT JOIN team_members tm ON u.id = tm.user_id + LEFT JOIN project_members pm ON tm.id = pm.team_member_id + LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id + WHERE twl.task_id = tc.id), 0)::float + tc.fixed_cost) - (tc.estimated_cost + tc.fixed_cost)::float as variance + FROM task_costs tc; `; - const result = await db.query(q, [project_id, group_by]); - return res.status(200).send(new ServerResponse(true, result.rows)); + const result = await db.query(q, [projectId]); + const tasks = result.rows; + + // Add color_code to each assignee + for (const task of tasks) { + if (Array.isArray(task.assignees)) { + for (const assignee of task.assignees) { + assignee.color_code = getColor(assignee.name); + } + } + } + + // Get groups based on groupBy parameter + let groups: Array<{ id: string; group_name: string; color_code: string; color_code_dark: string }> = []; + + if (groupBy === "status") { + const q = ` + SELECT + ts.id, + ts.name as group_name, + stsc.color_code::text, + stsc.color_code_dark::text + FROM task_statuses ts + INNER JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE ts.project_id = $1 + ORDER BY ts.sort_order; + `; + groups = (await db.query(q, [projectId])).rows; + } else if (groupBy === "priority") { + const q = ` + SELECT + id, + name as group_name, + color_code::text, + color_code_dark::text + FROM task_priorities + ORDER BY value; + `; + groups = (await db.query(q)).rows; + } else if (groupBy === "phases") { + const q = ` + SELECT + id, + name as group_name, + color_code::text, + color_code::text as color_code_dark + FROM project_phases + WHERE project_id = $1 + ORDER BY sort_index; + `; + groups = (await db.query(q, [projectId])).rows; + + // Add TASK_STATUS_COLOR_ALPHA to color codes + for (const group of groups) { + group.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA; + group.color_code_dark = group.color_code_dark + TASK_STATUS_COLOR_ALPHA; + } + } + + // Group tasks by the selected criteria + const groupedTasks = groups.map(group => { + const groupTasks = tasks.filter(task => { + if (groupBy === "status") return task.status_id === group.id; + if (groupBy === "priority") return task.priority_id === group.id; + if (groupBy === "phases") return task.phase_id === group.id; + return false; + }); + + return { + group_id: group.id, + group_name: group.group_name, + color_code: group.color_code, + color_code_dark: group.color_code_dark, + tasks: groupTasks.map(task => ({ + id: task.id, + name: task.name, + estimated_hours: Number(task.estimated_hours) || 0, + total_time_logged: Number(task.total_time_logged) || 0, + estimated_cost: Number(task.estimated_cost) || 0, + fixed_cost: Number(task.fixed_cost) || 0, + total_budget: Number(task.total_budget) || 0, + total_actual: Number(task.total_actual) || 0, + variance: Number(task.variance) || 0, + members: task.assignees, + billable: task.billable + })) + }; + }); + + return res.status(200).send(new ServerResponse(true, groupedTasks)); } } diff --git a/worklenz-frontend/public/locales/en/project-view-finance.json b/worklenz-frontend/public/locales/en/project-view-finance.json index ed43b4bf..19526d30 100644 --- a/worklenz-frontend/public/locales/en/project-view-finance.json +++ b/worklenz-frontend/public/locales/en/project-view-finance.json @@ -12,7 +12,9 @@ "taskColumn": "Task", "membersColumn": "Members", "hoursColumn": "Hours", + "totalTimeLoggedColumn": "Total Time Logged", "costColumn": "Cost", + "estimatedCostColumn": "Estimated Cost", "fixedCostColumn": "Fixed Cost", "totalBudgetedCostColumn": "Total Budgeted Cost", "totalActualCostColumn": "Total Actual Cost", diff --git a/worklenz-frontend/public/locales/es/project-view-finance.json b/worklenz-frontend/public/locales/es/project-view-finance.json index fdf9849d..440378fa 100644 --- a/worklenz-frontend/public/locales/es/project-view-finance.json +++ b/worklenz-frontend/public/locales/es/project-view-finance.json @@ -12,7 +12,9 @@ "taskColumn": "Tarea", "membersColumn": "Miembros", "hoursColumn": "Horas", + "totalTimeLoggedColumn": "Tiempo Total Registrado", "costColumn": "Costo", + "estimatedCostColumn": "Costo Estimado", "fixedCostColumn": "Costo Fijo", "totalBudgetedCostColumn": "Costo Total Presupuestado", "totalActualCostColumn": "Costo Total Real", diff --git a/worklenz-frontend/public/locales/pt/project-view-finance.json b/worklenz-frontend/public/locales/pt/project-view-finance.json index db5c67c6..e436d8c0 100644 --- a/worklenz-frontend/public/locales/pt/project-view-finance.json +++ b/worklenz-frontend/public/locales/pt/project-view-finance.json @@ -12,7 +12,9 @@ "taskColumn": "Tarefa", "membersColumn": "Membros", "hoursColumn": "Horas", + "totalTimeLoggedColumn": "Tempo Total Registrado", "costColumn": "Custo", + "estimatedCostColumn": "Custo Estimado", "fixedCostColumn": "Custo Fixo", "totalBudgetedCostColumn": "Custo Total Orçado", "totalActualCostColumn": "Custo Total Real", diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts index bfbf71b4..9d99ccda 100644 --- a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts @@ -16,6 +16,18 @@ export const projectFinanceApiService = { params: { group_by: groupBy } } ); + console.log(response.data); + return response.data; + }, + + updateTaskFixedCost: async ( + taskId: string, + fixedCost: number + ): Promise> => { + const response = await apiClient.put>( + `${rootUrl}/task/${taskId}/fixed-cost`, + { fixed_cost: fixedCost } + ); return response.data; }, } \ No newline at end of file diff --git a/worklenz-frontend/src/app/store.ts b/worklenz-frontend/src/app/store.ts index f9f13429..7ce0ccdc 100644 --- a/worklenz-frontend/src/app/store.ts +++ b/worklenz-frontend/src/app/store.ts @@ -73,6 +73,7 @@ import financeReducer from '../features/finance/finance-slice'; import roadmapReducer from '../features/roadmap/roadmap-slice'; import teamMembersReducer from '@features/team-members/team-members.slice'; import projectFinanceRateCardReducer from '../features/finance/project-finance-slice'; +import projectFinancesReducer from '../features/projects/finance/project-finance.slice'; import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice'; import homePageApiService from '@/api/home-page/home-page.api.service'; import { projectsApi } from '@/api/projects/projects.v1.api.service'; @@ -158,6 +159,7 @@ export const store = configureStore({ timeReportsOverviewReducer: timeReportsOverviewReducer, financeReducer: financeReducer, projectFinanceRateCard: projectFinanceRateCardReducer, + projectFinances: projectFinancesReducer, }, }); diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts new file mode 100644 index 00000000..87e3dd05 --- /dev/null +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -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) => { + state.activeTab = action.payload; + }, + setActiveGroup: (state, action: PayloadAction) => { + 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; diff --git a/worklenz-frontend/src/lib/project/finance-table-wrapper.tsx b/worklenz-frontend/src/lib/project/finance-table-wrapper.tsx new file mode 100644 index 00000000..36dc4053 --- /dev/null +++ b/worklenz-frontend/src/lib/project/finance-table-wrapper.tsx @@ -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 = ({ 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 ( + + ); +}; + +export default FinanceTableWrapper; \ No newline at end of file diff --git a/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts b/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts index e08bd430..0e357f37 100644 --- a/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts +++ b/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts @@ -3,55 +3,68 @@ type FinanceTableColumnsType = { name: string; width: number; type: 'string' | 'hours' | 'currency'; + render?: (value: any) => React.ReactNode; }; // finance table columns export const financeTableColumns: FinanceTableColumnsType[] = [ { key: 'task', - name: 'task', + name: 'taskColumn', width: 240, type: 'string', }, { key: 'members', - name: 'members', + name: 'membersColumn', width: 160, type: 'string', }, { key: 'hours', - name: 'hours', + name: 'hoursColumn', width: 80, type: 'hours', }, + { + key: 'total_time_logged', + name: 'totalTimeLoggedColumn', + width: 120, + type: 'hours', + }, + { + key: 'estimated_cost', + name: 'estimatedCostColumn', + width: 120, + type: 'currency', + }, { key: 'cost', - name: 'cost', + name: 'costColumn', width: 120, type: 'currency', }, { key: 'fixedCost', - name: 'fixedCost', + name: 'fixedCostColumn', width: 120, type: 'currency', }, { key: 'totalBudget', - name: 'totalBudgetedCost', + name: 'totalBudgetedCostColumn', width: 120, type: 'currency', }, { key: 'totalActual', - name: 'totalActualCost', + name: 'totalActualCostColumn', width: 120, type: 'currency', }, { key: 'variance', - name: 'variance', + name: 'varianceColumn', width: 120, type: 'currency', }, diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx index 3da81d48..02cc1f65 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx @@ -10,26 +10,28 @@ interface FinanceTabProps { const FinanceTab = ({ groupType, - taskGroups, + taskGroups = [], loading }: FinanceTabProps) => { // Transform taskGroups into the format expected by FinanceTableWrapper - const activeTablesList = taskGroups.map(group => ({ - id: group.group_id, - name: group.group_name, + const activeTablesList = (taskGroups || []).map(group => ({ + group_id: group.group_id, + group_name: group.group_name, color_code: group.color_code, color_code_dark: group.color_code_dark, - tasks: group.tasks.map(task => ({ - taskId: task.id, - task: task.name, + tasks: (group.tasks || []).map(task => ({ + id: task.id, + name: task.name, hours: task.estimated_hours || 0, cost: 0, // TODO: Calculate based on rate and hours fixedCost: 0, // TODO: Add fixed cost field totalBudget: 0, // TODO: Calculate total budget - totalActual: task.actual_hours || 0, + totalActual: task.total_actual || 0, variance: 0, // TODO: Calculate variance members: task.members || [], - isbBillable: task.billable + isbBillable: task.billable, + total_time_logged: task.total_time_logged || 0, + estimated_cost: task.estimated_cost || 0 })) })); diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx index 2360efc7..ba663bce 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx @@ -1,9 +1,8 @@ import React from "react"; -import { Card, Col, Row, Spin } from "antd"; -import { useThemeContext } from "../../../../../context/theme-context"; -import { FinanceTable } from "./finance-table"; -import { IFinanceTable } from "./finance-table.interface"; +import { Card, Col, Row } from "antd"; + import { IProjectFinanceGroup } from "../../../../../types/project/project-finance.types"; +import FinanceTable from "./finance-table/finance-table"; interface Props { activeTablesList: IProjectFinanceGroup[]; @@ -32,7 +31,7 @@ export const FinanceTableWrapper: React.FC = ({ activeTablesList, loading

{table.group_name}

diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx index aafb6224..5f35c008 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Checkbox, Flex, Typography } from 'antd'; +import { Checkbox, Flex, Tooltip, Typography } from 'antd'; import { themeWiseColor } from '../../../../../../utils/themeWiseColor'; import { useAppSelector } from '../../../../../../hooks/useAppSelector'; import { useTranslation } from 'react-i18next'; @@ -8,6 +8,7 @@ import { toggleFinanceDrawer } from '@/features/finance/finance-slice'; import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns'; import FinanceTable from './finance-table'; import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer'; +import { convertToHoursMinutes, formatHoursToReadable } from '@/utils/format-hours-to-readable'; interface FinanceTableWrapperProps { activeTablesList: { @@ -26,6 +27,8 @@ interface FinanceTableWrapperProps { variance: number; members: any[]; isbBillable: boolean; + total_time_logged: number; + estimated_cost: number; }[]; }[]; loading: boolean; @@ -72,6 +75,8 @@ const FinanceTableWrapper: React.FC = ({ totalBudget: number; totalActual: number; variance: number; + total_time_logged: number; + estimated_cost: number; }, table: { tasks: any[] } ) => { @@ -82,6 +87,8 @@ const FinanceTableWrapper: React.FC = ({ acc.totalBudget += task.totalBudget || 0; acc.totalActual += task.totalActual || 0; acc.variance += task.variance || 0; + acc.total_time_logged += task.total_time_logged || 0; + acc.estimated_cost += task.estimated_cost || 0; }); return acc; }, @@ -92,15 +99,21 @@ const FinanceTableWrapper: React.FC = ({ totalBudget: 0, totalActual: 0, variance: 0, + total_time_logged: 0, + estimated_cost: 0, } ); + console.log("totals", totals); + const renderFinancialTableHeaderContent = (columnKey: any) => { switch (columnKey) { case 'hours': return ( - {totals.hours} + + {formatHoursToReadable(totals.hours)} + ); case 'cost': @@ -138,6 +151,18 @@ const FinanceTableWrapper: React.FC = ({ {totals.variance} ); + case 'total_time_logged': + return ( + + {totals.total_time_logged?.toFixed(2)} + + ); + case 'estimated_cost': + return ( + + {`${currency.toUpperCase()} ${totals.estimated_cost?.toFixed(2)}`} + + ); default: return null; } @@ -189,7 +214,7 @@ const FinanceTableWrapper: React.FC = ({ 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'}`} > - {t(`${col.name}Column`)}{' '} + {t(`${col.name}`)}{' '} {col.type === 'currency' && `(${currency.toUpperCase()})`} diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx index 15aa423d..d1923e98 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx @@ -1,283 +1,250 @@ -import { Avatar, Checkbox, Flex, Input, Tooltip, Typography } from 'antd'; -import React, { useEffect, useMemo, useState } from 'react'; -import CustomAvatar from '../../../../../../components/CustomAvatar'; -import { useAppSelector } from '../../../../../../hooks/useAppSelector'; +import { Checkbox, Flex, Input, InputNumber, Skeleton, Tooltip, Typography } from 'antd'; +import { useEffect, useMemo, useState } from 'react'; +import { useAppSelector } from '@/hooks/useAppSelector'; import { DollarCircleOutlined, DownOutlined, RightOutlined, } from '@ant-design/icons'; -import { themeWiseColor } from '../../../../../../utils/themeWiseColor'; -import { colors } from '../../../../../../styles/colors'; +import { themeWiseColor } from '@/utils/themeWiseColor'; +import { colors } from '@/styles/colors'; import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns'; import Avatars from '@/components/avatars/avatars'; +import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; +import { updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; type FinanceTableProps = { - table: any; - isScrolling: boolean; - onTaskClick: (task: any) => void; + table: IProjectFinanceGroup; + loading: boolean; }; const FinanceTable = ({ table, - isScrolling, - onTaskClick, + loading, }: FinanceTableProps) => { const [isCollapse, setIsCollapse] = useState(false); - const [selectedTask, setSelectedTask] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); + const [tasks, setTasks] = useState(table.tasks); + const dispatch = useAppDispatch(); + + // Get the latest task groups from Redux store + const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups); + + // Update local state when table.tasks or Redux store changes + useEffect(() => { + const updatedGroup = taskGroups.find(g => g.group_id === table.group_id); + if (updatedGroup) { + setTasks(updatedGroup.tasks); + } else { + setTasks(table.tasks); + } + }, [table.tasks, taskGroups, table.group_id]); // get theme data from theme reducer const themeMode = useAppSelector((state) => state.themeReducer.mode); - // totals of the current table - const totals = useMemo( - () => ({ - hours: (table?.tasks || []).reduce( - (sum: any, task: { hours: any }) => sum + task.hours, - 0 - ), - cost: (table?.tasks || []).reduce( - (sum: any, task: { cost: any }) => sum + task.cost, - 0 - ), - fixedCost: (table?.tasks || []).reduce( - (sum: any, task: { fixedCost: any }) => sum + task.fixedCost, - 0 - ), - totalBudget: (table?.tasks || []).reduce( - (sum: any, task: { totalBudget: any }) => sum + task.totalBudget, - 0 - ), - totalActual: (table?.tasks || []).reduce( - (sum: any, task: { totalActual: any }) => sum + task.totalActual, - 0 - ), - variance: (table?.tasks || []).reduce( - (sum: any, task: { variance: any }) => sum + task.variance, - 0 - ), - }), - [table] - ); + const formatNumber = (value: number | undefined | null) => { + if (value === undefined || value === null) return '0.00'; + return value.toFixed(2); + }; - useEffect(() => { - console.log('Selected Task:', selectedTask); - }, [selectedTask]); - - const renderFinancialTableHeaderContent = (columnKey: any) => { + const renderFinancialTableHeaderContent = (columnKey: string) => { switch (columnKey) { case 'hours': - return ( - - {totals.hours} - - ); - case 'cost': - return ( - - {totals.cost} - - ); - case 'fixedCost': - return ( - - {totals.fixedCost} - - ); + return {formatNumber(totals.hours)}; + case 'total_time_logged': + return {formatNumber(totals.total_time_logged)}; + case 'estimated_cost': + return {formatNumber(totals.estimated_cost)}; case 'totalBudget': - return ( - - {totals.totalBudget} - - ); + return {formatNumber(totals.total_budget)}; case 'totalActual': - return ( - - {totals.totalActual} - - ); + return {formatNumber(totals.total_actual)}; case 'variance': - return ( - - {totals.variance} - - ); + return {formatNumber(totals.variance)}; default: return null; } }; - const renderFinancialTableColumnContent = (columnKey: any, task: any) => { + const handleFixedCostChange = (value: number | null, taskId: string) => { + dispatch(updateTaskFixedCost({ taskId, groupId: table.group_id, fixedCost: value || 0 })); + }; + + const renderFinancialTableColumnContent = (columnKey: string, task: IProjectFinanceTask) => { switch (columnKey) { case 'task': return ( - + - {task.task} + {task.name} - - {task.isbBillable && } + {task.billable && } ); case 'members': - return ( - task?.assignees && + return task.members && ( + ({ + ...member, + avatar_url: member.avatar_url || undefined + }))} + /> ); case 'hours': - return {task.hours}; - case 'cost': - return {task.cost}; + return {formatNumber(task.estimated_hours / 60)}; + case 'total_time_logged': + return {formatNumber(task.total_time_logged / 60)}; + case 'estimated_cost': + return {formatNumber(task.estimated_cost)}; case 'fixedCost': - return ( - { + handleFixedCostChange(Number(e.target.value), task.id); + setSelectedTask(null); }} + autoFocus + style={{ width: '100%', textAlign: 'right' }} + formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} + parser={(value) => Number(value!.replace(/\$\s?|(,*)/g, ''))} + min={0} + precision={2} /> + ) : ( + {formatNumber(task.fixed_cost)} ); - case 'totalBudget': - return ( - - ); - case 'totalActual': - return {task.totalActual}; case 'variance': - return ( - - {task.variance} - - ); + return {formatNumber(task.variance)}; + case 'totalBudget': + return {formatNumber(task.total_budget)}; + case 'totalActual': + return {formatNumber(task.total_actual)}; + case 'cost': + return {formatNumber(task.cost || 0)}; default: return null; } }; - // layout styles for table and the columns - const customColumnHeaderStyles = (key: string) => - `px-2 text-left ${key === 'tableTitle' && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`}`; - - const customColumnStyles = (key: string) => - `px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[52px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`; + // Calculate totals for the current table + const totals = useMemo(() => { + return tasks.reduce( + (acc, task) => ({ + hours: acc.hours + (task.estimated_hours / 60), + total_time_logged: acc.total_time_logged + (task.total_time_logged / 60), + estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), + total_budget: acc.total_budget + (task.total_budget || 0), + total_actual: acc.total_actual + (task.total_actual || 0), + variance: acc.variance + (task.variance || 0) + }), + { + hours: 0, + total_time_logged: 0, + estimated_cost: 0, + total_budget: 0, + total_actual: 0, + variance: 0 + } + ); + }, [tasks]); return ( - <> - {/* header row */} -
- setIsCollapse((prev) => !prev)} + className="group" > - - {isCollapse ? : } - {table.name} ({table.tasks.length}) - - + - {financeTableColumns.map( - (col) => - col.key !== 'task' && - col.key !== 'members' && ( + {financeTableColumns.map( + (col) => + col.key !== 'task' && + col.key !== 'members' && ( + + ) + )} + + + {/* task rows */} + {!isCollapse && tasks.map((task, idx) => ( + e.currentTarget.style.background = '#333'} + onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? '#232323' : '#181818'} + onClick={() => setSelectedTask(task)} + > + + {financeTableColumns.map((col) => ( - ) - )} - - - {/* task rows */} - {table.tasks.map((task: any) => ( - onTaskClick(task)} - > - - {financeTableColumns.map((col) => ( - - ))} - - ))} - + ))} + + ))} + + ); }; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/project-view-finance-header.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/project-view-finance-header.tsx index 6c697f50..7c213481 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/project-view-finance-header.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/project-view-finance-header.tsx @@ -1,5 +1,4 @@ import { Button, ConfigProvider, Flex, Select, Typography } from 'antd'; -import React from 'react'; import GroupByFilterDropdown from './group-by-filter-dropdown'; import { DownOutlined } from '@ant-design/icons'; import { useAppDispatch } from '../../../../../hooks/useAppDispatch'; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx index 91166edf..4531fb21 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx @@ -1,61 +1,33 @@ import { Flex } from 'antd'; -import React, { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; import ProjectViewFinanceHeader from './project-view-finance-header/project-view-finance-header'; import FinanceTab from './finance-tab/finance-tab'; import RatecardTab from './ratecard-tab/ratecard-tab'; -import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; -import { IProjectFinanceGroup } from '@/types/project/project-finance.types'; - -type FinanceTabType = 'finance' | 'ratecard'; -type GroupTypes = 'status' | 'priority' | 'phases'; - -interface TaskGroup { - group_id: string; - group_name: string; - tasks: any[]; -} - -interface FinanceTabProps { - groupType: GroupTypes; - taskGroups: TaskGroup[]; - loading: boolean; -} +import { fetchProjectFinances, setActiveTab, setActiveGroup } from '@/features/projects/finance/project-finance.slice'; +import { RootState } from '@/app/store'; const ProjectViewFinance = () => { const { projectId } = useParams<{ projectId: string }>(); - const [activeTab, setActiveTab] = useState('finance'); - const [activeGroup, setActiveGroup] = useState('status'); - const [loading, setLoading] = useState(false); - const [taskGroups, setTaskGroups] = useState([]); - - const fetchTasks = async () => { - if (!projectId) return; - - try { - setLoading(true); - const response = await projectFinanceApiService.getProjectTasks(projectId, activeGroup); - if (response.done) { - setTaskGroups(response.body); - } - } catch (error) { - console.error('Error fetching tasks:', error); - } finally { - setLoading(false); - } - }; + const dispatch = useAppDispatch(); + + const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances); useEffect(() => { - fetchTasks(); - }, [projectId, activeGroup]); + if (projectId) { + dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup })); + } + }, [projectId, activeGroup, dispatch]); return ( dispatch(setActiveTab(tab))} activeGroup={activeGroup} - setActiveGroup={setActiveGroup} + setActiveGroup={(group) => dispatch(setActiveGroup(group))} /> {activeTab === 'finance' ? ( diff --git a/worklenz-frontend/src/types/project/project-finance.types.ts b/worklenz-frontend/src/types/project/project-finance.types.ts index 7b4319c9..6b2f10ed 100644 --- a/worklenz-frontend/src/types/project/project-finance.types.ts +++ b/worklenz-frontend/src/types/project/project-finance.types.ts @@ -10,28 +10,30 @@ export interface IProjectFinanceJobTitle { } export interface IProjectFinanceMember { - id: string; team_member_id: string; - job_title_id: string; - rate: number | null; - user: IProjectFinanceUser; - job_title: IProjectFinanceJobTitle; + project_member_id: string; + name: string; + email_notifications_enabled: boolean; + avatar_url: string | null; + user_id: string; + email: string; + socket_id: string; + team_id: string; } export interface IProjectFinanceTask { id: string; name: string; - status_id: string; - priority_id: string; - phase_id: string; estimated_hours: number; - actual_hours: number; - completed_at: string | null; - created_at: string; - updated_at: string; - billable: boolean; - assignees: any[]; // Using any[] since we don't have the assignee structure yet + total_time_logged: number; + estimated_cost: number; members: IProjectFinanceMember[]; + billable: boolean; + fixed_cost?: number; + variance?: number; + total_budget?: number; + total_actual?: number; + cost?: number; } export interface IProjectFinanceGroup { diff --git a/worklenz-frontend/src/utils/format-hours-to-readable.ts b/worklenz-frontend/src/utils/format-hours-to-readable.ts new file mode 100644 index 00000000..11f53b7b --- /dev/null +++ b/worklenz-frontend/src/utils/format-hours-to-readable.ts @@ -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`; +};
+ <> + {/* header row */} +
setIsCollapse((prev) => !prev)} + > + + {isCollapse ? : } + {table.group_name} ({tasks.length}) + + + {renderFinancialTableHeaderContent(col.key)} +
+ + - {renderFinancialTableHeaderContent(col.key)} + {renderFinancialTableColumnContent(col.key, task)}
- - - {renderFinancialTableColumnContent(col.key, task)} -