diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index f0b2bbb5..fdc98789 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -8,6 +8,38 @@ import HandleExceptions from "../decorators/handle-exceptions"; import { TASK_STATUS_COLOR_ALPHA } from "../shared/constants"; import { getColor } from "../shared/utils"; +// Utility function to format time in hours, minutes, seconds format +const formatTimeToHMS = (totalSeconds: number): string => { + if (!totalSeconds || totalSeconds === 0) return "0s"; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(' '); +}; + +// Utility function to parse time string back to seconds for calculations +const parseTimeToSeconds = (timeString: string): number => { + if (!timeString || timeString === "0s") return 0; + + let totalSeconds = 0; + const hourMatch = timeString.match(/(\d+)h/); + const minuteMatch = timeString.match(/(\d+)m/); + const secondMatch = timeString.match(/(\d+)s/); + + if (hourMatch) totalSeconds += parseInt(hourMatch[1]) * 3600; + if (minuteMatch) totalSeconds += parseInt(minuteMatch[1]) * 60; + if (secondMatch) totalSeconds += parseInt(secondMatch[1]); + + return totalSeconds; +}; + export default class ProjectfinanceController extends WorklenzControllerBase { @HandleExceptions() public static async getTasks( @@ -40,8 +72,8 @@ export default class ProjectfinanceController extends WorklenzControllerBase { SELECT t.id, t.name, - COALESCE(t.total_minutes, 0) / 60.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(t.total_minutes * 60, 0) as estimated_seconds, + COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds, t.project_id, t.status_id, t.priority_id, @@ -57,7 +89,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { tc.*, -- Calculate estimated cost based on estimated hours and assignee rates from project_members COALESCE(( - SELECT SUM(tc.estimated_hours * COALESCE(fprr.rate, 0)) + SELECT SUM((tc.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)) FROM json_array_elements(tc.assignees) AS assignee_json LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid AND pm.project_id = tc.project_id @@ -198,8 +230,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase { 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_seconds: Number(task.estimated_seconds) || 0, + estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), + total_time_logged_seconds: Number(task.total_time_logged_seconds) || 0, + total_time_logged: formatTimeToHMS(Number(task.total_time_logged_seconds) || 0), estimated_cost: Number(task.estimated_cost) || 0, fixed_cost: Number(task.fixed_cost) || 0, total_budget: Number(task.total_budget) || 0, diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index 914aae76..1e2ede86 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -1,6 +1,7 @@ import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { IProjectFinanceGroup, IProjectFinanceTask, IProjectRateCard } from '@/types/project/project-finance.types'; +import { parseTimeToSeconds } from '@/utils/timeUtils'; type FinanceTabType = 'finance' | 'ratecard'; type GroupTypes = 'status' | 'priority' | 'phases'; @@ -14,11 +15,11 @@ interface ProjectFinanceState { } // Utility functions for frontend calculations -const minutesToHours = (minutes: number) => minutes / 60; +const secondsToHours = (seconds: number) => seconds / 3600; const calculateTaskCosts = (task: IProjectFinanceTask) => { - const hours = minutesToHours(task.estimated_hours || 0); - const timeLoggedHours = minutesToHours(task.total_time_logged || 0); + const hours = secondsToHours(task.estimated_seconds || 0); + const timeLoggedHours = secondsToHours(task.total_time_logged_seconds || 0); const fixedCost = task.fixed_cost || 0; // Calculate total budget (estimated hours * rate + fixed cost) @@ -127,13 +128,14 @@ export const projectFinancesSlice = createSlice({ } } }, - updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLogged: number }>) => { - const { taskId, groupId, timeLogged } = action.payload; + updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLoggedSeconds: number; timeLoggedString: string }>) => { + const { taskId, groupId, timeLoggedSeconds, timeLoggedString } = 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; + task.total_time_logged_seconds = timeLoggedSeconds; + task.total_time_logged = timeLoggedString; // Recalculate task costs after updating time logged const { totalBudget, totalActual, variance } = calculateTaskCosts(task); task.total_budget = totalBudget; 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 95af2101..d57a50aa 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 @@ -12,31 +12,10 @@ const FinanceTab = ({ taskGroups = [], loading }: FinanceTabProps) => { - // Transform taskGroups into the format expected by FinanceTableWrapper - 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 => ({ - id: task.id, - name: task.name, - hours: task.estimated_hours || 0, - cost: task.estimated_cost || 0, - fixedCost: task.fixed_cost || 0, - totalBudget: task.total_budget || 0, - totalActual: task.total_actual || 0, - variance: task.variance || 0, - members: task.members || [], - isbBillable: task.billable, - total_time_logged: task.total_time_logged || 0, - estimated_cost: task.estimated_cost || 0 - })) - })); return (
- +
); }; 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 dff26f54..62d4a38c 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 @@ -17,6 +17,22 @@ interface FinanceTableWrapperProps { loading: boolean; } +// Utility function to format seconds to time string +const formatSecondsToTimeString = (totalSeconds: number): string => { + if (!totalSeconds || totalSeconds === 0) return "0s"; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(' '); +}; + const FinanceTableWrapper: React.FC = ({ activeTablesList, loading }) => { const [isScrolling, setIsScrolling] = useState(false); const [editingFixedCost, setEditingFixedCost] = useState<{ taskId: string; groupId: string } | null>(null); @@ -80,13 +96,13 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL table: IProjectFinanceGroup ) => { table.tasks.forEach((task) => { - acc.hours += (task.estimated_hours / 60) || 0; + acc.hours += (task.estimated_seconds) || 0; acc.cost += task.estimated_cost || 0; acc.fixedCost += task.fixed_cost || 0; acc.totalBudget += task.total_budget || 0; acc.totalActual += task.total_actual || 0; acc.variance += task.variance || 0; - acc.total_time_logged += (task.total_time_logged / 60) || 0; + acc.total_time_logged += (task.total_time_logged_seconds) || 0; acc.estimated_cost += task.estimated_cost || 0; }); return acc; @@ -114,9 +130,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL case FinanceTableColumnKeys.HOURS: return ( - - {formatHoursToReadable(totals.hours).toFixed(2)} - + {formatSecondsToTimeString(totals.hours)} ); case FinanceTableColumnKeys.COST: @@ -131,7 +145,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL return ( 0 ? '#FF0000' : '#6DC376', fontSize: 18, }} > @@ -141,7 +155,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: return ( - {totals.total_time_logged?.toFixed(2)} + {formatSecondsToTimeString(totals.total_time_logged)} ); case FinanceTableColumnKeys.ESTIMATED_COST: 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 b882c55a..5c632820 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 @@ -13,6 +13,9 @@ import Avatars from '@/components/avatars/avatars'; import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; import { updateTaskFixedCostAsync, updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice'; import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice'; +import { useParams } from 'react-router-dom'; +import { parseTimeToSeconds } from '@/utils/timeUtils'; import './finance-table.css'; type FinanceTableProps = { @@ -78,19 +81,19 @@ const FinanceTable = ({ const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => { switch (columnKey) { case FinanceTableColumnKeys.HOURS: - return {formatNumber(totals.hours)}; + return {formattedTotals.hours}; case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: - return {formatNumber(totals.total_time_logged)}; + return {formattedTotals.total_time_logged}; case FinanceTableColumnKeys.ESTIMATED_COST: - return {formatNumber(totals.estimated_cost)}; + return {formatNumber(formattedTotals.estimated_cost)}; case FinanceTableColumnKeys.FIXED_COST: - return {formatNumber(totals.fixed_cost)}; + return {formatNumber(formattedTotals.fixed_cost)}; case FinanceTableColumnKeys.TOTAL_BUDGET: - return {formatNumber(totals.total_budget)}; + return {formatNumber(formattedTotals.total_budget)}; case FinanceTableColumnKeys.TOTAL_ACTUAL: - return {formatNumber(totals.total_actual)}; + return {formatNumber(formattedTotals.total_actual)}; case FinanceTableColumnKeys.VARIANCE: - return {formatNumber(totals.variance)}; + return 0 ? '#FF0000' : '#6DC376' }}>{formatNumber(formattedTotals.variance)}; default: return null; } @@ -106,6 +109,16 @@ const FinanceTable = ({ dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })); }; + const { projectId } = useParams<{ projectId: string }>(); + + const handleTaskNameClick = (taskId: string) => { + if (!taskId || !projectId) return; + + dispatch(setSelectedTaskId(taskId)); + dispatch(setShowTaskDrawer(true)); + dispatch(fetchTask({ taskId, projectId })); + }; + const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => { switch (columnKey) { case FinanceTableColumnKeys.TASK: @@ -114,7 +127,21 @@ const FinanceTable = ({ { + e.stopPropagation(); + handleTaskNameClick(task.id); + }} + onMouseEnter={(e) => { + e.currentTarget.style.textDecoration = 'underline'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.textDecoration = 'none'; + }} > {task.name} @@ -144,9 +171,9 @@ const FinanceTable = ({ ); case FinanceTableColumnKeys.HOURS: - return {formatNumber(task.estimated_hours / 60)}; + return {task.estimated_hours}; case FinanceTableColumnKeys.TOTAL_TIME_LOGGED: - return {formatNumber(task.total_time_logged / 60)}; + return {task.total_time_logged}; case FinanceTableColumnKeys.ESTIMATED_COST: return {formatNumber(task.estimated_cost)}; case FinanceTableColumnKeys.FIXED_COST: @@ -181,7 +208,15 @@ const FinanceTable = ({ ); case FinanceTableColumnKeys.VARIANCE: - return {formatNumber(task.variance)}; + return ( + 0 ? '#FF0000' : '#6DC376' + }} + > + {formatNumber(formattedTotals.variance)} + + ); case FinanceTableColumnKeys.TOTAL_BUDGET: return {formatNumber(task.total_budget)}; case FinanceTableColumnKeys.TOTAL_ACTUAL: @@ -193,12 +228,28 @@ const FinanceTable = ({ } }; + // Utility function to format seconds to time string + const formatSecondsToTimeString = (totalSeconds: number): string => { + if (!totalSeconds || totalSeconds === 0) return "0s"; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(' '); + }; + // 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), + hours: acc.hours + (task.estimated_seconds || 0), + total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), fixed_cost: acc.fixed_cost + (task.fixed_cost || 0), total_budget: acc.total_budget + (task.total_budget || 0), @@ -217,6 +268,17 @@ const FinanceTable = ({ ); }, [tasks]); + // Format the totals for display + const formattedTotals = useMemo(() => ({ + hours: formatSecondsToTimeString(totals.hours), + total_time_logged: formatSecondsToTimeString(totals.total_time_logged), + estimated_cost: totals.estimated_cost, + fixed_cost: totals.fixed_cost, + total_budget: totals.total_budget, + total_actual: totals.total_actual, + variance: totals.variance + }), [totals]); + return ( <> diff --git a/worklenz-frontend/src/types/project/project-finance.types.ts b/worklenz-frontend/src/types/project/project-finance.types.ts index 125a573f..f52a265a 100644 --- a/worklenz-frontend/src/types/project/project-finance.types.ts +++ b/worklenz-frontend/src/types/project/project-finance.types.ts @@ -29,8 +29,10 @@ export interface IProjectFinanceMember { export interface IProjectFinanceTask { id: string; name: string; - estimated_hours: number; - total_time_logged: number; + estimated_seconds: number; + estimated_hours: string; // Formatted time string like "4h 30m 12s" + total_time_logged_seconds: number; + total_time_logged: string; // Formatted time string like "4h 30m 12s" estimated_cost: number; members: IProjectFinanceMember[]; billable: boolean; diff --git a/worklenz-frontend/src/utils/timeUtils.ts b/worklenz-frontend/src/utils/timeUtils.ts index 6f2f6dbe..168a3893 100644 --- a/worklenz-frontend/src/utils/timeUtils.ts +++ b/worklenz-frontend/src/utils/timeUtils.ts @@ -4,6 +4,21 @@ export function formatDate(date: Date): string { return dayjs(date).format('MMM DD, YYYY'); } +export function parseTimeToSeconds(timeString: string): number { + if (!timeString || timeString === "0s") return 0; + + let totalSeconds = 0; + const hourMatch = timeString.match(/(\d+)h/); + const minuteMatch = timeString.match(/(\d+)m/); + const secondMatch = timeString.match(/(\d+)s/); + + if (hourMatch) totalSeconds += parseInt(hourMatch[1]) * 3600; + if (minuteMatch) totalSeconds += parseInt(minuteMatch[1]) * 60; + if (secondMatch) totalSeconds += parseInt(secondMatch[1]); + + return totalSeconds; +} + export function buildTimeString(hours: number, minutes: number, seconds: number) { const h = hours > 0 ? `${hours}h` : ''; const m = `${minutes}m`;