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

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

View File

@@ -0,0 +1,739 @@
import {
Button,
ConfigProvider,
Flex,
Select,
Typography,
message,
Alert,
Card,
Row,
Col,
Statistic,
Tooltip,
Input,
Modal,
CaretDownFilled,
DownOutlined,
CalculatorOutlined,
SettingOutlined,
EditOutlined,
} from '@/shared/antd-imports';
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import {
fetchProjectFinances,
setActiveTab,
setActiveGroup,
updateProjectFinanceCurrency,
fetchProjectFinancesSilent,
setBillableFilter,
} from '@/features/projects/finance/project-finance.slice';
import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice';
import { updateProjectCurrency, getProject } from '@/features/project/project.slice';
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
import { RootState } from '@/app/store';
import FinanceTableWrapper from '@/components/projects/project-finance/finance-table-wrapper/FinanceTableWrapper';
import ImportRatecardsDrawer from '@/components/projects/import-ratecards-drawer/ImportRateCardsDrawer';
import { useAuthService } from '@/hooks/useAuth';
import { hasFinanceEditPermission } from '@/utils/finance-permissions';
import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/currencies';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import RateCardTable from '@/components/projects/project-finance/ratecard-table/RateCardTable';
import ProjectBudgetSettingsDrawer from '@/components/projects/project-budget-settings-drawer/ProjectBudgetSettingsDrawer';
const ProjectViewFinance = () => {
const { projectId } = useParams<{ projectId: string }>();
const dispatch = useAppDispatch();
const { t } = useTranslation('project-view-finance');
const [exporting, setExporting] = useState(false);
const [updatingCurrency, setUpdatingCurrency] = useState(false);
const [updatingBudget, setUpdatingBudget] = useState(false);
const [budgetModalVisible, setBudgetModalVisible] = useState(false);
const [budgetValue, setBudgetValue] = useState<string>('');
const [budgetSettingsDrawerVisible, setBudgetSettingsDrawerVisible] = useState(false);
const { socket } = useSocket();
const {
activeTab,
activeGroup,
billableFilter,
loading,
taskGroups,
project: financeProject,
} = useAppSelector((state: RootState) => state.projectFinancesReducer);
const { refreshTimestamp, project } = useAppSelector((state: RootState) => state.projectReducer);
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
// Auth and permissions
const auth = useAuthService();
const currentSession = auth.getCurrentSession();
const hasEditPermission = hasFinanceEditPermission(currentSession, project);
// Get project-specific currency from finance API response, fallback to project reducer, then default
const projectCurrency = (
financeProject?.currency ||
project?.currency ||
DEFAULT_CURRENCY
).toLowerCase();
// Show loading state for currency selector until finance data is loaded
const currencyLoading = loading || updatingCurrency || !financeProject;
// Calculate project budget statistics
const budgetStatistics = useMemo(() => {
if (!taskGroups || taskGroups.length === 0) {
const manualBudget = project?.budget || 0;
const hasManualBudget = !!(project?.budget && project.budget > 0);
return {
totalEstimatedHours: 0,
totalFixedCost: 0,
totalTimeBasedCost: 0,
totalBudget: manualBudget,
totalActualCost: 0,
totalVariance: manualBudget,
budgetUtilization: 0,
manualBudget,
hasManualBudget,
};
}
// Optimized calculation that avoids double counting in nested hierarchies
const calculateTaskTotalsFlat = (tasks: any[]): any => {
let totals = {
totalEstimatedHours: 0,
totalFixedCost: 0,
totalTimeBasedCost: 0,
};
for (const task of tasks) {
if (task.sub_tasks && task.sub_tasks.length > 0) {
totals.totalEstimatedHours += (task.estimated_seconds || 0) / 3600;
totals.totalFixedCost += task.fixed_cost || 0;
totals.totalTimeBasedCost += task.actual_cost_from_logs || 0;
} else {
totals.totalEstimatedHours += (task.estimated_seconds || 0) / 3600;
totals.totalFixedCost += task.fixed_cost || 0;
totals.totalTimeBasedCost += task.actual_cost_from_logs || 0;
}
}
return totals;
};
const totals = taskGroups.reduce(
(
acc: { totalEstimatedHours: any; totalFixedCost: any; totalTimeBasedCost: any },
group: { tasks: any[] }
) => {
const groupTotals = calculateTaskTotalsFlat(group.tasks);
return {
totalEstimatedHours: acc.totalEstimatedHours + groupTotals.totalEstimatedHours,
totalFixedCost: acc.totalFixedCost + groupTotals.totalFixedCost,
totalTimeBasedCost: acc.totalTimeBasedCost + groupTotals.totalTimeBasedCost,
};
},
{
totalEstimatedHours: 0,
totalFixedCost: 0,
totalTimeBasedCost: 0,
}
);
const manualBudget = project?.budget || 0;
const hasManualBudget = !!(project?.budget && project.budget > 0);
const totalActualCost = totals.totalTimeBasedCost + totals.totalFixedCost;
const totalVariance = manualBudget - totalActualCost;
const budgetUtilization = manualBudget > 0 ? (totalActualCost / manualBudget) * 100 : 0;
return {
totalEstimatedHours: totals.totalEstimatedHours,
totalFixedCost: totals.totalFixedCost,
totalTimeBasedCost: totals.totalTimeBasedCost,
totalBudget: manualBudget,
totalActualCost,
totalVariance,
budgetUtilization,
manualBudget,
hasManualBudget,
};
}, [taskGroups, project?.budget]);
// Silent refresh function for socket events
const refreshFinanceData = useCallback(
(resetExpansions = false) => {
if (projectId) {
dispatch(
fetchProjectFinancesSilent({
projectId,
groupBy: activeGroup,
billableFilter,
resetExpansions,
})
);
}
},
[projectId, activeGroup, billableFilter, dispatch]
);
// Socket event handlers
const handleTaskEstimationChange = useCallback(() => {
refreshFinanceData(true); // Reset expansions when task estimation changes
}, [refreshFinanceData]);
const handleTaskTimerStop = useCallback(() => {
refreshFinanceData(true); // Reset expansions when timer stops (time logged changes)
}, [refreshFinanceData]);
const handleTaskProgressUpdate = useCallback(() => {
refreshFinanceData(true); // Reset expansions when task progress updates
}, [refreshFinanceData]);
const handleTaskBillableChange = useCallback(() => {
refreshFinanceData(true); // Reset expansions when billable status changes
}, [refreshFinanceData]);
// Additional socket event handlers for task drawer updates
const handleTaskNameChange = useCallback(() => {
refreshFinanceData(true); // Reset expansions when task name changes from drawer
}, [refreshFinanceData]);
const handleTaskStatusChange = useCallback(() => {
refreshFinanceData(true); // Reset expansions when task status changes from drawer
}, [refreshFinanceData]);
const handleTaskPriorityChange = useCallback(() => {
refreshFinanceData(true); // Reset expansions when task priority changes from drawer
}, [refreshFinanceData]);
const handleTaskPhaseChange = useCallback(() => {
refreshFinanceData(true); // Reset expansions when task phase changes from drawer
}, [refreshFinanceData]);
const handleTaskAssigneesChange = useCallback(() => {
refreshFinanceData(true); // Reset expansions when task assignees change from drawer
}, [refreshFinanceData]);
const handleTaskStartDateChange = useCallback(() => {
refreshFinanceData(true); // Reset expansions when task start date changes from drawer
}, [refreshFinanceData]);
const handleTaskEndDateChange = useCallback(() => {
refreshFinanceData(true); // Reset expansions when task end date changes from drawer
}, [refreshFinanceData]);
const handleProjectUpdatesAvailable = useCallback(() => {
refreshFinanceData(true); // Reset expansions when project updates are available (includes task deletion)
}, [refreshFinanceData]);
useEffect(() => {
if (projectId) {
dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup, billableFilter }));
}
}, [projectId, activeGroup, billableFilter, dispatch, refreshTimestamp]);
// Socket event listeners for finance data refresh
useEffect(() => {
if (!socket) return;
const eventHandlers = [
{
event: SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(),
handler: handleTaskEstimationChange,
},
{ event: SocketEvents.TASK_TIMER_STOP.toString(), handler: handleTaskTimerStop },
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdate },
{ event: SocketEvents.TASK_BILLABLE_CHANGE.toString(), handler: handleTaskBillableChange },
// Task drawer update events
{ event: SocketEvents.TASK_NAME_CHANGE.toString(), handler: handleTaskNameChange },
{ event: SocketEvents.TASK_STATUS_CHANGE.toString(), handler: handleTaskStatusChange },
{ event: SocketEvents.TASK_PRIORITY_CHANGE.toString(), handler: handleTaskPriorityChange },
{ event: SocketEvents.TASK_PHASE_CHANGE.toString(), handler: handleTaskPhaseChange },
{ event: SocketEvents.TASK_ASSIGNEES_CHANGE.toString(), handler: handleTaskAssigneesChange },
{ event: SocketEvents.TASK_START_DATE_CHANGE.toString(), handler: handleTaskStartDateChange },
{ event: SocketEvents.TASK_END_DATE_CHANGE.toString(), handler: handleTaskEndDateChange },
{
event: SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(),
handler: handleProjectUpdatesAvailable,
},
];
// Register all event listeners
eventHandlers.forEach(({ event, handler }) => {
socket.on(event, handler);
});
// Cleanup function
return () => {
eventHandlers.forEach(({ event, handler }) => {
socket.off(event, handler);
});
};
}, [
socket,
handleTaskEstimationChange,
handleTaskTimerStop,
handleTaskProgressUpdate,
handleTaskBillableChange,
handleTaskNameChange,
handleTaskStatusChange,
handleTaskPriorityChange,
handleTaskPhaseChange,
handleTaskAssigneesChange,
handleTaskStartDateChange,
handleTaskEndDateChange,
handleProjectUpdatesAvailable,
]);
const handleExport = async () => {
if (!projectId) {
message.error('Project ID not found');
return;
}
try {
setExporting(true);
const blob = await projectFinanceApiService.exportFinanceData(
projectId,
activeGroup,
billableFilter
);
const projectName = project?.name || 'Unknown_Project';
const sanitizedProjectName = projectName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_');
const dateTime = new Date().toISOString().replace(/[:.]/g, '-').split('T');
const date = dateTime[0];
const time = dateTime[1].split('.')[0];
const filename = `${sanitizedProjectName}_Finance_Data_${date}_${time}.xlsx`;
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success('Finance data exported successfully');
} catch (error) {
console.error('Export failed:', error);
message.error('Failed to export finance data');
} finally {
setExporting(false);
}
};
const handleCurrencyChange = async (currency: string) => {
if (!projectId || !hasEditPermission) {
message.error('You do not have permission to change the project currency');
return;
}
try {
setUpdatingCurrency(true);
const upperCaseCurrency = currency.toUpperCase();
await projectFinanceApiService.updateProjectCurrency(projectId, upperCaseCurrency);
// Update both global currency state and project-specific currency
dispatch(changeCurrency(currency));
dispatch(updateProjectCurrency(upperCaseCurrency));
dispatch(updateProjectFinanceCurrency(upperCaseCurrency));
message.success('Project currency updated successfully');
} catch (error) {
console.error('Currency update failed:', error);
message.error('Failed to update project currency');
} finally {
setUpdatingCurrency(false);
}
};
const handleBudgetUpdate = async () => {
if (!projectId || !hasEditPermission) {
message.error('You do not have permission to change the project budget');
return;
}
const budget = parseFloat(budgetValue);
if (isNaN(budget) || budget < 0) {
message.error('Please enter a valid budget amount');
return;
}
try {
setUpdatingBudget(true);
await projectFinanceApiService.updateProjectBudget(projectId, budget);
// Refresh the project data to get updated budget
refreshFinanceData();
// Also refresh the main project data to update budget statistics
dispatch(getProject(projectId));
message.success('Project budget updated successfully');
setBudgetModalVisible(false);
} catch (error) {
console.error('Budget update failed:', error);
message.error('Failed to update project budget');
} finally {
setUpdatingBudget(false);
}
};
const handleBudgetEdit = () => {
setBudgetValue((project?.budget || 0).toString());
setBudgetModalVisible(true);
};
const handleBudgetCancel = () => {
setBudgetModalVisible(false);
setBudgetValue('');
};
const groupDropdownMenuItems = [
{ key: 'status', value: 'status', label: t('statusText') },
{ key: 'priority', value: 'priority', label: t('priorityText') },
{
key: 'phases',
value: 'phases',
label: phaseList.length > 0 ? project?.phase_label || t('phaseText') : t('phaseText'),
},
];
const billableFilterOptions = [
{ key: 'billable', value: 'billable', label: t('billableOnlyText') },
{ key: 'non-billable', value: 'non-billable', label: t('nonBillableOnlyText') },
{ key: 'all', value: 'all', label: t('allTasksText') },
];
return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
{/* Finance Header */}
<ConfigProvider wave={{ disabled: true }}>
<Flex gap={16} align="center" justify="space-between">
<Flex gap={16} align="center">
<Flex>
<Button
className={`${activeTab === 'finance' && 'border-[#1890ff] text-[#1890ff]'} rounded-r-none`}
onClick={() => dispatch(setActiveTab('finance'))}
>
{t('financeText')}
</Button>
<Button
className={`${activeTab === 'ratecard' && 'border-[#1890ff] text-[#1890ff]'} rounded-l-none`}
onClick={() => dispatch(setActiveTab('ratecard'))}
>
{t('ratecardSingularText')}
</Button>
</Flex>
{activeTab === 'finance' && (
<Flex align="center" gap={16} style={{ marginInlineStart: 12 }}>
<Flex align="center" gap={4}>
{t('groupByText')}:
<Select
value={activeGroup}
options={groupDropdownMenuItems}
onChange={value =>
dispatch(setActiveGroup(value as 'status' | 'priority' | 'phases'))
}
suffixIcon={<CaretDownFilled />}
/>
</Flex>
<Flex align="center" gap={4}>
{t('filterText')}:
<Select
value={billableFilter}
options={billableFilterOptions}
onChange={value =>
dispatch(setBillableFilter(value as 'all' | 'billable' | 'non-billable'))
}
suffixIcon={<CaretDownFilled />}
style={{ minWidth: 140 }}
/>
</Flex>
</Flex>
)}
</Flex>
{activeTab === 'finance' ? (
<Button
type="primary"
icon={<DownOutlined />}
iconPosition="end"
loading={exporting}
onClick={handleExport}
>
{t('exportButton')}
</Button>
) : (
<Flex gap={8} align="center">
<Flex gap={8} align="center">
<Typography.Text>{t('currencyText')}</Typography.Text>
<Select
value={projectCurrency}
loading={currencyLoading}
disabled={!hasEditPermission}
options={CURRENCY_OPTIONS}
onChange={handleCurrencyChange}
/>
</Flex>
<Button type="primary" onClick={() => dispatch(toggleImportRatecardsDrawer())}>
{t('importButton')}
</Button>
</Flex>
)}
</Flex>
</ConfigProvider>
{/* Tab Content */}
{activeTab === 'finance' ? (
<div>
{!hasEditPermission && (
<Alert
message="Limited Access"
description="You can view finance data but cannot edit fixed costs. Only project managers, team admins, and team owners can make changes."
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)}
{/* Budget Statistics */}
<Card
title={
<Flex align="center" justify="space-between">
<Flex align="center" gap={8}>
<CalculatorOutlined />
<Typography.Text strong>{t('projectBudgetOverviewText')}</Typography.Text>
{!budgetStatistics.hasManualBudget && (
<Typography.Text type="warning" style={{ fontSize: '12px' }}>
{t('budgetStatistics.noManualBudgetSet')}
</Typography.Text>
)}
</Flex>
{hasEditPermission && (
<Tooltip title="Budget & Calculation Settings">
<Button
type="text"
icon={<SettingOutlined />}
size="small"
onClick={() => setBudgetSettingsDrawerVisible(true)}
style={{ color: '#666' }}
/>
</Tooltip>
)}
</Flex>
}
style={{ marginBottom: 16 }}
loading={loading}
size="small"
>
<Row gutter={[12, 8]}>
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
<Tooltip title={t('budgetOverviewTooltips.manualBudget')}>
<div style={{ textAlign: 'center', position: 'relative' }}>
<Statistic
title={
<Flex align="center" justify="center" gap={4}>
<span>{t('budgetStatistics.manualBudget')}</span>
{hasEditPermission && (
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={handleBudgetEdit}
style={{
padding: '0 4px',
height: '16px',
fontSize: '12px',
color: '#666',
}}
/>
)}
</Flex>
}
value={budgetStatistics.totalBudget}
precision={2}
prefix={projectCurrency.toUpperCase()}
valueStyle={{
color: budgetStatistics.hasManualBudget ? '#1890ff' : '#d9d9d9',
fontSize: '16px',
}}
/>
</div>
</Tooltip>
</Col>
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
<Tooltip title={t('budgetOverviewTooltips.totalActualCost')}>
<Statistic
title={t('budgetStatistics.totalActualCost')}
value={budgetStatistics.totalActualCost}
precision={2}
prefix={projectCurrency.toUpperCase()}
valueStyle={{ color: '#52c41a', fontSize: '16px' }}
style={{ textAlign: 'center' }}
/>
</Tooltip>
</Col>
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
<Tooltip title={t('budgetOverviewTooltips.variance')}>
<Statistic
title={t('budgetStatistics.variance')}
value={Math.abs(budgetStatistics.totalVariance)}
precision={2}
prefix={budgetStatistics.totalVariance >= 0 ? '+' : '-'}
suffix={` ${projectCurrency.toUpperCase()}`}
valueStyle={{
color:
budgetStatistics.totalVariance < 0
? '#ff4d4f'
: budgetStatistics.totalVariance > 0
? '#52c41a'
: '#666666',
fontSize: '16px',
fontWeight: 'bold',
}}
style={{ textAlign: 'center' }}
/>
</Tooltip>
</Col>
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
<Tooltip title={t('budgetOverviewTooltips.utilization')}>
<Statistic
title={t('budgetStatistics.budgetUtilization')}
value={budgetStatistics.budgetUtilization}
precision={1}
suffix="%"
valueStyle={{
color:
budgetStatistics.budgetUtilization > 100
? '#ff4d4f'
: budgetStatistics.budgetUtilization > 80
? '#faad14'
: '#52c41a',
fontSize: '16px',
}}
style={{ textAlign: 'center' }}
/>
</Tooltip>
</Col>
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
<Tooltip title={t('budgetOverviewTooltips.estimatedHours')}>
<Statistic
title={t('budgetStatistics.estimatedHours')}
value={budgetStatistics.totalEstimatedHours}
precision={1}
suffix="h"
valueStyle={{ color: '#722ed1', fontSize: '16px' }}
style={{ textAlign: 'center' }}
/>
</Tooltip>
</Col>
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
<Tooltip title={t('budgetOverviewTooltips.fixedCosts')}>
<Statistic
title={t('budgetStatistics.fixedCosts')}
value={budgetStatistics.totalFixedCost}
precision={2}
prefix={projectCurrency.toUpperCase()}
valueStyle={{ color: '#fa8c16', fontSize: '16px' }}
style={{ textAlign: 'center' }}
/>
</Tooltip>
</Col>
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
<Tooltip title={t('budgetOverviewTooltips.timeBasedCost')}>
<Statistic
title={t('budgetStatistics.timeBasedCost')}
value={budgetStatistics.totalTimeBasedCost}
precision={2}
prefix={projectCurrency.toUpperCase()}
valueStyle={{ color: '#13c2c2', fontSize: '16px' }}
style={{ textAlign: 'center' }}
/>
</Tooltip>
</Col>
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
<Tooltip title={t('budgetOverviewTooltips.remainingBudget')}>
<Statistic
title={t('budgetStatistics.remainingBudget')}
value={Math.abs(budgetStatistics.totalVariance)}
precision={2}
prefix={budgetStatistics.totalVariance >= 0 ? '+' : '-'}
suffix={` ${projectCurrency.toUpperCase()}`}
valueStyle={{
color: budgetStatistics.totalVariance >= 0 ? '#52c41a' : '#ff4d4f',
fontSize: '16px',
fontWeight: 'bold',
}}
style={{ textAlign: 'center' }}
/>
</Tooltip>
</Col>
</Row>
</Card>
<FinanceTableWrapper activeTablesList={taskGroups} loading={loading} />
</div>
) : (
<Flex vertical gap={8}>
{!hasEditPermission && (
<Alert
message="Limited Access"
description="You can view rate card data but cannot edit rates or manage member assignments. Only project managers, team admins, and team owners can make changes."
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<RateCardTable />
<Typography.Text type="danger" style={{ display: 'block', marginTop: '10px' }}>
{t('ratecardImportantNotice')}
</Typography.Text>
<ImportRatecardsDrawer />
</Flex>
)}
{/* Budget Edit Modal */}
<Modal
title={t('budgetModal.title')}
open={budgetModalVisible}
onOk={handleBudgetUpdate}
onCancel={handleBudgetCancel}
confirmLoading={updatingBudget}
okText={t('budgetModal.saveButton')}
cancelText={t('budgetModal.cancelButton')}
>
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary">{t('budgetModal.description')}</Typography.Text>
</div>
<Input
type="number"
min={0}
step={0.01}
value={budgetValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setBudgetValue(e.target.value)}
placeholder={t('budgetModal.placeholder')}
prefix={projectCurrency.toUpperCase()}
size="large"
autoFocus
/>
</Modal>
{/* Budget Settings Drawer */}
<ProjectBudgetSettingsDrawer
visible={budgetSettingsDrawerVisible}
onClose={() => setBudgetSettingsDrawerVisible(false)}
projectId={projectId!}
/>
</Flex>
);
};
export default ProjectViewFinance;

View File

@@ -0,0 +1,287 @@
import {
Button,
Card,
Empty,
Flex,
Input,
Popconfirm,
Table,
TableProps,
Tooltip,
Typography,
message,
DeleteOutlined,
EditOutlined,
ExclamationCircleFilled,
SearchOutlined,
} from '@/shared/antd-imports';
import type { TablePaginationConfig } from 'antd/es/table';
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { colors } from '../../../styles/colors';
import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '../../../hooks/useDoumentTItle';
import { durationDateFormat } from '../../../utils/durationDateFormat';
import {
createRateCard,
deleteRateCard,
fetchRateCardById,
toggleRatecardDrawer,
} from '../../../features/finance/finance-slice';
import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service';
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
import { RatecardType } from '@/types/project/ratecard.types';
import { useAppSelector } from '../../../hooks/useAppSelector';
import RateCardDrawer from '@/components/projects/project-finance/rate-card-drawer/RateCardDrawer';
interface PaginationType {
current: number;
pageSize: number;
field: string;
order: string;
total: number;
pageSizeOptions: string[];
size: 'small' | 'default';
}
const RatecardSettings: React.FC = () => {
const { t } = useTranslation('/settings/ratecard-settings');
const dispatch = useAppDispatch();
const [messageApi, contextHolder] = message.useMessage();
useDocumentTitle('Manage Rate Cards');
// Redux state
const isDrawerOpen = useAppSelector(state => state.financeReducer.isRatecardDrawerOpen);
// Local state
const [loading, setLoading] = useState(false);
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedRatecardId, setSelectedRatecardId] = useState<string | null>(null);
const [ratecardDrawerType, setRatecardDrawerType] = useState<'create' | 'update'>('create');
const [pagination, setPagination] = useState<PaginationType>({
current: 1,
pageSize: DEFAULT_PAGE_SIZE,
field: 'name',
order: 'desc',
total: 0,
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
size: 'small',
});
// Memoized filtered data
const filteredRatecardsData = useMemo(() => {
return ratecardsList.filter(item =>
item.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [ratecardsList, searchQuery]);
// Fetch rate cards with error handling
const fetchRateCards = useCallback(async () => {
setLoading(true);
try {
const response = await rateCardApiService.getRateCards(
pagination.current,
pagination.pageSize,
pagination.field,
pagination.order,
searchQuery
);
if (response.done) {
setRatecardsList(response.body.data || []);
setPagination(prev => ({ ...prev, total: response.body.total || 0 }));
} else {
messageApi.error(t('fetchError') || 'Failed to fetch rate cards');
}
} catch (error) {
console.error('Failed to fetch rate cards:', error);
messageApi.error(t('fetchError') || 'Failed to fetch rate cards');
} finally {
setLoading(false);
}
}, [
pagination.current,
pagination.pageSize,
pagination.field,
pagination.order,
searchQuery,
t,
messageApi,
]);
// Fetch rate cards when drawer state changes
useEffect(() => {
fetchRateCards();
}, [fetchRateCards, isDrawerOpen]);
// Handle rate card creation
const handleRatecardCreate = useCallback(async () => {
try {
const resultAction = await dispatch(
createRateCard({
name: 'Untitled Rate Card',
jobRolesList: [],
currency: 'LKR',
}) as any
);
if (createRateCard.fulfilled.match(resultAction)) {
const created = resultAction.payload;
setRatecardDrawerType('update');
setSelectedRatecardId(created.id ?? null);
dispatch(toggleRatecardDrawer());
} else {
messageApi.error(t('createError') || 'Failed to create rate card');
}
} catch (error) {
console.error('Failed to create rate card:', error);
messageApi.error(t('createError') || 'Failed to create rate card');
}
}, [dispatch, t, messageApi]);
// Handle rate card update
const handleRatecardUpdate = useCallback(
(id: string) => {
setRatecardDrawerType('update');
dispatch(fetchRateCardById(id));
setSelectedRatecardId(id);
dispatch(toggleRatecardDrawer());
},
[dispatch]
);
// Handle table changes
const handleTableChange = useCallback(
(
newPagination: TablePaginationConfig,
filters: Record<string, FilterValue | null>,
sorter: SorterResult<RatecardType> | SorterResult<RatecardType>[]
) => {
const sorterResult = Array.isArray(sorter) ? sorter[0] : sorter;
setPagination(prev => ({
...prev,
current: newPagination.current || 1,
pageSize: newPagination.pageSize || DEFAULT_PAGE_SIZE,
field: (sorterResult?.field as string) || 'name',
order: sorterResult?.order === 'ascend' ? 'asc' : 'desc',
}));
},
[]
);
// Table columns configuration
const columns: TableProps['columns'] = useMemo(
() => [
{
key: 'rateName',
title: t('nameColumn'),
render: (record: RatecardType) => (
<Typography.Text
style={{ color: '#1890ff', cursor: 'pointer' }}
onClick={() => record.id && handleRatecardUpdate(record.id)}
>
{record.name}
</Typography.Text>
),
},
{
key: 'created',
title: t('createdColumn'),
render: (record: RatecardType) => (
<Typography.Text onClick={() => record.id && handleRatecardUpdate(record.id)}>
{durationDateFormat(record.created_at)}
</Typography.Text>
),
},
{
key: 'actionBtns',
width: 80,
render: (record: RatecardType) => (
<Flex gap={8} className="hidden group-hover:flex">
<Tooltip title={t('editTooltip') || 'Edit'}>
<Button
size="small"
icon={<EditOutlined />}
onClick={() => record.id && handleRatecardUpdate(record.id)}
/>
</Tooltip>
<Popconfirm
title={t('deleteConfirmationTitle')}
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')}
onConfirm={async () => {
setLoading(true);
try {
if (record.id) {
await dispatch(deleteRateCard(record.id));
await fetchRateCards();
}
} catch (error) {
console.error('Failed to delete rate card:', error);
} finally {
setLoading(false);
}
}}
>
<Tooltip title={t('deleteTooltip') || 'Delete'}>
<Button shape="default" icon={<DeleteOutlined />} size="small" />
</Tooltip>
</Popconfirm>
</Flex>
),
},
],
[t, handleRatecardUpdate, fetchRateCards, dispatch, messageApi]
);
return (
<>
{contextHolder}
<Card
style={{ width: '100%' }}
title={
<Flex justify="flex-end" align="center" gap={8}>
<Input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
style={{ maxWidth: 232 }}
suffix={<SearchOutlined />}
/>
<Button type="primary" onClick={handleRatecardCreate}>
{t('createRatecard')}
</Button>
</Flex>
}
>
<Table
loading={loading}
className="custom-two-colors-row-table"
dataSource={filteredRatecardsData}
columns={columns}
rowKey="id"
pagination={{
...pagination,
showSizeChanger: true,
onChange: (page, pageSize) =>
setPagination(prev => ({ ...prev, current: page, pageSize })),
}}
onChange={handleTableChange}
rowClassName="group"
locale={{
emptyText: <Empty description={t('noRatecardsFound')} />,
}}
/>
<RateCardDrawer
type={ratecardDrawerType}
ratecardId={selectedRatecardId || ''}
onSaved={fetchRateCards}
/>
</Card>
</>
);
};
export default RatecardSettings;