feat(finance): implement project finance and rate card management features

- Added new controllers for managing project finance and rate cards, including CRUD operations for rate card roles and project finance tasks.
- Introduced API routes for project finance and rate card functionalities, enhancing the backend structure.
- Developed frontend components for displaying and managing project finance data, including a finance drawer and rate card settings.
- Enhanced localization files to support new UI elements and ensure consistency across multiple languages.
- Implemented utility functions for handling man-days and financial calculations, improving overall functionality.
This commit is contained in:
chamikaJ
2025-07-24 15:23:34 +05:30
parent 4b54f2cc17
commit 4ffc3465e3
51 changed files with 10202 additions and 169 deletions

View File

@@ -1,57 +1,46 @@
import React from 'react';
import { Avatar, Tooltip } from '@/shared/antd-imports';
import React, { useCallback, useMemo } from 'react';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
interface AvatarsProps {
members: InlineMember[];
maxCount?: number;
allowClickThrough?: boolean;
}
const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount }) => {
const stopPropagation = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
}, []);
const renderAvatar = useCallback(
(member: InlineMember, index: number) => (
<Tooltip
key={member.team_member_id || index}
title={member.end && member.names ? member.names.join(', ') : member.name}
>
{member.avatar_url ? (
<span onClick={stopPropagation}>
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
</span>
) : (
<span onClick={stopPropagation}>
<Avatar
size={28}
key={member.team_member_id || index}
style={{
backgroundColor: member.color_code || '#ececec',
fontSize: '14px',
}}
>
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
</Avatar>
</span>
)}
</Tooltip>
),
[stopPropagation]
);
const visibleMembers = useMemo(() => {
return maxCount ? members.slice(0, maxCount) : members;
}, [members, maxCount]);
const avatarElements = useMemo(() => {
return visibleMembers.map((member, index) => renderAvatar(member, index));
}, [visibleMembers, renderAvatar]);
const renderAvatar = (member: InlineMember, index: number, allowClickThrough: boolean = false) => (
<Tooltip
key={member.team_member_id || index}
title={member.end && member.names ? member.names.join(', ') : member.name}
>
{member.avatar_url ? (
<span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
</span>
) : (
<span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
<Avatar
size={28}
key={member.team_member_id || index}
style={{
backgroundColor: member.color_code || '#ececec',
fontSize: '14px',
}}
>
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
</Avatar>
</span>
)}
</Tooltip>
);
const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount, allowClickThrough = false }) => {
const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
return (
<div onClick={stopPropagation}>
<Avatar.Group>{avatarElements}</Avatar.Group>
<div onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
<Avatar.Group>
{visibleMembers.map((member, index) => renderAvatar(member, index, allowClickThrough))}
</Avatar.Group>
</div>
);
});

View File

@@ -0,0 +1,231 @@
import { Drawer, Typography, Button, Table, Menu, Flex, Spin, Alert } from 'antd';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { fetchRateCards, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice';
import { fetchRateCardById } from '@/features/finance/finance-slice';
import { insertProjectRateCardRoles } from '@/features/finance/project-finance-slice';
import { useParams } from 'react-router-dom';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { IOrganization } from '@/types/admin-center/admin-center.types';
import { hourlyRateToManDayRate } from '@/utils/man-days-utils';
import { JobRoleType } from '@/types/project/ratecard.types';
const ImportRateCardsDrawer: React.FC = () => {
const dispatch = useAppDispatch();
const { projectId } = useParams();
const { t } = useTranslation('project-view-finance');
const drawerRatecard = useAppSelector(state => state.financeReducer.drawerRatecard);
const ratecardsList = useAppSelector(state => state.financeReducer.ratecardsList || []);
const isDrawerOpen = useAppSelector(state => state.financeReducer.isImportRatecardsDrawerOpen);
// Get project currency from project finances, fallback to finance reducer currency
const projectCurrency = useAppSelector(state => state.projectFinancesReducer.project?.currency);
const fallbackCurrency = useAppSelector(state => state.financeReducer.currency);
const currency = (projectCurrency || fallbackCurrency || 'USD').toUpperCase();
const rolesRedux = useAppSelector(state => state.projectFinanceRateCardReducer.rateCardRoles) || [];
// Loading states
const isRatecardsLoading = useAppSelector(state => state.financeReducer.isRatecardsLoading);
const [selectedRatecardId, setSelectedRatecardId] = useState<string | null>(null);
const [organization, setOrganization] = useState<IOrganization | null>(null);
// Get calculation method from organization
const calculationMethod = organization?.calculation_method || 'hourly';
useEffect(() => {
if (selectedRatecardId) {
dispatch(fetchRateCardById(selectedRatecardId));
}
}, [selectedRatecardId, dispatch]);
// Fetch organization details to get calculation method
useEffect(() => {
const fetchOrganization = async () => {
try {
const response = await adminCenterApiService.getOrganizationDetails();
if (response.done) {
setOrganization(response.body);
}
} catch (error) {
console.error('Failed to fetch organization details:', error);
}
};
if (isDrawerOpen) {
fetchOrganization();
}
}, [isDrawerOpen]);
useEffect(() => {
if (isDrawerOpen) {
dispatch(
fetchRateCards({
index: 1,
size: 1000,
field: 'name',
order: 'asc',
search: '',
})
);
}
}, [isDrawerOpen, dispatch]);
useEffect(() => {
if (ratecardsList.length > 0 && !selectedRatecardId) {
setSelectedRatecardId(ratecardsList[0].id || null);
}
}, [ratecardsList, selectedRatecardId]);
const columns = [
{
title: t('jobTitleColumn'),
dataIndex: 'jobtitle',
render: (text: string) => (
<Typography.Text className="group-hover:text-[#1890ff]">{text}</Typography.Text>
),
},
{
title: `${calculationMethod === 'man_days' ? t('ratePerManDayColumn') : t('ratePerHourColumn')} (${currency})`,
dataIndex: 'rate',
render: (_: any, record: JobRoleType) => (
<Typography.Text>
{calculationMethod === 'man_days' ? record.man_day_rate : record.rate}
</Typography.Text>
),
},
];
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{t('ratecardsPluralText')}
</Typography.Text>
}
footer={
<div style={{ textAlign: 'right' }}>
{/* Alert message */}
{rolesRedux.length !== 0 ? (
<div style={{ textAlign: 'right' }}>
<Alert
message={
t('alreadyImportedRateCardMessage') ||
'A rate card has already been imported. Clear all imported rate cards to add a new one.'
}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
</div>
) : (
<div style={{ textAlign: 'right' }}>
<Button
type="primary"
onClick={() => {
if (!projectId) {
// Handle missing project id (show error, etc.)
return;
}
if (drawerRatecard?.jobRolesList?.length) {
const isProjectManDays = calculationMethod === 'man_days';
const hoursPerDay = organization?.hours_per_day || 8;
dispatch(
insertProjectRateCardRoles({
project_id: projectId,
roles: drawerRatecard.jobRolesList
.filter(role => typeof role.rate !== 'undefined' && role.job_title_id)
.map(role => {
if (isProjectManDays) {
// If the imported rate card is hourly, convert rate to man_day_rate
if (
(role.man_day_rate === undefined || role.man_day_rate === 0) &&
role.rate
) {
return {
...role,
job_title_id: role.job_title_id!,
man_day_rate: hourlyRateToManDayRate(
Number(role.rate),
hoursPerDay
),
rate: 0,
};
} else {
// Already has man_day_rate
return {
...role,
job_title_id: role.job_title_id!,
man_day_rate: Number(role.man_day_rate) || 0,
rate: 0,
};
}
} else {
// Project is hourly, import as is
return {
...role,
job_title_id: role.job_title_id!,
rate: Number(role.rate) || 0,
man_day_rate: Number(role.man_day_rate) || 0,
};
}
}),
})
);
}
dispatch(toggleImportRatecardsDrawer());
}}
>
{t('import')}
</Button>
</div>
)}
</div>
}
open={isDrawerOpen}
onClose={() => dispatch(toggleImportRatecardsDrawer())}
width={1000}
>
<Flex gap={12}>
{/* Sidebar menu with loading */}
<Spin spinning={isRatecardsLoading} style={{ width: '20%' }}>
<Menu
mode="vertical"
style={{ width: '100%' }}
selectedKeys={
selectedRatecardId
? [selectedRatecardId]
: ratecardsList[0]?.id
? [ratecardsList[0].id]
: []
}
onClick={({ key }) => setSelectedRatecardId(key)}
>
{ratecardsList.map(ratecard => (
<Menu.Item key={ratecard.id}>{ratecard.name}</Menu.Item>
))}
</Menu>
</Spin>
{/* Table for job roles with loading */}
<Table
style={{ flex: 1 }}
dataSource={drawerRatecard?.jobRolesList || []}
columns={columns}
rowKey={record => record.job_title_id || record.id || Math.random().toString()}
onRow={() => ({
className: 'group',
style: { cursor: 'pointer' },
})}
pagination={false}
loading={isRatecardsLoading}
/>
</Flex>
</Drawer>
);
};
export default ImportRateCardsDrawer;

View File

@@ -0,0 +1,268 @@
import React, { useState, useEffect } from 'react';
import {
Drawer,
Form,
Select,
InputNumber,
Button,
Space,
Typography,
Card,
Row,
Col,
Tooltip,
message,
Alert,
SettingOutlined,
InfoCircleOutlined,
DollarOutlined,
CalculatorOutlined,
SaveOutlined,
CloseOutlined,
} from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import {
updateProjectFinanceCurrency,
fetchProjectFinancesSilent,
} from '@/features/projects/finance/project-finance.slice';
import { updateProjectCurrency, getProject } from '@/features/project/project.slice';
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
import { CURRENCY_OPTIONS } from '@/shared/currencies';
const { Text } = Typography;
interface ProjectBudgetSettingsDrawerProps {
visible: boolean;
onClose: () => void;
projectId: string;
}
const ProjectBudgetSettingsDrawer: React.FC<ProjectBudgetSettingsDrawerProps> = ({
visible,
onClose,
projectId,
}) => {
const { t } = useTranslation('project-view-finance');
const dispatch = useAppDispatch();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Get project data from Redux
const financeProject = useAppSelector(state => state.projectFinancesReducer.project);
const { project } = useAppSelector(state => state.projectReducer);
const activeGroup = useAppSelector(state => state.projectFinancesReducer.activeGroup);
const billableFilter = useAppSelector(state => state.projectFinancesReducer.billableFilter);
// Form initial values
const initialValues = {
budget: project?.budget || 0,
currency: financeProject?.currency || 'USD',
};
// Set form values when drawer opens
useEffect(() => {
if (visible && (project || financeProject)) {
form.setFieldsValue(initialValues);
setHasChanges(false);
}
}, [visible, project, financeProject, form]);
// Handle form value changes
const handleValuesChange = () => {
setHasChanges(true);
};
// Handle save
const handleSave = async () => {
try {
setLoading(true);
const values = await form.validateFields();
// Update budget if changed
if (values.budget !== project?.budget) {
await projectFinanceApiService.updateProjectBudget(projectId, values.budget);
}
// Update currency if changed
if (values.currency !== financeProject?.currency) {
await projectFinanceApiService.updateProjectCurrency(
projectId,
values.currency.toUpperCase()
);
dispatch(updateProjectCurrency(values.currency));
dispatch(updateProjectFinanceCurrency(values.currency));
}
message.success('Project settings updated successfully');
setHasChanges(false);
// Reload project finances after save
dispatch(
fetchProjectFinancesSilent({
projectId,
groupBy: activeGroup,
billableFilter,
resetExpansions: true,
})
);
// Also refresh the main project data to update budget statistics
dispatch(getProject(projectId));
onClose();
} catch (error) {
console.error('Failed to update project settings:', error);
message.error('Failed to update project settings');
} finally {
setLoading(false);
}
};
// Handle cancel
const handleCancel = () => {
if (hasChanges) {
form.setFieldsValue(initialValues);
setHasChanges(false);
}
onClose();
};
return (
<Drawer
title={
<Space>
<SettingOutlined />
<span>Project Budget Settings</span>
</Space>
}
width={480}
open={visible}
onClose={handleCancel}
footer={
<Space style={{ float: 'right' }}>
<Button icon={<CloseOutlined />} onClick={handleCancel}>
Cancel
</Button>
<Button
type="primary"
icon={<SaveOutlined />}
loading={loading}
disabled={!hasChanges}
onClick={handleSave}
>
Save Changes
</Button>
</Space>
}
>
<Form
form={form}
layout="vertical"
onValuesChange={handleValuesChange}
initialValues={initialValues}
>
{/* Budget Configuration */}
<Card
title={
<Space>
<DollarOutlined />
<span>Budget Configuration</span>
</Space>
}
size="small"
style={{ marginBottom: 16 }}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="budget"
label={
<Space>
<span>Project Budget</span>
<Tooltip title="Total budget allocated for this project">
<InfoCircleOutlined style={{ color: '#666' }} />
</Tooltip>
</Space>
}
>
<InputNumber
style={{ width: '100%' }}
min={0}
precision={2}
placeholder="Enter budget amount"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="currency" label="Currency">
<Select options={CURRENCY_OPTIONS} placeholder="Select currency" />
</Form.Item>
</Col>
</Row>
</Card>
{/* Calculation Method - Organization Wide Setting */}
<Card
title={
<Space>
<CalculatorOutlined />
<span>Cost Calculation Method</span>
</Space>
}
size="small"
style={{ marginBottom: 16 }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<Text strong>Current Method: </Text>
<Text>
{financeProject?.calculation_method === 'man_days'
? `Man Days (${financeProject?.hours_per_day || 8}h/day)`
: 'Hourly Rates'}
</Text>
</div>
<Alert
message="Organization-wide Setting"
description={
<Space direction="vertical" size="small">
<Text>
The calculation method is now configured at the organization level and applies
to all projects.
</Text>
<Text>
To change this setting, please visit the{' '}
<strong>Admin Center Overview</strong> page.
</Text>
</Space>
}
type="info"
showIcon
/>
</Space>
</Card>
{/* Information Section */}
<Card title="Important Notes" size="small" type="inner">
<Space direction="vertical" size="small">
<Text type="secondary">
Changing the calculation method will affect how costs are calculated for all tasks
in this project
</Text>
<Text type="secondary">
Changes take effect immediately and will recalculate all project totals
</Text>
<Text type="secondary">
Budget settings apply to the entire project and all its tasks
</Text>
</Space>
</Card>
</Form>
</Drawer>
);
};
export default ProjectBudgetSettingsDrawer;

View File

@@ -0,0 +1,298 @@
import React, { useEffect, useState } from 'react';
import { Drawer, Typography, Spin } from 'antd';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { closeFinanceDrawer } from '@/features/projects/finance/finance-slice';
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
import { ITaskBreakdownResponse } from '@/types/project/project-finance.types';
const FinanceDrawer = () => {
const [taskBreakdown, setTaskBreakdown] = useState<ITaskBreakdownResponse | null>(null);
const [loading, setLoading] = useState(false);
// Get task and drawer state from Redux store
const selectedTask = useAppSelector(state => state.financeReducer.selectedTask);
const isDrawerOpen = useAppSelector(state => state.financeReducer.isFinanceDrawerOpen);
useEffect(() => {
if (selectedTask?.id && isDrawerOpen) {
fetchTaskBreakdown(selectedTask.id);
} else {
setTaskBreakdown(null);
}
}, [selectedTask, isDrawerOpen]);
const fetchTaskBreakdown = async (taskId: string) => {
try {
setLoading(true);
const response = await projectFinanceApiService.getTaskBreakdown(taskId);
setTaskBreakdown(response.body);
} catch (error) {
console.error('Error fetching task breakdown:', error);
} finally {
setLoading(false);
}
};
// localization
const { t } = useTranslation('project-view-finance');
// get theme data from theme reducer
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
// Get project currency from project finances, fallback to finance reducer currency
const projectCurrency = useAppSelector(state => state.projectFinancesReducer.project?.currency);
const fallbackCurrency = useAppSelector(state => state.financeReducer.currency);
const currency = (projectCurrency || fallbackCurrency || 'USD').toUpperCase();
// function handle drawer close
const handleClose = () => {
setTaskBreakdown(null);
dispatch(closeFinanceDrawer());
};
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{taskBreakdown?.task?.name || selectedTask?.name || t('noTaskSelected')}
</Typography.Text>
}
open={isDrawerOpen}
onClose={handleClose}
destroyOnHidden={true}
width={640}
>
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin size="large" />
</div>
) : (
<>
{/* Task Summary */}
{taskBreakdown?.task && (
<div
style={{
marginBottom: 24,
padding: 16,
backgroundColor: themeWiseColor('#f9f9f9', '#1a1a1a', themeMode),
borderRadius: 8,
}}
>
<Typography.Text
strong
style={{ fontSize: 16, display: 'block', marginBottom: 12 }}
>
Task Overview
</Typography.Text>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: 16,
}}
>
<div>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Estimated Hours
</Typography.Text>
<Typography.Text strong style={{ fontSize: 16 }}>
{taskBreakdown.task.estimated_hours?.toFixed(2) || '0.00'}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Total Logged Hours
</Typography.Text>
<Typography.Text strong style={{ fontSize: 16 }}>
{taskBreakdown.task.logged_hours?.toFixed(2) || '0.00'}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Estimated Labor Cost ({currency})
</Typography.Text>
<Typography.Text strong style={{ fontSize: 16 }}>
{taskBreakdown.task.estimated_labor_cost?.toFixed(2) || '0.00'}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Actual Labor Cost ({currency})
</Typography.Text>
<Typography.Text strong style={{ fontSize: 16 }}>
{taskBreakdown.task.actual_labor_cost?.toFixed(2) || '0.00'}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Fixed Cost ({currency})
</Typography.Text>
<Typography.Text strong style={{ fontSize: 16 }}>
{taskBreakdown.task.fixed_cost?.toFixed(2) || '0.00'}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Total Actual Cost ({currency})
</Typography.Text>
<Typography.Text strong style={{ fontSize: 16 }}>
{taskBreakdown.task.total_actual_cost?.toFixed(2) || '0.00'}
</Typography.Text>
</div>
</div>
</div>
)}
{/* Member Breakdown Table */}
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12 }}>
Member Time Logs & Costs
</Typography.Text>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
marginBottom: '16px',
}}
>
<thead>
<tr
style={{
height: 48,
backgroundColor: themeWiseColor('#F5F5F5', '#1d1d1d', themeMode),
}}
>
<th
style={{
textAlign: 'left',
padding: 8,
}}
>
Role / Member
</th>
<th
style={{
textAlign: 'right',
padding: 8,
}}
>
Logged Hours
</th>
<th
style={{
textAlign: 'right',
padding: 8,
}}
>
Hourly Rate ({currency})
</th>
<th
style={{
textAlign: 'right',
padding: 8,
}}
>
Actual Cost ({currency})
</th>
</tr>
</thead>
<tbody>
{taskBreakdown?.grouped_members?.map((group: any) => (
<React.Fragment key={group.jobRole}>
{/* Group Header */}
<tr
style={{
backgroundColor: themeWiseColor('#D9D9D9', '#000', themeMode),
height: 56,
}}
className="border-b-[1px] font-semibold"
>
<td style={{ padding: 8, fontWeight: 'bold' }}>{group.jobRole}</td>
<td
style={{
textAlign: 'right',
padding: 8,
fontWeight: 'bold',
}}
>
{group.logged_hours?.toFixed(2) || '0.00'}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
fontWeight: 'bold',
color: '#999',
}}
>
-
</td>
<td
style={{
textAlign: 'right',
padding: 8,
fontWeight: 'bold',
}}
>
{group.actual_cost?.toFixed(2) || '0.00'}
</td>
</tr>
{/* Member Rows */}
{group.members?.map((member: any, index: number) => (
<tr
key={`${group.jobRole}-${index}`}
className="border-b-[1px]"
style={{ height: 56 }}
>
<td
style={{
padding: 8,
paddingLeft: 32,
}}
>
{member.name}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.logged_hours?.toFixed(2) || '0.00'}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.hourly_rate?.toFixed(2) || '0.00'}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.actual_cost?.toFixed(2) || '0.00'}
</td>
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</>
)}
</div>
</Drawer>
);
};
export default FinanceDrawer;

View File

@@ -0,0 +1,370 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Flex, Typography, Empty, Tooltip } from 'antd';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { openFinanceDrawer } from '@/features/finance/finance-slice';
import {
FinanceTableColumnKeys,
getFinanceTableColumns,
} from '@/lib/project/project-view-finance-table-columns';
import { formatManDays } from '@/utils/man-days-utils';
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
import { createPortal } from 'react-dom';
import FinanceTable from '../finance-table/FinanceTable';
import FinanceDrawer from '../finance-drawer/FinanceDrawer';
interface FinanceTableWrapperProps {
activeTablesList: IProjectFinanceGroup[];
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 [isScrolling, setIsScrolling] = useState(false);
const { t } = useTranslation('project-view-finance');
const dispatch = useAppDispatch();
const onTaskClick = (task: any) => {
dispatch(openFinanceDrawer(task));
};
useEffect(() => {
const tableContainer = document.querySelector('.tasklist-container');
const handleScroll = () => {
if (tableContainer) {
setIsScrolling(tableContainer.scrollLeft > 0);
}
};
tableContainer?.addEventListener('scroll', handleScroll);
return () => {
tableContainer?.removeEventListener('scroll', handleScroll);
};
}, []);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const currency = useAppSelector(
state => state.projectFinancesReducer.project?.currency || ''
).toUpperCase();
const taskGroups = useAppSelector(state => state.projectFinancesReducer.taskGroups);
const financeProject = useAppSelector(state => state.projectFinancesReducer.project);
// Get calculation method and hours per day from project
const calculationMethod = financeProject?.calculation_method || 'hourly';
const hoursPerDay = financeProject?.hours_per_day || 8;
// Get dynamic columns based on calculation method
const activeColumns = useMemo(
() => getFinanceTableColumns(calculationMethod),
[calculationMethod]
);
// Function to get tooltip text for column headers
const getColumnTooltip = (columnKey: FinanceTableColumnKeys): string => {
switch (columnKey) {
case FinanceTableColumnKeys.HOURS:
return t('columnTooltips.hours');
case FinanceTableColumnKeys.MAN_DAYS:
return t('columnTooltips.manDays', { hoursPerDay });
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
return t('columnTooltips.totalTimeLogged');
case FinanceTableColumnKeys.ESTIMATED_COST:
return calculationMethod === 'man_days'
? t('columnTooltips.estimatedCostManDays')
: t('columnTooltips.estimatedCostHourly');
case FinanceTableColumnKeys.COST:
return t('columnTooltips.actualCost');
case FinanceTableColumnKeys.FIXED_COST:
return t('columnTooltips.fixedCost');
case FinanceTableColumnKeys.TOTAL_BUDGET:
return calculationMethod === 'man_days'
? t('columnTooltips.totalBudgetManDays')
: t('columnTooltips.totalBudgetHourly');
case FinanceTableColumnKeys.TOTAL_ACTUAL:
return t('columnTooltips.totalActual');
case FinanceTableColumnKeys.VARIANCE:
return t('columnTooltips.variance');
default:
return '';
}
};
// Use Redux store data for totals calculation to ensure reactivity
const totals = useMemo(() => {
// Recursive function to calculate totals from task hierarchy without double counting
const calculateTaskTotalsRecursively = (tasks: IProjectFinanceTask[]): any => {
return tasks.reduce(
(acc, task) => {
// For parent tasks with subtasks, aggregate values from subtasks only
// For leaf tasks, use their individual values
if (task.sub_tasks && task.sub_tasks.length > 0) {
// Parent task - only use aggregated values from subtasks (no parent's own values)
const subtaskTotals = calculateTaskTotalsRecursively(task.sub_tasks);
return {
hours: acc.hours + subtaskTotals.hours,
manDays: acc.manDays + subtaskTotals.manDays,
cost: acc.cost + subtaskTotals.cost,
fixedCost: acc.fixedCost + subtaskTotals.fixedCost,
totalBudget: acc.totalBudget + subtaskTotals.totalBudget,
totalActual: acc.totalActual + subtaskTotals.totalActual,
variance: acc.variance + subtaskTotals.variance,
total_time_logged: acc.total_time_logged + subtaskTotals.total_time_logged,
estimated_cost: acc.estimated_cost + subtaskTotals.estimated_cost,
};
} else {
// Leaf task - use backend-provided calculated values
const leafTotalActual = task.total_actual || 0;
const leafTotalBudget = task.total_budget || 0;
return {
hours: acc.hours + (task.estimated_seconds || 0),
// Calculate man days from total_minutes, fallback to estimated_seconds if total_minutes is 0
manDays:
acc.manDays +
(task.total_minutes > 0
? task.total_minutes / 60 / (hoursPerDay || 8)
: task.estimated_seconds / 3600 / (hoursPerDay || 8)),
cost: acc.cost + (task.actual_cost_from_logs || 0),
fixedCost: acc.fixedCost + (task.fixed_cost || 0),
totalBudget: acc.totalBudget + leafTotalBudget,
totalActual: acc.totalActual + leafTotalActual,
variance: acc.variance + (leafTotalBudget - leafTotalActual),
total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0),
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0),
};
}
},
{
hours: 0,
manDays: 0,
cost: 0,
fixedCost: 0,
totalBudget: 0,
totalActual: 0,
variance: 0,
total_time_logged: 0,
estimated_cost: 0,
}
);
};
return activeTablesList.reduce(
(acc, table: IProjectFinanceGroup) => {
const groupTotals = calculateTaskTotalsRecursively(table.tasks);
return {
hours: acc.hours + groupTotals.hours,
manDays: acc.manDays + groupTotals.manDays,
cost: acc.cost + groupTotals.cost,
fixedCost: acc.fixedCost + groupTotals.fixedCost,
totalBudget: acc.totalBudget + groupTotals.totalBudget,
totalActual: acc.totalActual + groupTotals.totalActual,
variance: acc.variance + groupTotals.variance,
total_time_logged: acc.total_time_logged + groupTotals.total_time_logged,
estimated_cost: acc.estimated_cost + groupTotals.estimated_cost,
};
},
{
hours: 0,
manDays: 0,
cost: 0,
fixedCost: 0,
totalBudget: 0,
totalActual: 0,
variance: 0,
total_time_logged: 0,
estimated_cost: 0,
}
);
}, [activeTablesList, hoursPerDay]);
const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
switch (columnKey) {
case FinanceTableColumnKeys.HOURS:
return (
<Typography.Text style={{ fontSize: 18 }}>
{formatSecondsToTimeString(totals.hours)}
</Typography.Text>
);
case FinanceTableColumnKeys.MAN_DAYS:
return (
<Typography.Text style={{ fontSize: 18 }}>
{formatManDays(totals.manDays, 1, hoursPerDay)}
</Typography.Text>
);
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={{
color: totals.variance < 0 ? '#d32f2f' : '#2e7d32',
fontSize: 18,
fontWeight: 'bold',
}}
>
{totals.variance?.toFixed(2)}
</Typography.Text>
);
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
return (
<Typography.Text style={{ fontSize: 18 }}>
{formatSecondsToTimeString(totals.total_time_logged)}
</Typography.Text>
);
case FinanceTableColumnKeys.ESTIMATED_COST:
return (
<Typography.Text style={{ fontSize: 18 }}>
{`${totals.estimated_cost?.toFixed(2)}`}
</Typography.Text>
);
default:
return null;
}
};
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: 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">
<table>
<tbody>
<tr
style={{
height: 56,
fontWeight: 600,
backgroundColor: themeWiseColor('#fafafa', '#1d1d1d', themeMode),
borderBlockEnd: `2px solid rgb(0 0 0 / 0.05)`,
}}
>
{activeColumns.map(col => (
<td
key={col.key}
style={{
minWidth: col.width,
paddingInline: 16,
textAlign:
col.type === 'hours' || col.type === 'currency' || col.type === 'man_days'
? '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'}`}
>
<Tooltip title={getColumnTooltip(col.key)} placement="top">
<Typography.Text style={{ cursor: 'help' }}>
{t(`${col.name}`)} {col.type === 'currency' && `(${currency.toUpperCase()})`}
</Typography.Text>
</Tooltip>
</td>
))}
</tr>
{hasAnyTasks && (
<tr
style={{
height: 56,
fontWeight: 500,
backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode),
}}
>
{activeColumns.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' ||
col.type === 'man_days') &&
renderFinancialTableHeaderContent(col.key)
)}
</td>
))}
</tr>
)}
{hasAnyTasks ? (
activeTablesList.map(table => (
<FinanceTable
key={table.group_id}
table={table}
onTaskClick={onTaskClick}
loading={loading}
columns={activeColumns}
/>
))
) : (
<tr>
<td
colSpan={activeColumns.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>
{createPortal(<FinanceDrawer />, document.body)}
</>
);
};
export default FinanceTableWrapper;

View File

@@ -0,0 +1,769 @@
import { Flex, InputNumber, Skeleton, Tooltip, Typography } from 'antd';
import React, { useEffect, useMemo, useState, useRef } 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,
FinanceTableColumnKeys,
getFinanceTableColumns,
} from '@/lib/project/project-view-finance-table-columns';
import { formatManDays } from '@/utils/man-days-utils';
import Avatars from '@/components/avatars/avatars';
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
import {
updateTaskFixedCostAsync,
toggleTaskExpansion,
fetchSubTasks,
fetchProjectFinancesSilent,
} from '@/features/projects/finance/project-finance.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
setSelectedTaskId,
setShowTaskDrawer,
fetchTask,
} from '@/features/task-drawer/task-drawer.slice';
import { useParams } from 'react-router-dom';
import { useAuthService } from '@/hooks/useAuth';
import { canEditFixedCost } from '@/utils/finance-permissions';
import './finance-table.css';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
type FinanceTableProps = {
table: IProjectFinanceGroup;
loading: boolean;
onTaskClick: (task: any) => void;
columns?: any[];
};
const FinanceTable = ({ table, loading, onTaskClick, columns }: FinanceTableProps) => {
const [isCollapse, setIsCollapse] = useState<boolean>(false);
const [isScrolling, setIsScrolling] = useState<boolean>(false);
const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
const [editingFixedCostValue, setEditingFixedCostValue] = useState<number | null>(null);
const [tasks, setTasks] = useState<IProjectFinanceTask[]>(table.tasks);
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const dispatch = useAppDispatch();
// Get the latest task groups from Redux store
const taskGroups = useAppSelector(state => state.projectFinancesReducer.taskGroups);
const {
activeGroup,
billableFilter,
project: financeProject,
} = useAppSelector(state => state.projectFinancesReducer);
// Get calculation method and dynamic columns
const calculationMethod = financeProject?.calculation_method || 'hourly';
const hoursPerDay = financeProject?.hours_per_day || 8;
const activeColumns = useMemo(
() => columns || getFinanceTableColumns(calculationMethod),
[columns, calculationMethod]
);
// Auth and permissions
const auth = useAuthService();
const currentSession = auth.getCurrentSession();
const { project } = useAppSelector(state => state.projectReducer);
const hasEditPermission = canEditFixedCost(currentSession, project);
// 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')) {
// Save current value before closing if it has changed
if (editingFixedCostValue !== null) {
immediateSaveFixedCost(editingFixedCostValue, selectedTask.id);
} else {
setSelectedTask(null);
setEditingFixedCostValue(null);
}
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [selectedTask, editingFixedCostValue, tasks]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
// get theme data from theme reducer
const themeMode = useAppSelector(state => state.themeReducer.mode);
const formatNumber = (value: number | undefined | null) => {
if (value === undefined || value === null) return '0.00';
return value.toFixed(2);
};
// 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 FinanceTableColumnKeys.HOURS:
return <Typography.Text>{formattedTotals.hours}</Typography.Text>;
case FinanceTableColumnKeys.MAN_DAYS:
return (
<Typography.Text>
{formatManDays(formattedTotals.man_days || 0, 1, hoursPerDay)}
</Typography.Text>
);
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
return <Typography.Text>{formattedTotals.total_time_logged}</Typography.Text>;
case FinanceTableColumnKeys.ESTIMATED_COST:
return <Typography.Text>{formatNumber(formattedTotals.estimated_cost)}</Typography.Text>;
case FinanceTableColumnKeys.COST:
return (
<Typography.Text>{formatNumber(formattedTotals.actual_cost_from_logs)}</Typography.Text>
);
case FinanceTableColumnKeys.FIXED_COST:
return <Typography.Text>{formatNumber(formattedTotals.fixed_cost)}</Typography.Text>;
case FinanceTableColumnKeys.TOTAL_BUDGET:
return <Typography.Text>{formatNumber(formattedTotals.total_budget)}</Typography.Text>;
case FinanceTableColumnKeys.TOTAL_ACTUAL:
return <Typography.Text>{formatNumber(formattedTotals.total_actual)}</Typography.Text>;
case FinanceTableColumnKeys.VARIANCE:
return (
<Typography.Text
style={{
color: formattedTotals.variance < 0 ? '#d32f2f' : '#2e7d32',
fontWeight: 'bold',
}}
>
{formatNumber(formattedTotals.variance)}
</Typography.Text>
);
default:
return null;
}
};
const handleFixedCostChange = async (value: number | null, taskId: string) => {
const fixedCost = value || 0;
// Find the task to check if it's a parent task
const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => {
for (const task of tasks) {
if (task.id === id) return task;
if (task.sub_tasks) {
const found = findTask(task.sub_tasks, id);
if (found) return found;
}
}
return null;
};
const task = findTask(tasks, taskId);
if (!task) {
console.error('Task not found:', taskId);
return;
}
// Prevent editing fixed cost for parent tasks
if (task.sub_tasks_count > 0) {
console.warn(
'Cannot edit fixed cost for parent tasks. Fixed cost is calculated from subtasks.'
);
return;
}
try {
// Update the task fixed cost - this will automatically trigger hierarchical recalculation
// The Redux slice handles parent task updates through recalculateTaskHierarchy
await dispatch(
updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })
).unwrap();
// Trigger a silent refresh with expansion reset to show updated data clearly
if (projectId) {
dispatch(
fetchProjectFinancesSilent({
projectId,
groupBy: activeGroup,
billableFilter,
resetExpansions: true,
})
);
}
setSelectedTask(null);
setEditingFixedCostValue(null);
} catch (error) {
console.error('Failed to update fixed cost:', error);
}
};
const { projectId } = useParams<{ projectId: string }>();
const handleTaskNameClick = (taskId: string) => {
if (!taskId || !projectId) return;
dispatch(setSelectedTaskId(taskId));
dispatch(fetchPhasesByProjectId(projectId));
dispatch(fetchPriorities());
dispatch(fetchTask({ taskId, projectId }));
dispatch(setShowTaskDrawer(true));
};
// Handle task expansion/collapse
const handleTaskExpansion = async (task: IProjectFinanceTask) => {
if (!projectId) return;
// If task has subtasks but they're not loaded yet, load them
if (task.sub_tasks_count > 0 && !task.sub_tasks) {
dispatch(fetchSubTasks({ projectId, parentTaskId: task.id }));
} else {
// Just toggle the expansion state
dispatch(toggleTaskExpansion({ taskId: task.id, groupId: table.group_id }));
}
};
// Debounced save function for fixed cost
const debouncedSaveFixedCost = (value: number | null, taskId: string) => {
// Clear existing timeout
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Set new timeout
saveTimeoutRef.current = setTimeout(() => {
// Find the current task to check if value actually changed
const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => {
for (const task of tasks) {
if (task.id === id) return task;
if (task.sub_tasks) {
const found = findTask(task.sub_tasks, id);
if (found) return found;
}
}
return null;
};
const currentTask = findTask(tasks, taskId);
const currentFixedCost = currentTask?.fixed_cost || 0;
const newFixedCost = value || 0;
// Only save if the value actually changed
if (newFixedCost !== currentFixedCost && value !== null) {
handleFixedCostChange(value, taskId);
// Don't close the input automatically - let user explicitly close it
}
}, 5000); // Save after 5 seconds of inactivity
};
// Immediate save function (for enter/blur)
const immediateSaveFixedCost = (value: number | null, taskId: string) => {
// Clear any pending debounced save
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
// Find the current task to check if value actually changed
const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => {
for (const task of tasks) {
if (task.id === id) return task;
if (task.sub_tasks) {
const found = findTask(task.sub_tasks, id);
if (found) return found;
}
}
return null;
};
const currentTask = findTask(tasks, taskId);
const currentFixedCost = currentTask?.fixed_cost || 0;
const newFixedCost = value || 0;
// Only save if the value actually changed
if (newFixedCost !== currentFixedCost && value !== null) {
handleFixedCostChange(value, taskId);
} else {
// Just close the editor without saving
setSelectedTask(null);
setEditingFixedCostValue(null);
}
};
// Calculate indentation based on nesting level
const getTaskIndentation = (level: number) => level * 32; // 32px per level for better visibility
// Recursive function to render task hierarchy
const renderTaskHierarchy = (
task: IProjectFinanceTask,
level: number = 0
): React.ReactElement[] => {
const elements: React.ReactElement[] = [];
// Add the current task
const isHovered = hoveredTaskId === task.id;
const rowIndex = elements.length;
const defaultBg =
rowIndex % 2 === 0
? themeWiseColor('#fafafa', '#232323', themeMode)
: themeWiseColor('#ffffff', '#181818', themeMode);
const hoverBg = themeMode === 'dark' ? 'rgba(64, 169, 255, 0.08)' : 'rgba(24, 144, 255, 0.04)';
elements.push(
<tr
key={task.id}
style={{
height: 40,
background: isHovered ? hoverBg : defaultBg,
transition: 'background 0.2s',
}}
className={`finance-table-task-row ${level > 0 ? 'finance-table-nested-task' : ''} ${themeMode === 'dark' ? 'dark' : ''}`}
onMouseEnter={() => setHoveredTaskId(task.id)}
onMouseLeave={() => setHoveredTaskId(null)}
>
{activeColumns.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
? isHovered
? hoverBg
: defaultBg
: isHovered
? hoverBg
: 'transparent',
cursor: 'default',
}}
className={customColumnStyles(col.key)}
onClick={
col.key === FinanceTableColumnKeys.FIXED_COST ? e => e.stopPropagation() : undefined
}
>
{renderFinancialTableColumnContent(col.key, task, level)}
</td>
))}
</tr>
);
// Add subtasks recursively if they are expanded and loaded
if (task.show_sub_tasks && task.sub_tasks) {
task.sub_tasks.forEach(subTask => {
elements.push(...renderTaskHierarchy(subTask, level + 1));
});
}
return elements;
};
const renderFinancialTableColumnContent = (
columnKey: FinanceTableColumnKeys,
task: IProjectFinanceTask,
level: number = 0
) => {
switch (columnKey) {
case FinanceTableColumnKeys.TASK:
return (
<Tooltip title={task.name}>
<Flex gap={8} align="center" style={{ paddingLeft: getTaskIndentation(level) }}>
{/* Expand/collapse icon for parent tasks */}
{task.sub_tasks_count > 0 && (
<div
className="finance-table-expand-btn"
style={{
cursor: 'pointer',
width: 18,
height: 18,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexShrink: 0,
zIndex: 1,
}}
onClick={e => {
e.stopPropagation();
handleTaskExpansion(task);
}}
>
{task.show_sub_tasks ? (
<DownOutlined style={{ fontSize: 12 }} />
) : (
<RightOutlined style={{ fontSize: 12 }} />
)}
</div>
)}
{/* Spacer for tasks without subtasks to align with those that have expand icons */}
{task.sub_tasks_count === 0 && level > 0 && (
<div style={{ width: 18, height: 18, flexShrink: 0 }} />
)}
{/* Task name */}
<Typography.Text
className="finance-table-task-name"
ellipsis={{ expanded: false }}
style={{
maxWidth: Math.max(
100,
200 - getTaskIndentation(level) - (task.sub_tasks_count > 0 ? 26 : 18)
),
cursor: 'pointer',
color: '#1890ff',
fontSize: Math.max(12, 14 - level * 0.3), // Slightly smaller font for deeper levels
opacity: Math.max(0.85, 1 - level * 0.03), // Slightly faded for deeper levels
fontWeight: level > 0 ? 400 : 500, // Slightly lighter weight for nested tasks
}}
onClick={e => {
e.stopPropagation();
handleTaskNameClick(task.id);
}}
onMouseEnter={e => {
e.currentTarget.style.textDecoration = 'underline';
}}
onMouseLeave={e => {
e.currentTarget.style.textDecoration = 'none';
}}
>
{task.name}
</Typography.Text>
{task.billable && <DollarCircleOutlined style={{ fontSize: 12, flexShrink: 0 }} />}
</Flex>
</Tooltip>
);
case FinanceTableColumnKeys.MEMBERS:
return (
task.members && (
<div
onClick={e => {
e.stopPropagation();
onTaskClick(task);
}}
style={{
cursor: 'pointer',
width: '100%',
}}
>
<Avatars
members={task.members.map(member => ({
...member,
avatar_url: member.avatar_url || undefined,
}))}
allowClickThrough={true}
/>
</div>
)
);
case FinanceTableColumnKeys.HOURS:
return (
<Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>
{task.estimated_hours}
</Typography.Text>
);
case FinanceTableColumnKeys.MAN_DAYS:
// Backend now provides correct recursive aggregation for parent tasks
const taskManDays =
task.total_minutes > 0
? task.total_minutes / 60 / (hoursPerDay || 8)
: task.estimated_seconds / 3600 / (hoursPerDay || 8);
return (
<Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>
{formatManDays(taskManDays, 1, hoursPerDay)}
</Typography.Text>
);
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
return (
<Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>
{task.total_time_logged}
</Typography.Text>
);
case FinanceTableColumnKeys.ESTIMATED_COST:
return (
<Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>
{formatNumber(task.estimated_cost)}
</Typography.Text>
);
case FinanceTableColumnKeys.FIXED_COST:
// Parent tasks with subtasks should not be editable - they aggregate from subtasks
const isParentTask = task.sub_tasks_count > 0;
const canEditThisTask = hasEditPermission && !isParentTask;
return selectedTask?.id === task.id && canEditThisTask ? (
<InputNumber
value={editingFixedCostValue !== null ? editingFixedCostValue : task.fixed_cost}
onChange={value => {
setEditingFixedCostValue(value);
// Trigger debounced save for up/down arrow clicks
debouncedSaveFixedCost(value, task.id);
}}
onBlur={() => {
// Immediate save on blur
immediateSaveFixedCost(editingFixedCostValue, task.id);
}}
onPressEnter={() => {
// Immediate save on enter
immediateSaveFixedCost(editingFixedCostValue, task.id);
}}
onFocus={e => {
// Select all text when input is focused
e.target.select();
}}
autoFocus
style={{ width: '100%', textAlign: 'right', fontSize: Math.max(12, 14 - level * 0.5) }}
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: canEditThisTask ? 'pointer' : 'default',
width: '100%',
display: 'block',
opacity: canEditThisTask ? 1 : 0.7,
fontSize: Math.max(12, 14 - level * 0.5),
fontStyle: isParentTask ? 'italic' : 'normal',
color: isParentTask ? (themeMode === 'dark' ? '#888' : '#666') : 'inherit',
}}
onClick={
canEditThisTask
? e => {
e.stopPropagation();
setSelectedTask(task);
setEditingFixedCostValue(task.fixed_cost);
}
: undefined
}
title={isParentTask ? 'Fixed cost is calculated from subtasks' : undefined}
>
{formatNumber(task.fixed_cost)}
</Typography.Text>
);
case FinanceTableColumnKeys.VARIANCE:
// Calculate variance as Budget - Actual (positive = under budget = good)
const varianceBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0);
const varianceActual = (task.actual_cost_from_logs || 0) + (task.fixed_cost || 0);
const taskVariance = varianceBudget - varianceActual;
return (
<Typography.Text
style={{
color: taskVariance < 0 ? '#d32f2f' : '#2e7d32',
fontSize: Math.max(12, 14 - level * 0.5),
fontWeight: 'bold',
}}
>
{formatNumber(taskVariance)}
</Typography.Text>
);
case FinanceTableColumnKeys.TOTAL_BUDGET:
const taskTotalBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0);
return (
<Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>
{formatNumber(taskTotalBudget)}
</Typography.Text>
);
case FinanceTableColumnKeys.TOTAL_ACTUAL:
return (
<Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>
{formatNumber(task.total_actual || 0)}
</Typography.Text>
);
case FinanceTableColumnKeys.COST:
return (
<Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>
{formatNumber(task.actual_cost_from_logs || 0)}
</Typography.Text>
);
default:
return null;
}
};
// 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(' ');
};
// Generate flattened task list with all nested levels
const flattenedTasks = useMemo(() => {
const flattened: React.ReactElement[] = [];
tasks.forEach(task => {
flattened.push(...renderTaskHierarchy(task, 0));
});
return flattened;
}, [tasks, selectedTask, editingFixedCostValue, hasEditPermission, themeMode, hoveredTaskId]);
// Calculate totals for the current table - backend provides correct aggregated values
const totals = useMemo(() => {
const calculateTaskTotals = (taskList: IProjectFinanceTask[]): any => {
let totals = {
hours: 0,
man_days: 0,
total_time_logged: 0,
estimated_cost: 0,
actual_cost_from_logs: 0,
fixed_cost: 0,
total_budget: 0,
total_actual: 0,
variance: 0,
};
for (const task of taskList) {
if (task.sub_tasks && task.sub_tasks.length > 0) {
// Parent task with loaded subtasks - only count subtasks recursively
const subtaskTotals = calculateTaskTotals(task.sub_tasks);
totals.hours += subtaskTotals.hours;
totals.man_days += subtaskTotals.man_days;
totals.total_time_logged += subtaskTotals.total_time_logged;
totals.estimated_cost += subtaskTotals.estimated_cost;
totals.actual_cost_from_logs += subtaskTotals.actual_cost_from_logs;
totals.fixed_cost += subtaskTotals.fixed_cost;
totals.total_budget += subtaskTotals.total_budget;
totals.total_actual += subtaskTotals.total_actual;
totals.variance += subtaskTotals.variance;
} else {
// Leaf task or parent task without loaded subtasks - use backend aggregated values
const leafTotalActual = task.total_actual || 0;
const leafTotalBudget = task.total_budget || 0;
totals.hours += task.estimated_seconds || 0;
// Use same calculation as individual task display - backend provides correct values
const taskManDays =
task.total_minutes > 0
? task.total_minutes / 60 / (hoursPerDay || 8)
: task.estimated_seconds / 3600 / (hoursPerDay || 8);
totals.man_days += taskManDays;
totals.total_time_logged += task.total_time_logged_seconds || 0;
totals.estimated_cost += task.estimated_cost || 0;
totals.actual_cost_from_logs += task.actual_cost_from_logs || 0;
totals.fixed_cost += task.fixed_cost || 0;
totals.total_budget += leafTotalBudget;
totals.total_actual += leafTotalActual;
totals.variance += leafTotalBudget - leafTotalActual;
}
}
return totals;
};
return calculateTaskTotals(tasks);
}, [tasks, hoursPerDay]);
// Format the totals for display
const formattedTotals = useMemo(
() => ({
hours: formatSecondsToTimeString(totals.hours),
man_days: totals.man_days,
total_time_logged: formatSecondsToTimeString(totals.total_time_logged),
estimated_cost: totals.estimated_cost,
actual_cost_from_logs: totals.actual_cost_from_logs,
fixed_cost: totals.fixed_cost,
total_budget: totals.total_budget,
total_actual: totals.total_actual,
variance: totals.variance,
}),
[totals]
);
if (loading) {
return (
<tr>
<td colSpan={activeColumns.length}>
<Skeleton active />
</td>
</tr>
);
}
return (
<>
{/* header row */}
<tr
style={{
height: 40,
backgroundColor: themeWiseColor(table.color_code, table.color_code_dark, themeMode),
fontWeight: 600,
}}
className={`group ${themeMode === 'dark' ? 'dark' : ''}`}
>
{activeColumns.map((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>
{/* task rows with recursive hierarchy */}
{!isCollapse && flattenedTasks}
</>
);
};
export default FinanceTable;

View File

@@ -0,0 +1,63 @@
/* Finance Table Styles */
/* Enhanced hierarchy visual indicators */
.finance-table-task-row {
transition: all 0.2s ease-in-out;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.dark .finance-table-task-row {
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
/* Hover effect is now handled by inline styles in the component for consistency */
/* Nested task styling */
.finance-table-nested-task {
/* No visual connectors, just clean indentation */
}
/* Expand/collapse button styling */
.finance-table-expand-btn {
transition: all 0.2s ease-in-out;
border-radius: 2px;
padding: 2px;
}
.finance-table-expand-btn:hover {
background: rgba(0, 0, 0, 0.05);
transform: scale(1.1);
}
.dark .finance-table-expand-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
/* Task name styling for different levels */
.finance-table-task-name {
transition: all 0.2s ease-in-out;
}
.finance-table-task-name:hover {
color: #40a9ff !important;
}
/* Fixed cost input styling */
.fixed-cost-input {
border-radius: 4px;
}
.fixed-cost-input:focus {
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
/* Responsive adjustments for nested content */
@media (max-width: 768px) {
.finance-table-nested-task {
padding-left: 12px;
}
.finance-table-task-name {
font-size: 12px !important;
}
}

View File

@@ -0,0 +1,586 @@
import {
Drawer,
Select,
Typography,
Flex,
Button,
Input,
Table,
Tooltip,
Alert,
Space,
message,
Popconfirm,
DeleteOutlined,
ExclamationCircleFilled,
PlusOutlined,
} from '@/shared/antd-imports';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
deleteRateCard,
fetchRateCardById,
fetchRateCards,
toggleRatecardDrawer,
updateRateCard,
} from '@/features/finance/finance-slice';
import { RatecardType, IJobType } from '@/types/project/ratecard.types';
import { IJobTitlesViewModel } from '@/types/job.types';
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { colors } from '@/styles/colors';
import CreateJobTitlesDrawer from '@/features/settings/job/CreateJobTitlesDrawer';
import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/currencies';
import { IOrganization } from '@/types/admin-center/admin-center.types';
interface PaginationType {
current: number;
pageSize: number;
field: string;
order: string;
total: number;
pageSizeOptions: string[];
size: 'small' | 'default';
}
const RateCardDrawer = ({
type,
ratecardId,
onSaved,
}: {
type: 'create' | 'update';
ratecardId: string;
onSaved?: () => void;
}) => {
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
const [roles, setRoles] = useState<IJobType[]>([]);
const [initialRoles, setInitialRoles] = useState<IJobType[]>([]);
const [initialName, setInitialName] = useState<string>('Untitled Rate Card');
const [initialCurrency, setInitialCurrency] = useState<string>(DEFAULT_CURRENCY);
const [addingRowIndex, setAddingRowIndex] = useState<number | null>(null);
const [organization, setOrganization] = useState<IOrganization | null>(null);
const { t } = useTranslation('settings/ratecard-settings');
const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading);
const drawerRatecard = useAppSelector(state => state.financeReducer.drawerRatecard);
const isDrawerOpen = useAppSelector(state => state.financeReducer.isRatecardDrawerOpen);
const dispatch = useAppDispatch();
const [isAddingRole, setIsAddingRole] = useState(false);
const [selectedJobTitleId, setSelectedJobTitleId] = useState<string | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState('');
const [currency, setCurrency] = useState(DEFAULT_CURRENCY);
const [name, setName] = useState<string>('Untitled Rate Card');
const [jobTitles, setJobTitles] = useState<IJobTitlesViewModel>({});
const [pagination, setPagination] = useState<PaginationType>({
current: 1,
pageSize: 10000,
field: 'name',
order: 'desc',
total: 0,
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
size: 'small',
});
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
const [showUnsavedAlert, setShowUnsavedAlert] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const [isCreatingJobTitle, setIsCreatingJobTitle] = useState(false);
const [newJobTitleName, setNewJobTitleName] = useState('');
// Determine if we're using man days calculation method
const isManDaysMethod = organization?.calculation_method === 'man_days';
// Detect changes
const hasChanges = useMemo(() => {
const rolesChanged = JSON.stringify(roles) !== JSON.stringify(initialRoles);
const nameChanged = name !== initialName;
const currencyChanged = currency !== initialCurrency;
return rolesChanged || nameChanged || currencyChanged;
}, [roles, name, currency, initialRoles, initialName, initialCurrency]);
// Fetch organization details
useEffect(() => {
const fetchOrganization = async () => {
try {
const response = await adminCenterApiService.getOrganizationDetails();
if (response.done) {
setOrganization(response.body);
}
} catch (error) {
console.error('Failed to fetch organization details:', error);
}
};
if (isDrawerOpen) {
fetchOrganization();
}
}, [isDrawerOpen]);
const getJobTitles = useMemo(() => {
return async () => {
const response = await jobTitlesApiService.getJobTitles(
pagination.current,
pagination.pageSize,
pagination.field,
pagination.order,
searchQuery
);
if (response.done) {
setJobTitles(response.body);
setPagination(prev => ({ ...prev, total: response.body.total || 0 }));
}
};
}, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery]);
useEffect(() => {
getJobTitles();
}, []);
const selectedRatecard = ratecardsList.find(ratecard => ratecard.id === ratecardId);
useEffect(() => {
if (type === 'update' && ratecardId) {
dispatch(fetchRateCardById(ratecardId));
}
}, [type, ratecardId, dispatch]);
useEffect(() => {
if (type === 'update' && drawerRatecard) {
setRoles(drawerRatecard.jobRolesList || []);
setInitialRoles(drawerRatecard.jobRolesList || []);
setName(drawerRatecard.name || '');
setInitialName(drawerRatecard.name || '');
setCurrency(drawerRatecard.currency || DEFAULT_CURRENCY);
setInitialCurrency(drawerRatecard.currency || DEFAULT_CURRENCY);
}
}, [drawerRatecard, type]);
const handleAddAllRoles = () => {
if (!jobTitles.data) return;
const existingIds = new Set(roles.map(r => r.job_title_id));
const newRoles = jobTitles.data
.filter(jt => jt.id && !existingIds.has(jt.id))
.map(jt => ({
jobtitle: jt.name,
rate_card_id: ratecardId,
job_title_id: jt.id || '',
rate: 0,
man_day_rate: 0,
}));
const mergedRoles = [...roles, ...newRoles].filter(
(role, idx, arr) => arr.findIndex(r => r.job_title_id === role.job_title_id) === idx
);
setRoles(mergedRoles);
};
const handleAddRole = () => {
if (Object.keys(jobTitles).length === 0) {
// Allow inline job title creation
setIsCreatingJobTitle(true);
} else {
// Add a new empty role to the table
const newRole = {
jobtitle: '',
rate_card_id: ratecardId,
job_title_id: '',
rate: 0,
man_day_rate: 0,
};
setRoles([...roles, newRole]);
setAddingRowIndex(roles.length);
setIsAddingRole(true);
}
};
const handleCreateJobTitle = async () => {
if (!newJobTitleName.trim()) {
messageApi.warning(t('jobTitleNameRequired') || 'Job title name is required');
return;
}
try {
// Create the job title using the API
const response = await jobTitlesApiService.createJobTitle({
name: newJobTitleName.trim(),
});
if (response.done) {
// Refresh job titles
await getJobTitles();
// Create a new role with the newly created job title
const newRole = {
jobtitle: newJobTitleName.trim(),
rate_card_id: ratecardId,
job_title_id: response.body.id,
rate: 0,
man_day_rate: 0,
};
setRoles([...roles, newRole]);
// Reset creation state
setIsCreatingJobTitle(false);
setNewJobTitleName('');
messageApi.success(t('jobTitleCreatedSuccess') || 'Job title created successfully');
} else {
messageApi.error(t('jobTitleCreateError') || 'Failed to create job title');
}
} catch (error) {
console.error('Failed to create job title:', error);
messageApi.error(t('jobTitleCreateError') || 'Failed to create job title');
}
};
const handleCancelJobTitleCreation = () => {
setIsCreatingJobTitle(false);
setNewJobTitleName('');
};
const handleDeleteRole = (index: number) => {
const updatedRoles = [...roles];
updatedRoles.splice(index, 1);
setRoles(updatedRoles);
};
const handleSelectJobTitle = (jobTitleId: string) => {
if (roles.some(role => role.job_title_id === jobTitleId)) {
setIsAddingRole(false);
setSelectedJobTitleId(undefined);
return;
}
const jobTitle = jobTitles.data?.find(jt => jt.id === jobTitleId);
if (jobTitle) {
const newRole = {
jobtitle: jobTitle.name,
rate_card_id: ratecardId,
job_title_id: jobTitleId,
rate: 0,
man_day_rate: 0,
};
setRoles([...roles, newRole]);
}
setIsAddingRole(false);
setSelectedJobTitleId(undefined);
};
const handleSave = async () => {
if (type === 'update' && ratecardId) {
try {
const filteredRoles = roles.filter(role => role.jobtitle && role.jobtitle.trim() !== '');
await dispatch(
updateRateCard({
id: ratecardId,
body: {
name,
currency,
jobRolesList: filteredRoles,
},
}) as any
);
await dispatch(
fetchRateCards({
index: 1,
size: 10,
field: 'name',
order: 'desc',
search: '',
}) as any
);
if (onSaved) onSaved();
dispatch(toggleRatecardDrawer());
// Reset initial states after save
setInitialRoles(filteredRoles);
setInitialName(name);
setInitialCurrency(currency);
setShowUnsavedAlert(false);
} catch (error) {
console.error('Failed to update rate card', error);
}
}
};
const columns = [
{
title: t('jobTitleColumn'),
dataIndex: 'jobtitle',
render: (text: string, record: any, index: number) => {
if (index === addingRowIndex || index === editingRowIndex) {
return (
<Select
showSearch
autoFocus
placeholder={t('selectJobTitle')}
style={{ minWidth: 150 }}
value={record.job_title_id || undefined}
onChange={value => {
if (roles.some((role, idx) => role.job_title_id === value && idx !== index)) {
return;
}
const updatedRoles = [...roles];
const selectedJob = jobTitles.data?.find(jt => jt.id === value);
updatedRoles[index].job_title_id = value;
updatedRoles[index].jobtitle = selectedJob?.name || '';
setRoles(updatedRoles);
setEditingRowIndex(null);
setAddingRowIndex(null);
}}
onBlur={() => {
if (roles[index].job_title_id === '') {
handleDeleteRole(index);
}
setEditingRowIndex(null);
setAddingRowIndex(null);
}}
filterOption={(input, option) =>
String(option?.children || '')
.toLowerCase()
.includes(input.toLowerCase())
}
>
{jobTitles.data
?.filter(
jt => !roles.some((role, idx) => role.job_title_id === jt.id && idx !== index)
)
.map(jt => (
<Select.Option key={jt.id} value={jt.id}>
{jt.name}
</Select.Option>
))}
</Select>
);
}
return <span style={{ cursor: 'pointer' }}>{record.jobtitle}</span>;
},
},
{
title: isManDaysMethod
? `${t('ratePerManDayColumn', { ns: 'project-view-finance' }) || 'Rate per day'} (${currency})`
: `${t('ratePerHourColumn')} (${currency})`,
dataIndex: isManDaysMethod ? 'man_day_rate' : 'rate',
align: 'right' as const,
render: (text: number, record: any, index: number) => (
<Input
type="number"
value={isManDaysMethod ? (roles[index]?.man_day_rate ?? 0) : (roles[index]?.rate ?? 0)}
min={0}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
textAlign: 'right',
padding: 0,
}}
onChange={e => {
const newValue = parseInt(e.target.value, 10) || 0;
const updatedRoles = roles.map((role, idx) =>
idx === index
? {
...role,
...(isManDaysMethod ? { man_day_rate: newValue } : { rate: newValue }),
}
: role
);
setRoles(updatedRoles);
}}
/>
),
},
{
title: t('actionsColumn') || 'Actions',
dataIndex: 'actions',
render: (_: any, __: any, index: number) => (
<Popconfirm
title={t('deleteConfirmationTitle')}
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')}
onConfirm={async () => {
handleDeleteRole(index);
}}
>
<Tooltip title="Delete">
<Button size="small" icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
),
},
];
const handleDrawerClose = async () => {
if (!name || name.trim() === '') {
messageApi.open({
type: 'warning',
content: t('ratecardNameRequired') || 'Rate card name is required.',
});
return;
} else if (hasChanges) {
setShowUnsavedAlert(true);
} else if (name === 'Untitled Rate Card' && roles.length === 0) {
await dispatch(deleteRateCard(ratecardId));
dispatch(toggleRatecardDrawer());
} else {
dispatch(toggleRatecardDrawer());
}
};
const handleConfirmSave = async () => {
await handleSave();
setShowUnsavedAlert(false);
};
const handleConfirmDiscard = () => {
dispatch(toggleRatecardDrawer());
setRoles([]);
setName('Untitled Rate Card');
setCurrency(DEFAULT_CURRENCY);
setInitialRoles([]);
setInitialName('Untitled Rate Card');
setInitialCurrency(DEFAULT_CURRENCY);
setShowUnsavedAlert(false);
};
return (
<>
{contextHolder}
<Drawer
loading={drawerLoading}
onClose={handleDrawerClose}
title={
<Flex align="center" justify="space-between">
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
<Input
value={name}
placeholder={t('ratecardNamePlaceholder')}
style={{
fontWeight: 500,
fontSize: 16,
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
}}
onChange={e => {
setName(e.target.value);
}}
/>
</Typography.Text>
<Flex gap={8} align="center">
<Typography.Text>{t('currency')}</Typography.Text>
<Select
value={currency}
options={CURRENCY_OPTIONS}
onChange={value => setCurrency(value)}
/>
<Button onClick={handleAddAllRoles} type="default">
{t('addAllButton')}
</Button>
</Flex>
</Flex>
}
open={isDrawerOpen}
width={700}
footer={
<Flex justify="end" gap={16} style={{ marginTop: 16 }}>
<Button
style={{ marginBottom: 24 }}
onClick={handleSave}
type="primary"
disabled={name === '' || (name === 'Untitled Rate Card' && roles.length === 0)}
>
{t('saveButton')}
</Button>
</Flex>
}
>
{showUnsavedAlert && (
<Alert
message={t('unsavedChangesTitle') || 'Unsaved Changes'}
type="warning"
showIcon
closable
onClose={() => setShowUnsavedAlert(false)}
action={
<Space direction="horizontal">
<Button size="small" type="primary" onClick={handleConfirmSave}>
Save
</Button>
<Button size="small" danger onClick={handleConfirmDiscard}>
Discard
</Button>
</Space>
}
style={{ marginBottom: 16 }}
/>
)}
<Flex vertical gap={16}>
<Flex justify="space-between" align="center">
<Typography.Title level={5} style={{ margin: 0 }}>
{t('jobRolesTitle') || 'Job Roles'}
</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddRole}>
{t('addRoleButton')}
</Button>
</Flex>
<Table
dataSource={roles}
columns={columns}
rowKey="job_title_id"
pagination={false}
locale={{
emptyText: isCreatingJobTitle ? (
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
<Typography.Text strong>
{t('createNewJobTitle') || 'Create New Job Title'}
</Typography.Text>
<Flex gap={8} align="center">
<Input
placeholder={t('jobTitleNamePlaceholder') || 'Enter job title name'}
value={newJobTitleName}
onChange={e => setNewJobTitleName(e.target.value)}
onPressEnter={handleCreateJobTitle}
autoFocus
style={{ width: 200 }}
/>
<Button type="primary" onClick={handleCreateJobTitle}>
{t('createButton') || 'Create'}
</Button>
<Button onClick={handleCancelJobTitleCreation}>
{t('cancelButton') || 'Cancel'}
</Button>
</Flex>
</Flex>
) : (
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
<Typography.Text type="secondary">
{Object.keys(jobTitles).length === 0
? t('noJobTitlesAvailable')
: t('noRolesAdded')}
</Typography.Text>
</Flex>
),
}}
/>
{organization && (
<Alert
message={
isManDaysMethod
? `Organization is using man days calculation (${organization.hours_per_day || 8}h/day). Rates above represent daily rates.`
: 'Organization is using hourly calculation. Rates above represent hourly rates.'
}
type="info"
showIcon
style={{ marginTop: 16 }}
/>
)}
</Flex>
</Drawer>
<CreateJobTitlesDrawer />
</>
);
};
export default RateCardDrawer;

View File

@@ -0,0 +1,425 @@
import React, { useEffect, useState } from 'react';
import {
Avatar,
Button,
Input,
Popconfirm,
Table,
TableProps,
Select,
Flex,
InputRef,
DeleteOutlined,
} from '@/shared/antd-imports';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import CustomAvatar from '@/components/CustomAvatar';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { JobRoleType, RatecardType } from '@/types/project/ratecard.types';
import {
assignMemberToRateCardRole,
deleteProjectRateCardRoleById,
fetchProjectRateCardRoles,
insertProjectRateCardRole,
updateProjectRateCardRoleById,
updateProjectRateCardRolesByProjectId,
} from '@/features/finance/project-finance-slice';
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
import { projectsApiService } from '@/api/projects/projects.api.service';
import { IProjectMemberViewModel } from '@/types/projectMember.types';
import { useAuthService } from '@/hooks/useAuth';
import { canEditRateCard, canAddMembersToRateCard } from '@/utils/finance-permissions';
import RateCardAssigneeSelector from '../../project-ratecard/RateCardAssigneeSelector';
const RateCardTable: React.FC = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('project-view-finance');
const { projectId } = useParams();
// Redux state
const rolesRedux =
useAppSelector(state => state.projectFinanceRateCardReducer.rateCardRoles) || [];
const isLoading = useAppSelector(state => state.projectFinanceRateCardReducer.isLoading);
const currency = useAppSelector(
state => state.projectFinancesReducer.project?.currency || 'USD'
).toUpperCase();
const financeProject = useAppSelector(state => state.projectFinancesReducer.project);
// Get calculation method from project finance data
const calculationMethod = financeProject?.calculation_method || 'hourly';
const rateInputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
// Auth and permissions
const auth = useAuthService();
const currentSession = auth.getCurrentSession();
const { project } = useAppSelector(state => state.projectReducer);
const hasEditPermission = canEditRateCard(currentSession, project);
const canAddMembers = canAddMembersToRateCard(currentSession, project);
// Local state for editing
const [roles, setRoles] = useState<JobRoleType[]>(rolesRedux);
const [addingRow, setAddingRow] = useState<boolean>(false);
const [jobTitles, setJobTitles] = useState<RatecardType[]>([]);
const [members, setMembers] = useState<IProjectMemberViewModel[]>([]);
const [isLoadingMembers, setIsLoading] = useState(false);
const [focusRateIndex, setFocusRateIndex] = useState<number | null>(null);
const pagination = {
current: 1,
pageSize: 1000,
field: 'name',
order: 'asc',
};
const getProjectMembers = async () => {
if (!projectId) return;
setIsLoading(true);
try {
const res = await projectsApiService.getMembers(
projectId,
pagination.current,
pagination.pageSize,
pagination.field,
pagination.order,
null
);
if (res.done) {
setMembers(res.body?.data || []);
}
} catch (error) {
console.error('Error fetching members:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
getProjectMembers();
}, [projectId]);
// Fetch job titles for selection
useEffect(() => {
(async () => {
const res = await jobTitlesApiService.getJobTitles(1, 1000, 'name', 'asc', '');
setJobTitles(res.body?.data || []);
})();
}, []);
// Sync local roles with redux roles
useEffect(() => {
setRoles(rolesRedux);
}, [rolesRedux]);
// Fetch roles on mount
useEffect(() => {
if (projectId) {
dispatch(fetchProjectRateCardRoles(projectId));
}
}, [dispatch, projectId]);
useEffect(() => {
if (focusRateIndex !== null && rateInputRefs.current[focusRateIndex]) {
rateInputRefs.current[focusRateIndex]?.focus();
setFocusRateIndex(null);
}
}, [roles, focusRateIndex]);
// Add new role row
const handleAddRole = () => {
setAddingRow(true);
};
// Save all roles (bulk update)
const handleSaveAll = () => {
if (projectId) {
const filteredRoles = roles
.filter(
r => typeof r.job_title_id === 'string' && r.job_title_id && typeof r.rate !== 'undefined'
)
.map(r => ({
job_title_id: r.job_title_id as string,
jobtitle: r.jobtitle || r.name || '',
rate: Number(r.rate ?? 0),
man_day_rate: Number(r.man_day_rate ?? 0),
}));
dispatch(
updateProjectRateCardRolesByProjectId({ project_id: projectId, roles: filteredRoles })
);
}
};
// In handleSelectJobTitle, after successful insert, update the rate if needed
const handleSelectJobTitle = async (jobTitleId: string) => {
const jobTitle = jobTitles.find(jt => jt.id === jobTitleId);
if (!jobTitle || !projectId) return;
if (roles.some(r => r.job_title_id === jobTitleId)) return;
// Set the appropriate rate based on calculation method
const isManDays = calculationMethod === 'man_days';
const resultAction = await dispatch(
insertProjectRateCardRole({
project_id: projectId,
job_title_id: jobTitleId,
rate: 0, // Always initialize rate as 0
man_day_rate: isManDays ? 0 : undefined, // Only set man_day_rate for man_days mode
})
);
if (insertProjectRateCardRole.fulfilled.match(resultAction)) {
// Re-fetch roles and focus the last one (newly added)
dispatch(fetchProjectRateCardRoles(projectId)).then(() => {
setFocusRateIndex(roles.length); // The new row will be at the end
});
}
setAddingRow(false);
};
// Update handleRateChange to update the correct field
const handleRateChange = (value: string | number, index: number) => {
setRoles(prev =>
prev.map((role, idx) =>
idx === index
? {
...role,
...(calculationMethod === 'man_days'
? { man_day_rate: Number(value) }
: { rate: Number(value) }),
}
: role
)
);
};
// Handle delete
const handleDelete = (record: JobRoleType, index: number) => {
if (record.id) {
dispatch(deleteProjectRateCardRoleById(record.id));
} else {
setRoles(roles.filter((_, idx) => idx !== index));
}
};
// Handle member change
const handleMemberChange = async (memberId: string, rowIndex: number, record: JobRoleType) => {
if (!projectId || !record.id) return; // Ensure required IDs are present
try {
const resultAction = await dispatch(
assignMemberToRateCardRole({
project_id: projectId,
member_id: memberId,
project_rate_card_role_id: record.id,
})
);
if (assignMemberToRateCardRole.fulfilled.match(resultAction)) {
const updatedMembers = resultAction.payload; // Array of member IDs
setRoles(prev =>
prev.map((role, idx) => {
if (idx !== rowIndex) return role;
return { ...role, members: updatedMembers?.members || [] };
})
);
}
} catch (error) {
console.error('Error assigning member:', error);
}
};
// Separate function for updating rate if changed
const handleRateBlur = (value: string, index: number) => {
const isManDays = calculationMethod === 'man_days';
// Compare with Redux value, not local state
const reduxRole = rolesRedux[index];
const reduxValue = isManDays
? String(reduxRole?.man_day_rate ?? 0)
: String(reduxRole?.rate ?? 0);
if (value !== reduxValue) {
const payload = {
id: roles[index].id!,
body: {
job_title_id: String(roles[index].job_title_id),
// Only update the field that corresponds to the current calculation method
...(isManDays
? {
rate: String(reduxRole?.rate ?? 0), // Keep existing rate value
man_day_rate: String(value), // Update man_day_rate with new value
}
: {
rate: String(value), // Update rate with new value
man_day_rate: String(reduxRole?.man_day_rate ?? 0), // Keep existing man_day_rate value
}),
},
};
dispatch(updateProjectRateCardRoleById(payload));
}
};
const assignedMembers = roles
.flatMap(role => role.members || [])
.filter((memberId, index, self) => self.indexOf(memberId) === index);
// Columns
const columns: TableProps<JobRoleType>['columns'] = [
{
title: t('jobTitleColumn'),
dataIndex: 'jobtitle',
render: (text: string, record: JobRoleType, index: number) => {
if (addingRow && index === roles.length) {
return (
<Select
showSearch
autoFocus
placeholder={t('selectJobTitle')}
style={{ minWidth: 150 }}
value={record.job_title_id || undefined}
onChange={handleSelectJobTitle}
onBlur={() => setAddingRow(false)}
filterOption={(input, option) =>
((option?.children as unknown as string) || '')
.toLowerCase()
.includes(input.toLowerCase())
}
>
{jobTitles
.filter(jt => !roles.some(role => role.job_title_id === jt.id))
.map(jt => (
<Select.Option key={jt.id} value={jt.id!}>
{jt.name}
</Select.Option>
))}
</Select>
);
}
return <span>{text || record.name}</span>;
},
},
{
title: `${calculationMethod === 'man_days' ? t('ratePerManDayColumn') : t('ratePerHourColumn')} (${currency})`,
dataIndex: 'rate',
align: 'right',
render: (value: number, record: JobRoleType, index: number) => (
<Input
ref={(el: InputRef | null) => {
if (el) rateInputRefs.current[index] = el as unknown as HTMLInputElement;
}}
type="number"
value={
calculationMethod === 'man_days'
? (roles[index]?.man_day_rate ?? 0)
: (roles[index]?.rate ?? 0)
}
min={0}
disabled={!hasEditPermission}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
width: 80,
textAlign: 'right',
opacity: hasEditPermission ? 1 : 0.7,
cursor: hasEditPermission ? 'text' : 'not-allowed',
}}
onChange={
hasEditPermission
? e => handleRateChange((e.target as HTMLInputElement).value, index)
: undefined
}
onBlur={
hasEditPermission
? e => handleRateBlur((e.target as HTMLInputElement).value, index)
: undefined
}
onPressEnter={
hasEditPermission
? e => handleRateBlur((e.target as HTMLInputElement).value, index)
: undefined
}
/>
),
},
{
title: t('membersColumn'),
dataIndex: 'members',
render: (memberscol: string[] | null | undefined, record: JobRoleType, index: number) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, position: 'relative' }}>
<Avatar.Group>
{memberscol?.map((memberId, i) => {
const member = members.find(m => m.id === memberId);
return member ? (
<CustomAvatar key={i} avatarName={member.name || ''} size={26} />
) : null;
})}
</Avatar.Group>
{canAddMembers && (
<div>
<RateCardAssigneeSelector
projectId={projectId as string}
selectedMemberIds={memberscol || []}
onChange={(memberId: string) => handleMemberChange(memberId, index, record)}
memberlist={members}
assignedMembers={assignedMembers} // Pass assigned members here
/>
</div>
)}
</div>
),
},
{
title: t('actions'),
key: 'actions',
align: 'center',
render: (_: any, record: JobRoleType, index: number) =>
hasEditPermission ? (
<Popconfirm
title={t('deleteConfirm')}
onConfirm={() => handleDelete(record, index)}
okText={t('yes')}
cancelText={t('no')}
>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
) : null,
},
];
return (
<Table
dataSource={
addingRow
? [
...roles,
{
job_title_id: '',
jobtitle: '',
rate: 0,
members: [],
},
]
: roles
}
columns={columns}
rowKey={(record, idx) => record.id || record.job_title_id || String(idx)}
pagination={false}
loading={isLoading || isLoadingMembers}
footer={() => (
<Flex gap={0}>
{hasEditPermission && (
<Button type="dashed" onClick={handleAddRole} style={{ width: 'fit-content' }}>
{t('addRoleButton')}
</Button>
)}
{/* <Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveAll}
disabled={roles.length === 0}
>
{t('saveButton') || 'Save'}
</Button> */}
</Flex>
)}
/>
);
};
export default RateCardTable;

View File

@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from 'react';
import { InputRef } from 'antd/es/input';
import Dropdown from 'antd/es/dropdown';
import Card from 'antd/es/card';
import List from 'antd/es/list';
import Input from 'antd/es/input';
import Checkbox from 'antd/es/checkbox';
import Button from 'antd/es/button';
import Empty from 'antd/es/empty';
import { PlusOutlined } from '@ant-design/icons';
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
import { IProjectMemberViewModel } from '@/types/projectMember.types';
interface RateCardAssigneeSelectorProps {
projectId: string;
onChange?: (memberId: string) => void;
selectedMemberIds?: string[];
memberlist?: IProjectMemberViewModel[];
}
const RateCardAssigneeSelector = ({
projectId,
onChange,
selectedMemberIds = [],
memberlist = [],
assignedMembers = [], // New prop: List of all assigned member IDs across all job titles
}: RateCardAssigneeSelectorProps & { assignedMembers: string[] }) => {
const membersInputRef = useRef<InputRef>(null);
const [searchQuery, setSearchQuery] = useState('');
const [members, setMembers] = useState<IProjectMemberViewModel[]>(memberlist);
useEffect(() => {
setMembers(memberlist);
}, [memberlist]);
const filteredMembers = members.filter(member =>
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
const dropdownContent = (
<Card styles={{ body: { padding: 8 } }}>
<Input
ref={membersInputRef}
value={searchQuery}
onChange={e => setSearchQuery(e.currentTarget.value)}
placeholder="Search members"
/>
<List style={{ padding: 0, maxHeight: 200, overflow: 'auto' }}>
{filteredMembers.length ? (
filteredMembers.map(member => {
const isAssignedToAnotherJobTitle =
assignedMembers.includes(member.id || '') &&
!selectedMemberIds.includes(member.id || ''); // Check if the member is assigned elsewhere
return (
<List.Item
key={member.id}
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
padding: '4px 8px',
border: 'none',
opacity: member.pending_invitation || isAssignedToAnotherJobTitle ? 0.5 : 1,
justifyContent: 'flex-start',
textAlign: 'left',
}}
>
<Checkbox
checked={selectedMemberIds.includes(member.id || '')}
disabled={member.pending_invitation || isAssignedToAnotherJobTitle}
onChange={() => onChange?.(member.id || '')}
/>
<SingleAvatar
avatarUrl={member.avatar_url}
name={member.name}
email={member.email}
/>
<span>{member.name}</span>
</List.Item>
);
})
) : (
<Empty description="No members found" />
)}
</List>
</Card>
);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => dropdownContent}
onOpenChange={open => {
if (open) setTimeout(() => membersInputRef.current?.focus(), 0);
}}
>
<Button
type="dashed"
shape="circle"
size="small"
icon={<PlusOutlined style={{ fontSize: 12 }} />}
/>
</Dropdown>
);
};
export default RateCardAssigneeSelector;