feat(project-finance): implement time formatting utilities and update task time handling
- Added utility functions to format time in hours, minutes, and seconds, and to parse time strings back to seconds. - Updated the project finance controller to use seconds for estimated time and total time logged, improving accuracy in calculations. - Modified frontend components to reflect changes in time handling, ensuring consistent display of time in both seconds and formatted strings. - Adjusted Redux slice and types to accommodate new time formats, enhancing data integrity across the application.
This commit is contained in:
@@ -8,6 +8,38 @@ import HandleExceptions from "../decorators/handle-exceptions";
|
|||||||
import { TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
|
import { TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
|
||||||
import { getColor } from "../shared/utils";
|
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 {
|
export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async getTasks(
|
public static async getTasks(
|
||||||
@@ -40,8 +72,8 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
SELECT
|
SELECT
|
||||||
t.id,
|
t.id,
|
||||||
t.name,
|
t.name,
|
||||||
COALESCE(t.total_minutes, 0) / 60.0::float as estimated_hours,
|
COALESCE(t.total_minutes * 60, 0) as estimated_seconds,
|
||||||
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(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds,
|
||||||
t.project_id,
|
t.project_id,
|
||||||
t.status_id,
|
t.status_id,
|
||||||
t.priority_id,
|
t.priority_id,
|
||||||
@@ -57,7 +89,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
tc.*,
|
tc.*,
|
||||||
-- Calculate estimated cost based on estimated hours and assignee rates from project_members
|
-- Calculate estimated cost based on estimated hours and assignee rates from project_members
|
||||||
COALESCE((
|
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
|
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
|
LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid
|
||||||
AND pm.project_id = tc.project_id
|
AND pm.project_id = tc.project_id
|
||||||
@@ -198,8 +230,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
tasks: groupTasks.map(task => ({
|
tasks: groupTasks.map(task => ({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
name: task.name,
|
name: task.name,
|
||||||
estimated_hours: Number(task.estimated_hours) || 0,
|
estimated_seconds: Number(task.estimated_seconds) || 0,
|
||||||
total_time_logged: Number(task.total_time_logged) || 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,
|
estimated_cost: Number(task.estimated_cost) || 0,
|
||||||
fixed_cost: Number(task.fixed_cost) || 0,
|
fixed_cost: Number(task.fixed_cost) || 0,
|
||||||
total_budget: Number(task.total_budget) || 0,
|
total_budget: Number(task.total_budget) || 0,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { IProjectFinanceGroup, IProjectFinanceTask, IProjectRateCard } from '@/types/project/project-finance.types';
|
import { IProjectFinanceGroup, IProjectFinanceTask, IProjectRateCard } from '@/types/project/project-finance.types';
|
||||||
|
import { parseTimeToSeconds } from '@/utils/timeUtils';
|
||||||
|
|
||||||
type FinanceTabType = 'finance' | 'ratecard';
|
type FinanceTabType = 'finance' | 'ratecard';
|
||||||
type GroupTypes = 'status' | 'priority' | 'phases';
|
type GroupTypes = 'status' | 'priority' | 'phases';
|
||||||
@@ -14,11 +15,11 @@ interface ProjectFinanceState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Utility functions for frontend calculations
|
// Utility functions for frontend calculations
|
||||||
const minutesToHours = (minutes: number) => minutes / 60;
|
const secondsToHours = (seconds: number) => seconds / 3600;
|
||||||
|
|
||||||
const calculateTaskCosts = (task: IProjectFinanceTask) => {
|
const calculateTaskCosts = (task: IProjectFinanceTask) => {
|
||||||
const hours = minutesToHours(task.estimated_hours || 0);
|
const hours = secondsToHours(task.estimated_seconds || 0);
|
||||||
const timeLoggedHours = minutesToHours(task.total_time_logged || 0);
|
const timeLoggedHours = secondsToHours(task.total_time_logged_seconds || 0);
|
||||||
const fixedCost = task.fixed_cost || 0;
|
const fixedCost = task.fixed_cost || 0;
|
||||||
|
|
||||||
// Calculate total budget (estimated hours * rate + fixed cost)
|
// 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 }>) => {
|
updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLoggedSeconds: number; timeLoggedString: string }>) => {
|
||||||
const { taskId, groupId, timeLogged } = action.payload;
|
const { taskId, groupId, timeLoggedSeconds, timeLoggedString } = action.payload;
|
||||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||||
if (group) {
|
if (group) {
|
||||||
const task = group.tasks.find(t => t.id === taskId);
|
const task = group.tasks.find(t => t.id === taskId);
|
||||||
if (task) {
|
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
|
// Recalculate task costs after updating time logged
|
||||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||||
task.total_budget = totalBudget;
|
task.total_budget = totalBudget;
|
||||||
|
|||||||
@@ -12,31 +12,10 @@ const FinanceTab = ({
|
|||||||
taskGroups = [],
|
taskGroups = [],
|
||||||
loading
|
loading
|
||||||
}: FinanceTabProps) => {
|
}: 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<FinanceTableWrapper activeTablesList={activeTablesList} loading={loading} />
|
<FinanceTableWrapper activeTablesList={taskGroups} loading={loading} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,22 @@ interface FinanceTableWrapperProps {
|
|||||||
loading: boolean;
|
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<FinanceTableWrapperProps> = ({ activeTablesList, loading }) => {
|
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesList, loading }) => {
|
||||||
const [isScrolling, setIsScrolling] = useState(false);
|
const [isScrolling, setIsScrolling] = useState(false);
|
||||||
const [editingFixedCost, setEditingFixedCost] = useState<{ taskId: string; groupId: string } | null>(null);
|
const [editingFixedCost, setEditingFixedCost] = useState<{ taskId: string; groupId: string } | null>(null);
|
||||||
@@ -80,13 +96,13 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesL
|
|||||||
table: IProjectFinanceGroup
|
table: IProjectFinanceGroup
|
||||||
) => {
|
) => {
|
||||||
table.tasks.forEach((task) => {
|
table.tasks.forEach((task) => {
|
||||||
acc.hours += (task.estimated_hours / 60) || 0;
|
acc.hours += (task.estimated_seconds) || 0;
|
||||||
acc.cost += task.estimated_cost || 0;
|
acc.cost += task.estimated_cost || 0;
|
||||||
acc.fixedCost += task.fixed_cost || 0;
|
acc.fixedCost += task.fixed_cost || 0;
|
||||||
acc.totalBudget += task.total_budget || 0;
|
acc.totalBudget += task.total_budget || 0;
|
||||||
acc.totalActual += task.total_actual || 0;
|
acc.totalActual += task.total_actual || 0;
|
||||||
acc.variance += task.variance || 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;
|
acc.estimated_cost += task.estimated_cost || 0;
|
||||||
});
|
});
|
||||||
return acc;
|
return acc;
|
||||||
@@ -114,9 +130,7 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesL
|
|||||||
case FinanceTableColumnKeys.HOURS:
|
case FinanceTableColumnKeys.HOURS:
|
||||||
return (
|
return (
|
||||||
<Typography.Text style={{ fontSize: 18 }}>
|
<Typography.Text style={{ fontSize: 18 }}>
|
||||||
<Tooltip title={convertToHoursMinutes(totals.hours)}>
|
{formatSecondsToTimeString(totals.hours)}
|
||||||
{formatHoursToReadable(totals.hours).toFixed(2)}
|
|
||||||
</Tooltip>
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
);
|
);
|
||||||
case FinanceTableColumnKeys.COST:
|
case FinanceTableColumnKeys.COST:
|
||||||
@@ -131,7 +145,7 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesL
|
|||||||
return (
|
return (
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
style={{
|
style={{
|
||||||
color: totals.variance < 0 ? '#FF0000' : '#6DC376',
|
color: totals.variance > 0 ? '#FF0000' : '#6DC376',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -141,7 +155,7 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesL
|
|||||||
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||||
return (
|
return (
|
||||||
<Typography.Text style={{ fontSize: 18 }}>
|
<Typography.Text style={{ fontSize: 18 }}>
|
||||||
{totals.total_time_logged?.toFixed(2)}
|
{formatSecondsToTimeString(totals.total_time_logged)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
);
|
);
|
||||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import Avatars from '@/components/avatars/avatars';
|
|||||||
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
|
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
|
||||||
import { updateTaskFixedCostAsync, updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice';
|
import { updateTaskFixedCostAsync, updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
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';
|
import './finance-table.css';
|
||||||
|
|
||||||
type FinanceTableProps = {
|
type FinanceTableProps = {
|
||||||
@@ -78,19 +81,19 @@ const FinanceTable = ({
|
|||||||
const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
|
const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
|
||||||
switch (columnKey) {
|
switch (columnKey) {
|
||||||
case FinanceTableColumnKeys.HOURS:
|
case FinanceTableColumnKeys.HOURS:
|
||||||
return <Typography.Text>{formatNumber(totals.hours)}</Typography.Text>;
|
return <Typography.Text>{formattedTotals.hours}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||||
return <Typography.Text>{formatNumber(totals.total_time_logged)}</Typography.Text>;
|
return <Typography.Text>{formattedTotals.total_time_logged}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||||
return <Typography.Text>{formatNumber(totals.estimated_cost)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(formattedTotals.estimated_cost)}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.FIXED_COST:
|
case FinanceTableColumnKeys.FIXED_COST:
|
||||||
return <Typography.Text>{formatNumber(totals.fixed_cost)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(formattedTotals.fixed_cost)}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||||
return <Typography.Text>{formatNumber(totals.total_budget)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(formattedTotals.total_budget)}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
||||||
return <Typography.Text>{formatNumber(totals.total_actual)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(formattedTotals.total_actual)}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.VARIANCE:
|
case FinanceTableColumnKeys.VARIANCE:
|
||||||
return <Typography.Text>{formatNumber(totals.variance)}</Typography.Text>;
|
return <Typography.Text style={{ color: formattedTotals.variance > 0 ? '#FF0000' : '#6DC376' }}>{formatNumber(formattedTotals.variance)}</Typography.Text>;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -106,6 +109,16 @@ const FinanceTable = ({
|
|||||||
dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost }));
|
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) => {
|
const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => {
|
||||||
switch (columnKey) {
|
switch (columnKey) {
|
||||||
case FinanceTableColumnKeys.TASK:
|
case FinanceTableColumnKeys.TASK:
|
||||||
@@ -114,7 +127,21 @@ const FinanceTable = ({
|
|||||||
<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,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#1890ff'
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleTaskNameClick(task.id);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.textDecoration = 'underline';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.textDecoration = 'none';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{task.name}
|
{task.name}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@@ -144,9 +171,9 @@ const FinanceTable = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case FinanceTableColumnKeys.HOURS:
|
case FinanceTableColumnKeys.HOURS:
|
||||||
return <Typography.Text>{formatNumber(task.estimated_hours / 60)}</Typography.Text>;
|
return <Typography.Text>{task.estimated_hours}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||||
return <Typography.Text>{formatNumber(task.total_time_logged / 60)}</Typography.Text>;
|
return <Typography.Text>{task.total_time_logged}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||||
return <Typography.Text>{formatNumber(task.estimated_cost)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(task.estimated_cost)}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.FIXED_COST:
|
case FinanceTableColumnKeys.FIXED_COST:
|
||||||
@@ -181,7 +208,15 @@ const FinanceTable = ({
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
);
|
);
|
||||||
case FinanceTableColumnKeys.VARIANCE:
|
case FinanceTableColumnKeys.VARIANCE:
|
||||||
return <Typography.Text>{formatNumber(task.variance)}</Typography.Text>;
|
return (
|
||||||
|
<Typography.Text
|
||||||
|
style={{
|
||||||
|
color: formattedTotals.variance > 0 ? '#FF0000' : '#6DC376'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatNumber(formattedTotals.variance)}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||||
return <Typography.Text>{formatNumber(task.total_budget)}</Typography.Text>;
|
return <Typography.Text>{formatNumber(task.total_budget)}</Typography.Text>;
|
||||||
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
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
|
// Calculate totals for the current table
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
return tasks.reduce(
|
return tasks.reduce(
|
||||||
(acc, task) => ({
|
(acc, task) => ({
|
||||||
hours: acc.hours + (task.estimated_hours / 60),
|
hours: acc.hours + (task.estimated_seconds || 0),
|
||||||
total_time_logged: acc.total_time_logged + (task.total_time_logged / 60),
|
total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0),
|
||||||
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0),
|
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0),
|
||||||
fixed_cost: acc.fixed_cost + (task.fixed_cost || 0),
|
fixed_cost: acc.fixed_cost + (task.fixed_cost || 0),
|
||||||
total_budget: acc.total_budget + (task.total_budget || 0),
|
total_budget: acc.total_budget + (task.total_budget || 0),
|
||||||
@@ -217,6 +268,17 @@ const FinanceTable = ({
|
|||||||
);
|
);
|
||||||
}, [tasks]);
|
}, [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 (
|
return (
|
||||||
<Skeleton active loading={loading}>
|
<Skeleton active loading={loading}>
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ export interface IProjectFinanceMember {
|
|||||||
export interface IProjectFinanceTask {
|
export interface IProjectFinanceTask {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
estimated_hours: number;
|
estimated_seconds: number;
|
||||||
total_time_logged: 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;
|
estimated_cost: number;
|
||||||
members: IProjectFinanceMember[];
|
members: IProjectFinanceMember[];
|
||||||
billable: boolean;
|
billable: boolean;
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ export function formatDate(date: Date): string {
|
|||||||
return dayjs(date).format('MMM DD, YYYY');
|
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) {
|
export function buildTimeString(hours: number, minutes: number, seconds: number) {
|
||||||
const h = hours > 0 ? `${hours}h` : '';
|
const h = hours > 0 ? `${hours}h` : '';
|
||||||
const m = `${minutes}m`;
|
const m = `${minutes}m`;
|
||||||
|
|||||||
Reference in New Issue
Block a user