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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user