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