feat(task-breakdown-api): implement task financial breakdown API and related enhancements
- Added a new API endpoint `GET /api/project-finance/task/:id/breakdown` to retrieve detailed financial breakdown for individual tasks, including labor hours and costs grouped by job roles. - Introduced a new SQL migration to add a `fixed_cost` column to the tasks table for improved financial calculations. - Updated the project finance controller to handle task breakdown logic, including calculations for estimated and actual costs. - Enhanced frontend components to integrate the new task breakdown API, providing real-time financial data in the finance drawer. - Updated localization files to reflect changes in financial terminology across English, Spanish, and Portuguese. - Implemented Redux state management for selected tasks in the finance drawer.
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import FinanceTableWrapper from './finance-table/finance-table-wrapper';
|
||||
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
|
||||
|
||||
@@ -23,11 +22,11 @@ const FinanceTab = ({
|
||||
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
|
||||
cost: task.estimated_cost || 0,
|
||||
fixedCost: task.fixed_cost || 0,
|
||||
totalBudget: task.total_budget || 0,
|
||||
totalActual: task.total_actual || 0,
|
||||
variance: 0, // TODO: Calculate variance
|
||||
variance: task.variance || 0,
|
||||
members: task.members || [],
|
||||
isbBillable: task.billable,
|
||||
total_time_logged: task.total_time_logged || 0,
|
||||
|
||||
@@ -1,52 +1,34 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Checkbox, Flex, Tooltip, 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 } 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;
|
||||
total_time_logged: number;
|
||||
estimated_cost: number;
|
||||
}[];
|
||||
}[];
|
||||
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(() => {
|
||||
@@ -63,84 +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;
|
||||
total_time_logged: number;
|
||||
estimated_cost: 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;
|
||||
acc.total_time_logged += task.total_time_logged || 0;
|
||||
acc.estimated_cost += task.estimated_cost || 0;
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
hours: 0,
|
||||
cost: 0,
|
||||
fixedCost: 0,
|
||||
totalBudget: 0,
|
||||
totalActual: 0,
|
||||
variance: 0,
|
||||
total_time_logged: 0,
|
||||
estimated_cost: 0,
|
||||
}
|
||||
);
|
||||
{
|
||||
hours: 0,
|
||||
cost: 0,
|
||||
fixedCost: 0,
|
||||
totalBudget: 0,
|
||||
totalActual: 0,
|
||||
variance: 0,
|
||||
total_time_logged: 0,
|
||||
estimated_cost: 0,
|
||||
}
|
||||
);
|
||||
}, [taskGroups]);
|
||||
|
||||
console.log("totals", totals);
|
||||
const handleFixedCostChange = (value: number | null, taskId: string, groupId: string) => {
|
||||
dispatch(updateTaskFixedCostAsync({ taskId, groupId, fixedCost: value || 0 }));
|
||||
setEditingFixedCost(null);
|
||||
};
|
||||
|
||||
const renderFinancialTableHeaderContent = (columnKey: any) => {
|
||||
const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
|
||||
switch (columnKey) {
|
||||
case 'hours':
|
||||
case FinanceTableColumnKeys.HOURS:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
<Tooltip title={convertToHoursMinutes(totals.hours)}>
|
||||
{formatHoursToReadable(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={{
|
||||
@@ -148,19 +135,19 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
{totals.variance}
|
||||
{`${totals.variance?.toFixed(2)}`}
|
||||
</Typography.Text>
|
||||
);
|
||||
case 'total_time_logged':
|
||||
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{totals.total_time_logged?.toFixed(2)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case 'estimated_cost':
|
||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{`${currency.toUpperCase()} ${totals.estimated_cost?.toFixed(2)}`}
|
||||
{`${totals.estimated_cost?.toFixed(2)}`}
|
||||
</Typography.Text>
|
||||
);
|
||||
default:
|
||||
@@ -168,54 +155,37 @@ 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-[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-[#141414]' : 'bg-[#fbfbfb]'}`;
|
||||
|
||||
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}`)}{' '}
|
||||
{col.type === 'currency' && `(${currency.toUpperCase()})`}
|
||||
{t(`${col.name}`)} {col.type === 'currency' && `(${currency.toUpperCase()})`}
|
||||
</Typography.Text>
|
||||
</td>
|
||||
))}
|
||||
@@ -225,59 +195,43 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
|
||||
style={{
|
||||
height: 56,
|
||||
fontWeight: 500,
|
||||
backgroundColor: themeWiseColor(
|
||||
'#fbfbfb',
|
||||
'#141414',
|
||||
themeMode
|
||||
),
|
||||
backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode),
|
||||
}}
|
||||
>
|
||||
<td
|
||||
colSpan={3}
|
||||
style={{
|
||||
paddingInline: 16,
|
||||
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>
|
||||
)
|
||||
)}
|
||||
{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) => (
|
||||
{activeTablesList.map((table) => (
|
||||
<FinanceTable
|
||||
key={index}
|
||||
key={table.group_id}
|
||||
table={table}
|
||||
isScrolling={isScrolling}
|
||||
onTaskClick={onTaskClick}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Flex>
|
||||
|
||||
{selectedTask && <FinanceDrawer task={selectedTask} />}
|
||||
<FinanceDrawer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,20 +8,24 @@ import {
|
||||
} 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 { 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 { updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice';
|
||||
import { updateTaskFixedCostAsync, updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
|
||||
type FinanceTableProps = {
|
||||
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<IProjectFinanceTask | null>(null);
|
||||
@@ -41,6 +45,20 @@ const FinanceTable = ({
|
||||
}
|
||||
}, [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);
|
||||
|
||||
@@ -49,19 +67,28 @@ const FinanceTable = ({
|
||||
return value.toFixed(2);
|
||||
};
|
||||
|
||||
const renderFinancialTableHeaderContent = (columnKey: string) => {
|
||||
// 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 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':
|
||||
case FinanceTableColumnKeys.HOURS:
|
||||
return <Typography.Text>{formatNumber(totals.hours)}</Typography.Text>;
|
||||
case 'total_time_logged':
|
||||
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||
return <Typography.Text>{formatNumber(totals.total_time_logged)}</Typography.Text>;
|
||||
case 'estimated_cost':
|
||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||
return <Typography.Text>{formatNumber(totals.estimated_cost)}</Typography.Text>;
|
||||
case 'totalBudget':
|
||||
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 'totalActual':
|
||||
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
||||
return <Typography.Text>{formatNumber(totals.total_actual)}</Typography.Text>;
|
||||
case 'variance':
|
||||
case FinanceTableColumnKeys.VARIANCE:
|
||||
return <Typography.Text>{formatNumber(totals.variance)}</Typography.Text>;
|
||||
default:
|
||||
return null;
|
||||
@@ -69,12 +96,18 @@ const FinanceTable = ({
|
||||
};
|
||||
|
||||
const handleFixedCostChange = (value: number | null, taskId: string) => {
|
||||
dispatch(updateTaskFixedCost({ taskId, groupId: table.group_id, fixedCost: value || 0 }));
|
||||
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: string, task: IProjectFinanceTask) => {
|
||||
const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => {
|
||||
switch (columnKey) {
|
||||
case 'task':
|
||||
case FinanceTableColumnKeys.TASK:
|
||||
return (
|
||||
<Tooltip title={task.name}>
|
||||
<Flex gap={8} align="center">
|
||||
@@ -88,22 +121,43 @@ const FinanceTable = ({
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'members':
|
||||
case FinanceTableColumnKeys.MEMBERS:
|
||||
return task.members && (
|
||||
<Avatars
|
||||
members={task.members.map(member => ({
|
||||
...member,
|
||||
avatar_url: member.avatar_url || undefined
|
||||
}))}
|
||||
/>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskClick(task);
|
||||
}}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = themeWiseColor('#f0f0f0', '#333', themeMode);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<Avatars
|
||||
members={task.members.map(member => ({
|
||||
...member,
|
||||
avatar_url: member.avatar_url || undefined
|
||||
}))}
|
||||
allowClickThrough={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'hours':
|
||||
case FinanceTableColumnKeys.HOURS:
|
||||
return <Typography.Text>{formatNumber(task.estimated_hours / 60)}</Typography.Text>;
|
||||
case 'total_time_logged':
|
||||
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||
return <Typography.Text>{formatNumber(task.total_time_logged / 60)}</Typography.Text>;
|
||||
case 'estimated_cost':
|
||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||
return <Typography.Text>{formatNumber(task.estimated_cost)}</Typography.Text>;
|
||||
case 'fixedCost':
|
||||
case FinanceTableColumnKeys.FIXED_COST:
|
||||
return selectedTask?.id === task.id ? (
|
||||
<InputNumber
|
||||
value={task.fixed_cost}
|
||||
@@ -111,24 +165,37 @@ const FinanceTable = ({
|
||||
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>{formatNumber(task.fixed_cost)}</Typography.Text>
|
||||
<Typography.Text
|
||||
style={{ cursor: 'pointer', width: '100%', display: 'block' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedTask(task);
|
||||
}}
|
||||
>
|
||||
{formatNumber(task.fixed_cost)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case 'variance':
|
||||
case FinanceTableColumnKeys.VARIANCE:
|
||||
return <Typography.Text>{formatNumber(task.variance)}</Typography.Text>;
|
||||
case 'totalBudget':
|
||||
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||
return <Typography.Text>{formatNumber(task.total_budget)}</Typography.Text>;
|
||||
case 'totalActual':
|
||||
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
||||
return <Typography.Text>{formatNumber(task.total_actual)}</Typography.Text>;
|
||||
case 'cost':
|
||||
return <Typography.Text>{formatNumber(task.cost || 0)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.COST:
|
||||
return <Typography.Text>{formatNumber(task.estimated_cost || 0)}</Typography.Text>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -141,6 +208,7 @@ const FinanceTable = ({
|
||||
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)
|
||||
@@ -149,6 +217,7 @@ const FinanceTable = ({
|
||||
hours: 0,
|
||||
total_time_logged: 0,
|
||||
estimated_cost: 0,
|
||||
fixed_cost: 0,
|
||||
total_budget: 0,
|
||||
total_actual: 0,
|
||||
variance: 0
|
||||
@@ -172,43 +241,33 @@ const FinanceTable = ({
|
||||
}}
|
||||
className="group"
|
||||
>
|
||||
<td
|
||||
colSpan={3}
|
||||
style={{
|
||||
width: 48,
|
||||
textTransform: 'capitalize',
|
||||
textAlign: 'left',
|
||||
paddingInline: 16,
|
||||
backgroundColor: themeWiseColor(
|
||||
table.color_code,
|
||||
table.color_code_dark,
|
||||
themeMode
|
||||
),
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setIsCollapse((prev) => !prev)}
|
||||
>
|
||||
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
|
||||
{isCollapse ? <RightOutlined /> : <DownOutlined />}
|
||||
{table.group_name} ({tasks.length})
|
||||
</Flex>
|
||||
</td>
|
||||
|
||||
{financeTableColumns.map(
|
||||
(col) =>
|
||||
col.key !== 'task' &&
|
||||
col.key !== 'members' && (
|
||||
<td
|
||||
key={`header-${col.key}`}
|
||||
style={{
|
||||
width: col.width,
|
||||
paddingInline: 16,
|
||||
textAlign: 'end',
|
||||
}}
|
||||
>
|
||||
{renderFinancialTableHeaderContent(col.key)}
|
||||
</td>
|
||||
)
|
||||
(col, index) => (
|
||||
<td
|
||||
key={`header-${col.key}`}
|
||||
style={{
|
||||
width: col.width,
|
||||
paddingInline: 16,
|
||||
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}
|
||||
>
|
||||
{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>
|
||||
|
||||
@@ -218,17 +277,12 @@ const FinanceTable = ({
|
||||
key={task.id}
|
||||
style={{
|
||||
height: 40,
|
||||
background: idx % 2 === 0 ? '#232323' : '#181818',
|
||||
background: idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode),
|
||||
transition: 'background 0.2s',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#333'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? '#232323' : '#181818'}
|
||||
onClick={() => setSelectedTask(task)}
|
||||
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)}
|
||||
>
|
||||
<td style={{ width: 48, paddingInline: 16 }}>
|
||||
<Checkbox />
|
||||
</td>
|
||||
{financeTableColumns.map((col) => (
|
||||
<td
|
||||
key={`${task.id}-${col.key}`}
|
||||
@@ -236,7 +290,17 @@ const FinanceTable = ({
|
||||
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>
|
||||
|
||||
@@ -14,12 +14,13 @@ const ProjectViewFinance = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances);
|
||||
const { refreshTimestamp } = useAppSelector((state: RootState) => state.projectReducer);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup }));
|
||||
}
|
||||
}, [projectId, activeGroup, dispatch]);
|
||||
}, [projectId, activeGroup, dispatch, refreshTimestamp]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user