Merge branch 'Worklenz:feature/project-finance' into feature/project-finance

This commit is contained in:
Tharindu Kosgahakumbura
2025-05-27 08:51:31 +05:30
committed by GitHub
33 changed files with 1915 additions and 735 deletions

View File

@@ -1,4 +1,3 @@
import React from 'react';
import FinanceTableWrapper from './finance-table/finance-table-wrapper';
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
@@ -10,26 +9,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,
variance: 0, // TODO: Calculate variance
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
isbBillable: task.billable,
total_time_logged: task.total_time_logged || 0,
estimated_cost: task.estimated_cost || 0
}))
}));

View File

@@ -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<Props> = ({ activeTablesList, loading
<h3>{table.group_name}</h3>
</div>
<FinanceTable
table={table as unknown as IFinanceTable}
table={table}
loading={loading}
/>
</Card>

View File

@@ -1,49 +1,34 @@
import React, { useEffect, useState } from 'react';
import { Checkbox, Flex, Typography } from 'antd';
import { themeWiseColor } from '../../../../../../utils/themeWiseColor';
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
import React, { useEffect, useState, useMemo } from 'react';
import { Flex, InputNumber, Tooltip, Typography, Empty } from 'antd';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '../../../../../../hooks/useAppDispatch';
import { toggleFinanceDrawer } from '@/features/finance/finance-slice';
import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { openFinanceDrawer } from '@/features/finance/finance-slice';
import { financeTableColumns, FinanceTableColumnKeys } 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';
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
import { updateTaskFixedCostAsync } from '@/features/projects/finance/project-finance.slice';
interface FinanceTableWrapperProps {
activeTablesList: {
id: string;
name: string;
color_code: string;
color_code_dark: string;
tasks: {
taskId: string;
task: string;
hours: number;
cost: number;
fixedCost: number;
totalBudget: number;
totalActual: number;
variance: number;
members: any[];
isbBillable: boolean;
}[];
}[];
activeTablesList: IProjectFinanceGroup[];
loading: boolean;
}
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
activeTablesList,
loading
}) => {
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesList, loading }) => {
const [isScrolling, setIsScrolling] = useState(false);
const [selectedTask, setSelectedTask] = useState(null);
const [editingFixedCost, setEditingFixedCost] = useState<{ taskId: string; groupId: string } | null>(null);
const { t } = useTranslation('project-view-finance');
const dispatch = useAppDispatch();
// Get selected task from Redux store
const selectedTask = useAppSelector(state => state.financeReducer.selectedTask);
const onTaskClick = (task: any) => {
setSelectedTask(task);
dispatch(toggleFinanceDrawer());
dispatch(openFinanceDrawer(task));
};
useEffect(() => {
@@ -60,74 +45,89 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
};
}, []);
const themeMode = useAppSelector((state) => state.themeReducer.mode);
const { currency } = useAppSelector((state) => state.financeReducer);
// Handle click outside to close editing
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (editingFixedCost && !(event.target as Element)?.closest('.fixed-cost-input')) {
setEditingFixedCost(null);
}
};
const totals = activeTablesList.reduce(
(
acc: {
hours: number;
cost: number;
fixedCost: number;
totalBudget: number;
totalActual: number;
variance: number;
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [editingFixedCost]);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { currency } = useAppSelector(state => state.financeReducer);
const taskGroups = useAppSelector(state => state.projectFinances.taskGroups);
// Use Redux store data for totals calculation to ensure reactivity
const totals = useMemo(() => {
return taskGroups.reduce(
(
acc: {
hours: number;
cost: number;
fixedCost: number;
totalBudget: number;
totalActual: number;
variance: number;
total_time_logged: number;
estimated_cost: number;
},
table: IProjectFinanceGroup
) => {
table.tasks.forEach((task) => {
acc.hours += (task.estimated_hours / 60) || 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.estimated_cost += task.estimated_cost || 0;
});
return acc;
},
table: { tasks: any[] }
) => {
table.tasks.forEach((task: any) => {
acc.hours += task.hours || 0;
acc.cost += task.cost || 0;
acc.fixedCost += task.fixedCost || 0;
acc.totalBudget += task.totalBudget || 0;
acc.totalActual += task.totalActual || 0;
acc.variance += task.variance || 0;
});
return acc;
},
{
hours: 0,
cost: 0,
fixedCost: 0,
totalBudget: 0,
totalActual: 0,
variance: 0,
}
);
{
hours: 0,
cost: 0,
fixedCost: 0,
totalBudget: 0,
totalActual: 0,
variance: 0,
total_time_logged: 0,
estimated_cost: 0,
}
);
}, [taskGroups]);
const renderFinancialTableHeaderContent = (columnKey: any) => {
const handleFixedCostChange = (value: number | null, taskId: string, groupId: string) => {
dispatch(updateTaskFixedCostAsync({ taskId, groupId, fixedCost: value || 0 }));
setEditingFixedCost(null);
};
const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
switch (columnKey) {
case 'hours':
case FinanceTableColumnKeys.HOURS:
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.hours}
<Tooltip title={convertToHoursMinutes(totals.hours)}>
{formatHoursToReadable(totals.hours).toFixed(2)}
</Tooltip>
</Typography.Text>
);
case 'cost':
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.cost}
</Typography.Text>
);
case 'fixedCost':
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.fixedCost}
</Typography.Text>
);
case 'totalBudget':
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.totalBudget}
</Typography.Text>
);
case 'totalActual':
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.totalActual}
</Typography.Text>
);
case 'variance':
case FinanceTableColumnKeys.COST:
return <Typography.Text style={{ fontSize: 18 }}>{`${totals.cost?.toFixed(2)}`}</Typography.Text>;
case FinanceTableColumnKeys.FIXED_COST:
return <Typography.Text style={{ fontSize: 18 }}>{totals.fixedCost?.toFixed(2)}</Typography.Text>;
case FinanceTableColumnKeys.TOTAL_BUDGET:
return <Typography.Text style={{ fontSize: 18 }}>{totals.totalBudget?.toFixed(2)}</Typography.Text>;
case FinanceTableColumnKeys.TOTAL_ACTUAL:
return <Typography.Text style={{ fontSize: 18 }}>{totals.totalActual?.toFixed(2)}</Typography.Text>;
case FinanceTableColumnKeys.VARIANCE:
return (
<Typography.Text
style={{
@@ -135,7 +135,19 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
fontSize: 18,
}}
>
{totals.variance}
{`${totals.variance?.toFixed(2)}`}
</Typography.Text>
);
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.total_time_logged?.toFixed(2)}
</Typography.Text>
);
case FinanceTableColumnKeys.ESTIMATED_COST:
return (
<Typography.Text style={{ fontSize: 18 }}>
{`${totals.estimated_cost?.toFixed(2)}`}
</Typography.Text>
);
default:
@@ -143,116 +155,103 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
}
};
const customColumnHeaderStyles = (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-[68px] 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]'}`;
const customColumnHeaderStyles = (key: FinanceTableColumnKeys) =>
`px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] 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]'}`;
const customColumnStyles = (key: string) =>
`px-2 text-left ${key === 'totalRow' && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] 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: FinanceTableColumnKeys) =>
`px-2 text-left ${key === FinanceTableColumnKeys.TASK && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[56px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[56px] 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-[#141414]' : 'bg-[#fbfbfb]'}`;
// Check if there are any tasks across all groups
const hasAnyTasks = activeTablesList.some(table => table.tasks && table.tasks.length > 0);
return (
<>
<Flex
vertical
className="tasklist-container min-h-0 max-w-full overflow-x-auto"
>
<Flex vertical className="tasklist-container min-h-0 max-w-full overflow-x-auto">
<table>
<tbody>
<tr
style={{
height: 56,
fontWeight: 600,
backgroundColor: themeWiseColor(
'#fafafa',
'#1d1d1d',
themeMode
),
backgroundColor: themeWiseColor('#fafafa', '#1d1d1d', themeMode),
borderBlockEnd: `2px solid rgb(0 0 0 / 0.05)`,
}}
>
<td
style={{ width: 32, paddingInline: 16 }}
className={customColumnHeaderStyles('selector')}
>
<Checkbox />
</td>
{financeTableColumns.map((col) => (
{financeTableColumns.map(col => (
<td
key={col.key}
style={{
minWidth: col.width,
paddingInline: 16,
textAlign:
col.type === 'hours' || col.type === 'currency'
? 'center'
: 'start',
textAlign: col.type === 'hours' || col.type === 'currency' ? 'center' : 'start',
}}
className={`${customColumnHeaderStyles(col.key)} before:constent relative before:absolute before:left-0 before:top-1/2 before:h-[36px] before:w-0.5 before:-translate-y-1/2 ${themeMode === 'dark' ? 'before:bg-white/10' : 'before:bg-black/5'}`}
>
<Typography.Text>
{t(`${col.name}Column`)}{' '}
{col.type === 'currency' && `(${currency.toUpperCase()})`}
{t(`${col.name}`)} {col.type === 'currency' && `(${currency.toUpperCase()})`}
</Typography.Text>
</td>
))}
</tr>
<tr
style={{
height: 56,
fontWeight: 500,
backgroundColor: themeWiseColor(
'#fbfbfb',
'#141414',
themeMode
),
}}
>
<td
colSpan={3}
{hasAnyTasks && (
<tr
style={{
paddingInline: 16,
backgroundColor: themeWiseColor(
'#fbfbfb',
'#141414',
themeMode
),
height: 56,
fontWeight: 500,
backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode),
}}
className={customColumnStyles('totalRow')}
>
<Typography.Text style={{ fontSize: 18 }}>
{t('totalText')}
</Typography.Text>
</td>
{financeTableColumns.map(
(col) =>
(col.type === 'hours' || col.type === 'currency') && (
<td
key={col.key}
style={{
minWidth: col.width,
paddingInline: 16,
textAlign: 'end',
}}
>
{renderFinancialTableHeaderContent(col.key)}
</td>
)
)}
</tr>
{financeTableColumns.map((col, index) => (
<td
key={col.key}
style={{
minWidth: col.width,
paddingInline: 16,
textAlign: col.key === FinanceTableColumnKeys.TASK ? 'left' : 'right',
backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode),
}}
className={customColumnStyles(col.key)}
>
{col.key === FinanceTableColumnKeys.TASK ? (
<Typography.Text style={{ fontSize: 18 }}>{t('totalText')}</Typography.Text>
) : col.key === FinanceTableColumnKeys.MEMBERS ? null : (
(col.type === 'hours' || col.type === 'currency') && renderFinancialTableHeaderContent(col.key)
)}
</td>
))}
</tr>
)}
{activeTablesList.map((table, index) => (
<FinanceTable
key={index}
table={table}
isScrolling={isScrolling}
onTaskClick={onTaskClick}
/>
))}
{hasAnyTasks ? (
activeTablesList.map((table) => (
<FinanceTable
key={table.group_id}
table={table}
isScrolling={isScrolling}
onTaskClick={onTaskClick}
loading={loading}
/>
))
) : (
<tr>
<td colSpan={financeTableColumns.length} style={{ padding: '40px 0', textAlign: 'center' }}>
<Empty
description={
<Typography.Text type="secondary">
{t('noTasksFound')}
</Typography.Text>
}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</td>
</tr>
)}
</tbody>
</table>
</Flex>
{selectedTask && <FinanceDrawer task={selectedTask} />}
<FinanceDrawer />
</>
);
};

View File

@@ -0,0 +1 @@
/* Finance Table Styles */

View File

@@ -1,283 +1,307 @@
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 { financeTableColumns } from '@/lib/project/project-view-finance-table-columns';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { colors } from '@/styles/colors';
import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns';
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 './finance-table.css';
type FinanceTableProps = {
table: any;
table: IProjectFinanceGroup;
loading: boolean;
isScrolling: boolean;
onTaskClick: (task: any) => void;
};
const FinanceTable = ({
table,
loading,
isScrolling,
onTaskClick,
}: FinanceTableProps) => {
const [isCollapse, setIsCollapse] = useState<boolean>(false);
const [selectedTask, setSelectedTask] = useState(null);
const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
const [tasks, setTasks] = useState<IProjectFinanceTask[]>(table.tasks);
const dispatch = useAppDispatch();
// Get the latest task groups from Redux store
const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups);
// Update local state when table.tasks or Redux store changes
useEffect(() => {
const updatedGroup = taskGroups.find(g => g.group_id === table.group_id);
if (updatedGroup) {
setTasks(updatedGroup.tasks);
} else {
setTasks(table.tasks);
}
}, [table.tasks, taskGroups, table.group_id]);
// Handle click outside to close editing
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (selectedTask && !(event.target as Element)?.closest('.fixed-cost-input')) {
setSelectedTask(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [selectedTask]);
// 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]);
// Custom column styles for sticky positioning
const customColumnStyles = (key: FinanceTableColumnKeys) =>
`px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] 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' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-white'}`;
const renderFinancialTableHeaderContent = (columnKey: any) => {
const customHeaderColumnStyles = (key: FinanceTableColumnKeys) =>
`px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] 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 renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
switch (columnKey) {
case 'hours':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.hours}
</Typography.Text>
);
case 'cost':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.cost}
</Typography.Text>
);
case 'fixedCost':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.fixedCost}
</Typography.Text>
);
case 'totalBudget':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.totalBudget}
</Typography.Text>
);
case 'totalActual':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.totalActual}
</Typography.Text>
);
case 'variance':
return (
<Typography.Text
style={{
color:
totals.variance < 0
? '#FF0000'
: themeWiseColor('#6DC376', colors.darkGray, themeMode),
}}
>
{totals.variance}
</Typography.Text>
);
case FinanceTableColumnKeys.HOURS:
return <Typography.Text>{formatNumber(totals.hours)}</Typography.Text>;
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
return <Typography.Text>{formatNumber(totals.total_time_logged)}</Typography.Text>;
case FinanceTableColumnKeys.ESTIMATED_COST:
return <Typography.Text>{formatNumber(totals.estimated_cost)}</Typography.Text>;
case FinanceTableColumnKeys.FIXED_COST:
return <Typography.Text>{formatNumber(totals.fixed_cost)}</Typography.Text>;
case FinanceTableColumnKeys.TOTAL_BUDGET:
return <Typography.Text>{formatNumber(totals.total_budget)}</Typography.Text>;
case FinanceTableColumnKeys.TOTAL_ACTUAL:
return <Typography.Text>{formatNumber(totals.total_actual)}</Typography.Text>;
case FinanceTableColumnKeys.VARIANCE:
return <Typography.Text>{formatNumber(totals.variance)}</Typography.Text>;
default:
return null;
}
};
const renderFinancialTableColumnContent = (columnKey: any, task: any) => {
const handleFixedCostChange = (value: number | null, taskId: string) => {
const fixedCost = value || 0;
// Optimistic update for immediate UI feedback
dispatch(updateTaskFixedCost({ taskId, groupId: table.group_id, fixedCost }));
// Then make the API call to persist the change
dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost }));
};
const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => {
switch (columnKey) {
case 'task':
case FinanceTableColumnKeys.TASK:
return (
<Tooltip title={task.task}>
<Tooltip title={task.name}>
<Flex gap={8} align="center">
<Typography.Text
ellipsis={{ expanded: false }}
style={{ maxWidth: 160 }}
>
{task.task}
{task.name}
</Typography.Text>
{task.isbBillable && <DollarCircleOutlined />}
{task.billable && <DollarCircleOutlined />}
</Flex>
</Tooltip>
);
case 'members':
return (
task?.assignees && <Avatars members={task.assignees} />
);
case 'hours':
return <Typography.Text>{task.hours}</Typography.Text>;
case 'cost':
return <Typography.Text>{task.cost}</Typography.Text>;
case 'fixedCost':
return (
<Input
value={task.fixedCost}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
textAlign: 'right',
padding: 0,
case FinanceTableColumnKeys.MEMBERS:
return task.members && (
<div
onClick={(e) => {
e.stopPropagation();
onTaskClick(task);
}}
/>
);
case 'totalBudget':
return (
<Input
value={task.totalBudget}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
textAlign: 'right',
padding: 0,
}}
/>
);
case 'totalActual':
return <Typography.Text>{task.totalActual}</Typography.Text>;
case 'variance':
return (
<Typography.Text
style={{
color: task.variance < 0 ? '#FF0000' : '#6DC376',
style={{
cursor: 'pointer',
width: '100%'
}}
>
{task.variance}
<Avatars
members={task.members.map(member => ({
...member,
avatar_url: member.avatar_url || undefined
}))}
allowClickThrough={true}
/>
</div>
);
case FinanceTableColumnKeys.HOURS:
return <Typography.Text>{formatNumber(task.estimated_hours / 60)}</Typography.Text>;
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
return <Typography.Text>{formatNumber(task.total_time_logged / 60)}</Typography.Text>;
case FinanceTableColumnKeys.ESTIMATED_COST:
return <Typography.Text>{formatNumber(task.estimated_cost)}</Typography.Text>;
case FinanceTableColumnKeys.FIXED_COST:
return selectedTask?.id === task.id ? (
<InputNumber
value={task.fixed_cost}
onBlur={(e) => {
handleFixedCostChange(Number(e.target.value), task.id);
setSelectedTask(null);
}}
onPressEnter={(e) => {
handleFixedCostChange(Number((e.target as HTMLInputElement).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}
className="fixed-cost-input"
/>
) : (
<Typography.Text
style={{ cursor: 'pointer', width: '100%', display: 'block' }}
onClick={(e) => {
e.stopPropagation();
setSelectedTask(task);
}}
>
{formatNumber(task.fixed_cost)}
</Typography.Text>
);
case FinanceTableColumnKeys.VARIANCE:
return <Typography.Text>{formatNumber(task.variance)}</Typography.Text>;
case FinanceTableColumnKeys.TOTAL_BUDGET:
return <Typography.Text>{formatNumber(task.total_budget)}</Typography.Text>;
case FinanceTableColumnKeys.TOTAL_ACTUAL:
return <Typography.Text>{formatNumber(task.total_actual)}</Typography.Text>;
case FinanceTableColumnKeys.COST:
return <Typography.Text>{formatNumber(task.estimated_cost || 0)}</Typography.Text>;
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),
fixed_cost: acc.fixed_cost + (task.fixed_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,
fixed_cost: 0,
total_budget: 0,
total_actual: 0,
variance: 0
}
);
}, [tasks]);
return (
<>
{/* header row */}
<tr
style={{
height: 40,
backgroundColor: themeWiseColor(
table.color_code,
table.color_code_dark,
themeMode
),
fontWeight: 600,
}}
className="group"
>
<td
colSpan={3}
<Skeleton active loading={loading}>
<>
{/* header row */}
<tr
style={{
width: 48,
textTransform: 'capitalize',
textAlign: 'left',
paddingInline: 16,
height: 40,
backgroundColor: themeWiseColor(
table.color_code,
table.color_code_dark,
themeMode
),
cursor: 'pointer',
fontWeight: 600,
}}
className={customColumnHeaderStyles('tableTitle')}
onClick={(e) => setIsCollapse((prev) => !prev)}
className={`group ${themeMode === 'dark' ? 'dark' : ''}`}
>
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
{isCollapse ? <RightOutlined /> : <DownOutlined />}
{table.name} ({table.tasks.length})
</Flex>
</td>
{financeTableColumns.map(
(col) =>
col.key !== 'task' &&
col.key !== 'members' && (
{financeTableColumns.map(
(col, index) => (
<td
key={col.key}
key={`header-${col.key}`}
style={{
width: col.width,
paddingInline: 16,
textAlign: 'end',
textAlign: col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS ? 'left' : 'right',
backgroundColor: themeWiseColor(
table.color_code,
table.color_code_dark,
themeMode
),
cursor: col.key === FinanceTableColumnKeys.TASK ? 'pointer' : 'default',
textTransform: col.key === FinanceTableColumnKeys.TASK ? 'capitalize' : 'none',
}}
className={customHeaderColumnStyles(col.key)}
onClick={col.key === FinanceTableColumnKeys.TASK ? () => setIsCollapse((prev) => !prev) : undefined}
>
{renderFinancialTableHeaderContent(col.key)}
{col.key === FinanceTableColumnKeys.TASK ? (
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
{isCollapse ? <RightOutlined /> : <DownOutlined />}
{table.group_name} ({tasks.length})
</Flex>
) : col.key === FinanceTableColumnKeys.MEMBERS ? null : renderFinancialTableHeaderContent(col.key)}
</td>
)
)}
</tr>
{/* task rows */}
{table.tasks.map((task: any) => (
<tr
key={task.taskId}
style={{ height: 52 }}
className={`${isCollapse ? 'hidden' : 'static'} cursor-pointer border-b-[1px] ${themeMode === 'dark' ? 'hover:bg-[#000000]' : 'hover:bg-[#f8f7f9]'} `}
onClick={() => onTaskClick(task)}
>
<td
style={{ paddingInline: 16 }}
className={customColumnStyles('selector')}
>
<Checkbox />
</td>
{financeTableColumns.map((col) => (
<td
key={col.key}
className={customColumnStyles(col.key)}
style={{
width: col.width,
paddingInline: 16,
textAlign:
col.type === 'hours' || col.type === 'currency'
? 'end'
: 'start',
}}
>
{renderFinancialTableColumnContent(col.key, task)}
</td>
))}
)}
</tr>
))}
</>
{/* task rows */}
{!isCollapse && tasks.map((task, idx) => (
<tr
key={task.id}
style={{
height: 40,
background: idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode),
transition: 'background 0.2s',
}}
className={themeMode === 'dark' ? 'dark' : ''}
onMouseEnter={e => e.currentTarget.style.background = themeWiseColor('#f0f0f0', '#333', themeMode)}
onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)}
>
{financeTableColumns.map((col) => (
<td
key={`${task.id}-${col.key}`}
style={{
width: col.width,
paddingInline: 16,
textAlign: col.type === 'string' ? 'left' : 'right',
backgroundColor: (col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS) ?
(idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)) :
'transparent',
cursor: 'default'
}}
className={customColumnStyles(col.key)}
onClick={
col.key === FinanceTableColumnKeys.FIXED_COST
? (e) => e.stopPropagation()
: undefined
}
>
{renderFinancialTableColumnContent(col.key, task)}
</td>
))}
</tr>
))}
</>
</Skeleton>
);
};

View File

@@ -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';

View File

@@ -1,61 +1,34 @@
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<FinanceTabType>('finance');
const [activeGroup, setActiveGroup] = useState<GroupTypes>('status');
const [loading, setLoading] = useState(false);
const [taskGroups, setTaskGroups] = useState<IProjectFinanceGroup[]>([]);
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);
const { refreshTimestamp } = useAppSelector((state: RootState) => state.projectReducer);
useEffect(() => {
fetchTasks();
}, [projectId, activeGroup]);
if (projectId) {
dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup }));
}
}, [projectId, activeGroup, dispatch, refreshTimestamp]);
return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
<ProjectViewFinanceHeader
activeTab={activeTab}
setActiveTab={setActiveTab}
setActiveTab={(tab) => dispatch(setActiveTab(tab))}
activeGroup={activeGroup}
setActiveGroup={setActiveGroup}
setActiveGroup={(group) => dispatch(setActiveGroup(group))}
/>
{activeTab === 'finance' ? (

View File

@@ -22,8 +22,18 @@ import { useAppSelector } from '@/hooks/useAppSelector';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth';
import { useSocket } from '@/socket/socketContext';
import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp, getProject } from '@features/project/project.slice';
import { addTask, fetchTaskGroups, fetchTaskListColumns, IGroupBy } from '@features/tasks/tasks.slice';
import {
setProject,
setImportTaskTemplateDrawerOpen,
setRefreshTimestamp,
getProject,
} from '@features/project/project.slice';
import {
addTask,
fetchTaskGroups,
fetchTaskListColumns,
IGroupBy,
} from '@features/tasks/tasks.slice';
import ProjectStatusIcon from '@/components/common/project-status-icon/project-status-icon';
import { formatDate } from '@/utils/timeUtils';
import { toggleSaveAsTemplateDrawer } from '@/features/projects/projectsSlice';
@@ -60,10 +70,7 @@ const ProjectViewHeader = () => {
const { socket } = useSocket();
const {
project: selectedProject,
projectId,
} = useAppSelector(state => state.projectReducer);
const { project: selectedProject, projectId } = useAppSelector(state => state.projectReducer);
const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer);
const [creatingTask, setCreatingTask] = useState(false);
@@ -74,7 +81,7 @@ const ProjectViewHeader = () => {
switch (tab) {
case 'tasks-list':
dispatch(fetchTaskListColumns(projectId));
dispatch(fetchPhasesByProjectId(projectId))
dispatch(fetchPhasesByProjectId(projectId));
dispatch(fetchTaskGroups(projectId));
break;
case 'board':
@@ -92,6 +99,9 @@ const ProjectViewHeader = () => {
case 'updates':
dispatch(setRefreshTimestamp());
break;
case 'finance':
dispatch(setRefreshTimestamp());
break;
default:
break;
}
@@ -222,7 +232,7 @@ const ProjectViewHeader = () => {
/>
</Tooltip>
{(isOwnerOrAdmin) && (
{isOwnerOrAdmin && (
<Tooltip title="Save as template">
<Button
shape="circle"
@@ -299,10 +309,9 @@ const ProjectViewHeader = () => {
style={{ paddingInline: 0, marginBlockEnd: 12 }}
extra={renderHeaderActions()}
/>
{createPortal(<ProjectDrawer onClose={() => { }} />, document.body, 'project-drawer')}
{createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
{createPortal(<ImportTaskTemplate />, document.body, 'import-task-template')}
{createPortal(<SaveProjectAsTemplate />, document.body, 'save-project-as-template')}
</>
);
};

View File

@@ -48,7 +48,16 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600;
return loggedTimeInHours.toFixed(2);
}) : [];
const colors = Array.isArray(jsonData) ? jsonData.map(item => item.color_code) : [];
const colors = Array.isArray(jsonData) ? jsonData.map(item => {
const overUnder = parseFloat(item.over_under_utilized_hours || '0');
if (overUnder > 0) {
return '#ef4444'; // Red for over-utilized
} else if (overUnder < 0) {
return '#22c55e'; // Green for under-utilized
} else {
return '#6b7280'; // Gray for exactly on target
}
}) : [];
const themeMode = useAppSelector(state => state.themeReducer.mode);