feat(project-finance): add billable filter functionality to project finance queries
- Introduced a `billable_filter` query parameter to filter tasks based on their billable status (billable, non-billable, or all). - Updated the project finance controller to construct SQL queries with billable conditions based on the filter. - Enhanced the frontend components to support billable filtering in project finance views and exports. - Added corresponding translations for filter options in multiple languages. - Refactored related API services to accommodate the new filtering logic.
This commit is contained in:
@@ -1,43 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, Col, Row } from "antd";
|
||||
|
||||
import { IProjectFinanceGroup } from "../../../../../types/project/project-finance.types";
|
||||
import FinanceTable from "./finance-table/finance-table";
|
||||
|
||||
interface Props {
|
||||
activeTablesList: IProjectFinanceGroup[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const FinanceTableWrapper: React.FC<Props> = ({ activeTablesList, loading }) => {
|
||||
const { isDarkMode } = useThemeContext();
|
||||
|
||||
const getTableColor = (table: IProjectFinanceGroup) => {
|
||||
return isDarkMode ? table.color_code_dark : table.color_code;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="finance-table-wrapper">
|
||||
<Row gutter={[16, 16]}>
|
||||
{activeTablesList.map((table) => (
|
||||
<Col key={table.group_id} xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Card
|
||||
className="finance-table-card"
|
||||
style={{
|
||||
borderTop: `3px solid ${getTableColor(table)}`,
|
||||
}}
|
||||
>
|
||||
<div className="finance-table-header">
|
||||
<h3>{table.group_name}</h3>
|
||||
</div>
|
||||
<FinanceTable
|
||||
table={table}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Flex, InputNumber, Tooltip, Typography, Empty } from 'antd';
|
||||
import { Flex, Typography, Empty } from 'antd';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -8,9 +8,7 @@ import { openFinanceDrawer } from '@/features/finance/finance-slice';
|
||||
import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns';
|
||||
import FinanceTable from './finance-table';
|
||||
import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer';
|
||||
import { convertToHoursMinutes, formatHoursToReadable } from '@/utils/format-hours-to-readable';
|
||||
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
|
||||
import { updateTaskFixedCostAsync } from '@/features/projects/finance/project-finance.slice';
|
||||
|
||||
interface FinanceTableWrapperProps {
|
||||
activeTablesList: IProjectFinanceGroup[];
|
||||
@@ -35,14 +33,10 @@ const formatSecondsToTimeString = (totalSeconds: number): string => {
|
||||
|
||||
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesList, loading }) => {
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
const [editingFixedCost, setEditingFixedCost] = useState<{ taskId: string; groupId: string } | null>(null);
|
||||
|
||||
const { t } = useTranslation('project-view-finance');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Get selected task from Redux store
|
||||
const selectedTask = useAppSelector(state => state.financeReducer.selectedTask);
|
||||
|
||||
const onTaskClick = (task: any) => {
|
||||
dispatch(openFinanceDrawer(task));
|
||||
};
|
||||
@@ -61,19 +55,7 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesL
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle click outside to close editing
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (editingFixedCost && !(event.target as Element)?.closest('.fixed-cost-input')) {
|
||||
setEditingFixedCost(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [editingFixedCost]);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const currency = useAppSelector(state => state.projectFinances.project?.currency || "").toUpperCase();
|
||||
@@ -97,7 +79,7 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesL
|
||||
) => {
|
||||
table.tasks.forEach((task) => {
|
||||
acc.hours += (task.estimated_seconds) || 0;
|
||||
acc.cost += task.estimated_cost || 0;
|
||||
acc.cost += ((task.total_actual || 0) - (task.fixed_cost || 0));
|
||||
acc.fixedCost += task.fixed_cost || 0;
|
||||
acc.totalBudget += task.total_budget || 0;
|
||||
acc.totalActual += task.total_actual || 0;
|
||||
@@ -120,10 +102,7 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesL
|
||||
);
|
||||
}, [taskGroups]);
|
||||
|
||||
const handleFixedCostChange = (value: number | null, taskId: string, groupId: string) => {
|
||||
dispatch(updateTaskFixedCostAsync({ taskId, groupId, fixedCost: value || 0 }));
|
||||
setEditingFixedCost(null);
|
||||
};
|
||||
|
||||
|
||||
const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
|
||||
switch (columnKey) {
|
||||
@@ -242,7 +221,6 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesL
|
||||
<FinanceTable
|
||||
key={table.group_id}
|
||||
table={table}
|
||||
isScrolling={isScrolling}
|
||||
onTaskClick={onTaskClick}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Checkbox, Flex, Input, InputNumber, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { Flex, InputNumber, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import {
|
||||
@@ -13,7 +13,6 @@ import Avatars from '@/components/avatars/avatars';
|
||||
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
|
||||
import {
|
||||
updateTaskFixedCostAsync,
|
||||
updateTaskFixedCost,
|
||||
fetchProjectFinancesSilent,
|
||||
toggleTaskExpansion,
|
||||
fetchSubTasks
|
||||
@@ -21,7 +20,7 @@ import {
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { parseTimeToSeconds } from '@/utils/timeUtils';
|
||||
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { canEditFixedCost } from '@/utils/finance-permissions';
|
||||
import './finance-table.css';
|
||||
@@ -29,17 +28,16 @@ import './finance-table.css';
|
||||
type FinanceTableProps = {
|
||||
table: IProjectFinanceGroup;
|
||||
loading: boolean;
|
||||
isScrolling: boolean;
|
||||
onTaskClick: (task: any) => void;
|
||||
};
|
||||
|
||||
const FinanceTable = ({
|
||||
table,
|
||||
loading,
|
||||
isScrolling,
|
||||
onTaskClick,
|
||||
}: FinanceTableProps) => {
|
||||
const [isCollapse, setIsCollapse] = useState<boolean>(false);
|
||||
const [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);
|
||||
@@ -357,44 +355,6 @@ const FinanceTable = ({
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
// Calculate totals for the current table
|
||||
const totals = useMemo(() => {
|
||||
return tasks.reduce(
|
||||
(acc, task) => ({
|
||||
hours: acc.hours + (task.estimated_seconds || 0),
|
||||
total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0),
|
||||
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0),
|
||||
actual_cost_from_logs: acc.actual_cost_from_logs + ((task.total_actual || 0) - (task.fixed_cost || 0)),
|
||||
fixed_cost: acc.fixed_cost + (task.fixed_cost || 0),
|
||||
total_budget: acc.total_budget + (task.total_budget || 0),
|
||||
total_actual: acc.total_actual + (task.total_actual || 0),
|
||||
variance: acc.variance + (task.variance || 0)
|
||||
}),
|
||||
{
|
||||
hours: 0,
|
||||
total_time_logged: 0,
|
||||
estimated_cost: 0,
|
||||
actual_cost_from_logs: 0,
|
||||
fixed_cost: 0,
|
||||
total_budget: 0,
|
||||
total_actual: 0,
|
||||
variance: 0
|
||||
}
|
||||
);
|
||||
}, [tasks]);
|
||||
|
||||
// Format the totals for display
|
||||
const formattedTotals = useMemo(() => ({
|
||||
hours: formatSecondsToTimeString(totals.hours),
|
||||
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]);
|
||||
|
||||
// Flatten tasks to include subtasks for rendering
|
||||
const flattenedTasks = useMemo(() => {
|
||||
const flattened: IProjectFinanceTask[] = [];
|
||||
@@ -414,91 +374,142 @@ const FinanceTable = ({
|
||||
return flattened;
|
||||
}, [tasks]);
|
||||
|
||||
// Calculate totals for the current table (only count parent tasks to avoid double counting)
|
||||
const totals = useMemo(() => {
|
||||
return tasks.reduce(
|
||||
(acc, task) => {
|
||||
// Calculate actual cost from logs (total_actual - fixed_cost)
|
||||
const actualCostFromLogs = (task.total_actual || 0) - (task.fixed_cost || 0);
|
||||
|
||||
return {
|
||||
hours: acc.hours + (task.estimated_seconds || 0),
|
||||
total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0),
|
||||
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0),
|
||||
actual_cost_from_logs: acc.actual_cost_from_logs + actualCostFromLogs,
|
||||
fixed_cost: acc.fixed_cost + (task.fixed_cost || 0),
|
||||
total_budget: acc.total_budget + (task.total_budget || 0),
|
||||
total_actual: acc.total_actual + (task.total_actual || 0),
|
||||
variance: acc.variance + (task.variance || 0)
|
||||
};
|
||||
},
|
||||
{
|
||||
hours: 0,
|
||||
total_time_logged: 0,
|
||||
estimated_cost: 0,
|
||||
actual_cost_from_logs: 0,
|
||||
fixed_cost: 0,
|
||||
total_budget: 0,
|
||||
total_actual: 0,
|
||||
variance: 0
|
||||
}
|
||||
);
|
||||
}, [tasks]);
|
||||
|
||||
// Format the totals for display
|
||||
const formattedTotals = useMemo(() => ({
|
||||
hours: formatSecondsToTimeString(totals.hours),
|
||||
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={financeTableColumns.length}>
|
||||
<Skeleton active />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Skeleton active loading={loading}>
|
||||
<>
|
||||
{/* header row */}
|
||||
<>
|
||||
{/* header row */}
|
||||
<tr
|
||||
style={{
|
||||
height: 40,
|
||||
backgroundColor: themeWiseColor(
|
||||
table.color_code,
|
||||
table.color_code_dark,
|
||||
themeMode
|
||||
),
|
||||
fontWeight: 600,
|
||||
}}
|
||||
className={`group ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
>
|
||||
{financeTableColumns.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 */}
|
||||
{!isCollapse && flattenedTasks.map((task, idx) => (
|
||||
<tr
|
||||
key={task.id}
|
||||
style={{
|
||||
height: 40,
|
||||
backgroundColor: themeWiseColor(
|
||||
table.color_code,
|
||||
table.color_code_dark,
|
||||
themeMode
|
||||
),
|
||||
fontWeight: 600,
|
||||
background: idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode),
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
className={`group ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
className={themeMode === 'dark' ? 'dark' : ''}
|
||||
onMouseEnter={e => e.currentTarget.style.background = themeWiseColor('#f0f0f0', '#333', themeMode)}
|
||||
onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)}
|
||||
>
|
||||
{financeTableColumns.map(
|
||||
(col, 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>
|
||||
)
|
||||
)}
|
||||
{financeTableColumns.map((col) => (
|
||||
<td
|
||||
key={`${task.id}-${col.key}`}
|
||||
style={{
|
||||
width: col.width,
|
||||
paddingInline: 16,
|
||||
textAlign: col.type === 'string' ? 'left' : 'right',
|
||||
backgroundColor: (col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS) ?
|
||||
(idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)) :
|
||||
'transparent',
|
||||
cursor: 'default'
|
||||
}}
|
||||
className={customColumnStyles(col.key)}
|
||||
onClick={
|
||||
col.key === FinanceTableColumnKeys.FIXED_COST
|
||||
? (e) => e.stopPropagation()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{renderFinancialTableColumnContent(col.key, task)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* task rows */}
|
||||
{!isCollapse && flattenedTasks.map((task, idx) => (
|
||||
<tr
|
||||
key={task.id}
|
||||
style={{
|
||||
height: 40,
|
||||
background: idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode),
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
className={themeMode === 'dark' ? 'dark' : ''}
|
||||
onMouseEnter={e => e.currentTarget.style.background = themeWiseColor('#f0f0f0', '#333', themeMode)}
|
||||
onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)}
|
||||
>
|
||||
{financeTableColumns.map((col) => (
|
||||
<td
|
||||
key={`${task.id}-${col.key}`}
|
||||
style={{
|
||||
width: col.width,
|
||||
paddingInline: 16,
|
||||
textAlign: col.type === 'string' ? 'left' : 'right',
|
||||
backgroundColor: (col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS) ?
|
||||
(idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)) :
|
||||
'transparent',
|
||||
cursor: 'default'
|
||||
}}
|
||||
className={customColumnStyles(col.key)}
|
||||
onClick={
|
||||
col.key === FinanceTableColumnKeys.FIXED_COST
|
||||
? (e) => e.stopPropagation()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{renderFinancialTableColumnContent(col.key, task)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
</Skeleton>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Button, ConfigProvider, Flex, Select, Typography, message, Alert } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, ConfigProvider, Flex, Select, Typography, message, Alert, Card, Row, Col, Statistic } from 'antd';
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CaretDownFilled, DownOutlined } from '@ant-design/icons';
|
||||
import { CaretDownFilled, DownOutlined, CalculatorOutlined } from '@ant-design/icons';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { fetchProjectFinances, setActiveTab, setActiveGroup, updateProjectFinanceCurrency } from '@/features/projects/finance/project-finance.slice';
|
||||
import { fetchProjectFinances, setActiveTab, setActiveGroup, updateProjectFinanceCurrency, fetchProjectFinancesSilent, setBillableFilter } from '@/features/projects/finance/project-finance.slice';
|
||||
import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice';
|
||||
import { updateProjectCurrency } from '@/features/project/project.slice';
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
@@ -16,6 +16,8 @@ import ImportRatecardsDrawer from '@/features/finance/ratecard-drawer/import-rat
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { hasFinanceEditPermission } from '@/utils/finance-permissions';
|
||||
import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/constants/currencies';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
|
||||
const ProjectViewFinance = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
@@ -23,8 +25,9 @@ const ProjectViewFinance = () => {
|
||||
const { t } = useTranslation('project-view-finance');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [updatingCurrency, setUpdatingCurrency] = useState(false);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const { activeTab, activeGroup, loading, taskGroups, project: financeProject } = useAppSelector((state: RootState) => state.projectFinances);
|
||||
const { activeTab, activeGroup, billableFilter, loading, taskGroups, project: financeProject } = useAppSelector((state: RootState) => state.projectFinances);
|
||||
const { refreshTimestamp, project } = useAppSelector((state: RootState) => state.projectReducer);
|
||||
const phaseList = useAppSelector((state) => state.phaseReducer.phaseList);
|
||||
|
||||
@@ -39,11 +42,99 @@ const ProjectViewFinance = () => {
|
||||
// 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) {
|
||||
return {
|
||||
totalEstimatedCost: 0,
|
||||
totalFixedCost: 0,
|
||||
totalBudget: 0,
|
||||
totalActualCost: 0,
|
||||
totalVariance: 0,
|
||||
budgetUtilization: 0
|
||||
};
|
||||
}
|
||||
|
||||
const totals = taskGroups.reduce((acc, group) => {
|
||||
group.tasks.forEach(task => {
|
||||
acc.totalEstimatedCost += task.estimated_cost || 0;
|
||||
acc.totalFixedCost += task.fixed_cost || 0;
|
||||
acc.totalBudget += task.total_budget || 0;
|
||||
acc.totalActualCost += task.total_actual || 0;
|
||||
acc.totalVariance += task.variance || 0;
|
||||
});
|
||||
return acc;
|
||||
}, {
|
||||
totalEstimatedCost: 0,
|
||||
totalFixedCost: 0,
|
||||
totalBudget: 0,
|
||||
totalActualCost: 0,
|
||||
totalVariance: 0
|
||||
});
|
||||
|
||||
const budgetUtilization = totals.totalBudget > 0
|
||||
? (totals.totalActualCost / totals.totalBudget) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
...totals,
|
||||
budgetUtilization
|
||||
};
|
||||
}, [taskGroups]);
|
||||
|
||||
// Silent refresh function for socket events
|
||||
const refreshFinanceData = useCallback(() => {
|
||||
if (projectId) {
|
||||
dispatch(fetchProjectFinancesSilent({ projectId, groupBy: activeGroup, billableFilter }));
|
||||
}
|
||||
}, [projectId, activeGroup, billableFilter, dispatch]);
|
||||
|
||||
// Socket event handlers
|
||||
const handleTaskEstimationChange = useCallback(() => {
|
||||
refreshFinanceData();
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
const handleTaskTimerStop = useCallback(() => {
|
||||
refreshFinanceData();
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
const handleTaskProgressUpdate = useCallback(() => {
|
||||
refreshFinanceData();
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
const handleTaskBillableChange = useCallback(() => {
|
||||
refreshFinanceData();
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup }));
|
||||
dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup, billableFilter }));
|
||||
}
|
||||
}, [projectId, activeGroup, dispatch, refreshTimestamp]);
|
||||
}, [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 },
|
||||
];
|
||||
|
||||
// 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]);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!projectId) {
|
||||
@@ -53,7 +144,7 @@ const ProjectViewFinance = () => {
|
||||
|
||||
try {
|
||||
setExporting(true);
|
||||
const blob = await projectFinanceApiService.exportFinanceData(projectId, activeGroup);
|
||||
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, '_');
|
||||
@@ -115,6 +206,12 @@ const ProjectViewFinance = () => {
|
||||
},
|
||||
];
|
||||
|
||||
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 */}
|
||||
@@ -137,14 +234,26 @@ const ProjectViewFinance = () => {
|
||||
</Flex>
|
||||
|
||||
{activeTab === 'finance' && (
|
||||
<Flex align="center" gap={4} style={{ marginInlineStart: 12 }}>
|
||||
{t('groupByText')}:
|
||||
<Select
|
||||
value={activeGroup}
|
||||
options={groupDropdownMenuItems}
|
||||
onChange={(value) => dispatch(setActiveGroup(value as 'status' | 'priority' | 'phases'))}
|
||||
suffixIcon={<CaretDownFilled />}
|
||||
/>
|
||||
<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>
|
||||
@@ -194,6 +303,106 @@ const ProjectViewFinance = () => {
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Budget Statistics */}
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<CalculatorOutlined />
|
||||
<Typography.Text strong>Project Budget Overview</Typography.Text>
|
||||
</Flex>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
loading={loading}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Statistic
|
||||
title="Total Budget"
|
||||
value={budgetStatistics.totalBudget}
|
||||
precision={2}
|
||||
prefix={projectCurrency.toUpperCase()}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Statistic
|
||||
title="Actual Cost"
|
||||
value={budgetStatistics.totalActualCost}
|
||||
precision={2}
|
||||
prefix={projectCurrency.toUpperCase()}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Statistic
|
||||
title="Variance"
|
||||
value={budgetStatistics.totalVariance}
|
||||
precision={2}
|
||||
prefix={budgetStatistics.totalVariance >= 0 ? '+' : ''}
|
||||
suffix={projectCurrency.toUpperCase()}
|
||||
valueStyle={{
|
||||
color: budgetStatistics.totalVariance > 0 ? '#ff4d4f' : '#52c41a'
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Statistic
|
||||
title="Budget Utilization"
|
||||
value={budgetStatistics.budgetUtilization}
|
||||
precision={1}
|
||||
suffix="%"
|
||||
valueStyle={{
|
||||
color: budgetStatistics.budgetUtilization > 100 ? '#ff4d4f' :
|
||||
budgetStatistics.budgetUtilization > 80 ? '#faad14' : '#52c41a'
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Statistic
|
||||
title="Estimated Cost"
|
||||
value={budgetStatistics.totalEstimatedCost}
|
||||
precision={2}
|
||||
prefix={projectCurrency.toUpperCase()}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Statistic
|
||||
title="Fixed Cost"
|
||||
value={budgetStatistics.totalFixedCost}
|
||||
precision={2}
|
||||
prefix={projectCurrency.toUpperCase()}
|
||||
valueStyle={{ color: '#fa8c16' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Statistic
|
||||
title="Cost from Time Logs"
|
||||
value={budgetStatistics.totalActualCost - budgetStatistics.totalFixedCost}
|
||||
precision={2}
|
||||
prefix={projectCurrency.toUpperCase()}
|
||||
valueStyle={{ color: '#13c2c2' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Statistic
|
||||
title="Budget Status"
|
||||
value={budgetStatistics.totalBudget - budgetStatistics.totalActualCost}
|
||||
precision={2}
|
||||
prefix={budgetStatistics.totalBudget - budgetStatistics.totalActualCost >= 0 ? '+' : ''}
|
||||
suffix={`${projectCurrency.toUpperCase()} remaining`}
|
||||
valueStyle={{
|
||||
color: budgetStatistics.totalBudget - budgetStatistics.totalActualCost >= 0 ? '#52c41a' : '#ff4d4f'
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<FinanceTableWrapper activeTablesList={taskGroups} loading={loading} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -38,6 +38,8 @@ const transformTasksToGanttData = (taskGroups: ITaskListGroup[]) => {
|
||||
parent: 0,
|
||||
progress: Math.round((group.done_progress || 0) * 100) / 100,
|
||||
details: `Status: ${group.name}`,
|
||||
$custom_class: 'gantt-group-row',
|
||||
$group_type: group.name.toLowerCase().replace(/\s+/g, '-'),
|
||||
});
|
||||
|
||||
// Add individual tasks
|
||||
@@ -141,82 +143,82 @@ const ProjectViewGantt = () => {
|
||||
return { tasks: [], links: [] };
|
||||
}
|
||||
|
||||
// Test with hardcoded data first to isolate the issue
|
||||
const testData = {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
text: "Test Project",
|
||||
start: new Date(2024, 0, 1),
|
||||
end: new Date(2024, 0, 15),
|
||||
type: "summary",
|
||||
open: true,
|
||||
parent: 0,
|
||||
progress: 0.5,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
text: "Test Task 1",
|
||||
start: new Date(2024, 0, 2),
|
||||
end: new Date(2024, 0, 8),
|
||||
type: "task",
|
||||
parent: 1,
|
||||
progress: 0.3,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
text: "Test Task 2",
|
||||
start: new Date(2024, 0, 9),
|
||||
end: new Date(2024, 0, 14),
|
||||
type: "task",
|
||||
parent: 1,
|
||||
progress: 0.7,
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
};
|
||||
// // Test with hardcoded data first to isolate the issue
|
||||
// const testData = {
|
||||
// tasks: [
|
||||
// {
|
||||
// id: 1,
|
||||
// text: "Test Project",
|
||||
// start: new Date(2024, 0, 1),
|
||||
// end: new Date(2024, 0, 15),
|
||||
// type: "summary",
|
||||
// open: true,
|
||||
// parent: 0,
|
||||
// progress: 0.5,
|
||||
// },
|
||||
// {
|
||||
// id: 2,
|
||||
// text: "Test Task 1",
|
||||
// start: new Date(2024, 0, 2),
|
||||
// end: new Date(2024, 0, 8),
|
||||
// type: "task",
|
||||
// parent: 1,
|
||||
// progress: 0.3,
|
||||
// },
|
||||
// {
|
||||
// id: 3,
|
||||
// text: "Test Task 2",
|
||||
// start: new Date(2024, 0, 9),
|
||||
// end: new Date(2024, 0, 14),
|
||||
// type: "task",
|
||||
// parent: 1,
|
||||
// progress: 0.7,
|
||||
// },
|
||||
// ],
|
||||
// links: [],
|
||||
// };
|
||||
|
||||
console.log('Using test data for debugging:', testData);
|
||||
return testData;
|
||||
// console.log('Using test data for debugging:', testData);
|
||||
// return testData;
|
||||
|
||||
// Original transformation (commented out for testing)
|
||||
// const result = transformTasksToGanttData(taskGroups);
|
||||
// console.log('Gantt data - tasks count:', result.tasks.length);
|
||||
// if (result.tasks.length > 0) {
|
||||
// console.log('First task:', result.tasks[0]);
|
||||
// console.log('Sample dates:', result.tasks[0]?.start, result.tasks[0]?.end);
|
||||
// }
|
||||
// return result;
|
||||
const result = transformTasksToGanttData(taskGroups);
|
||||
console.log('Gantt data - tasks count:', result.tasks.length);
|
||||
if (result.tasks.length > 0) {
|
||||
console.log('First task:', result.tasks[0]);
|
||||
console.log('Sample dates:', result.tasks[0]?.start, result.tasks[0]?.end);
|
||||
}
|
||||
return result;
|
||||
}, [taskGroups]);
|
||||
|
||||
// Calculate date range for the Gantt chart
|
||||
const dateRange = useMemo(() => {
|
||||
// Fixed range for testing
|
||||
return {
|
||||
start: new Date(2023, 11, 1), // December 1, 2023
|
||||
end: new Date(2024, 1, 29), // February 29, 2024
|
||||
};
|
||||
// return {
|
||||
// start: new Date(2023, 11, 1), // December 1, 2023
|
||||
// end: new Date(2024, 1, 29), // February 29, 2024
|
||||
// };
|
||||
|
||||
// Original dynamic calculation (commented out for testing)
|
||||
// if (ganttData.tasks.length === 0) {
|
||||
// const now = new Date();
|
||||
// return {
|
||||
// start: new Date(now.getFullYear(), now.getMonth() - 1, 1),
|
||||
// end: new Date(now.getFullYear(), now.getMonth() + 2, 0),
|
||||
// };
|
||||
// }
|
||||
if (ganttData.tasks.length === 0) {
|
||||
const now = new Date();
|
||||
return {
|
||||
start: new Date(now.getFullYear(), now.getMonth() - 1, 1),
|
||||
end: new Date(now.getFullYear(), now.getMonth() + 2, 0),
|
||||
};
|
||||
}
|
||||
|
||||
// const dates = ganttData.tasks.map(task => [task.start, task.end]).flat();
|
||||
// const minDate = new Date(Math.min(...dates.map(d => d.getTime())));
|
||||
// const maxDate = new Date(Math.max(...dates.map(d => d.getTime())));
|
||||
const dates = ganttData.tasks.map(task => [task.start, task.end]).flat();
|
||||
const minDate = new Date(Math.min(...dates.map(d => d.getTime())));
|
||||
const maxDate = new Date(Math.max(...dates.map(d => d.getTime())));
|
||||
|
||||
// // Add some padding
|
||||
// const startDate = new Date(minDate);
|
||||
// startDate.setDate(startDate.getDate() - 7);
|
||||
// const endDate = new Date(maxDate);
|
||||
// endDate.setDate(endDate.getDate() + 7);
|
||||
// Add some padding
|
||||
const startDate = new Date(minDate);
|
||||
startDate.setDate(startDate.getDate() - 7);
|
||||
const endDate = new Date(maxDate);
|
||||
endDate.setDate(endDate.getDate() + 7);
|
||||
|
||||
// return { start: startDate, end: endDate };
|
||||
return { start: startDate, end: endDate };
|
||||
}, [ganttData.tasks]);
|
||||
|
||||
// Batch initial data fetching
|
||||
@@ -313,11 +315,76 @@ const ProjectViewGantt = () => {
|
||||
background-color: #00ba94 !important;
|
||||
border: 1px solid #099f81 !important;
|
||||
}
|
||||
|
||||
/* Highlight group names (summary tasks) */
|
||||
.wx-gantt-summary {
|
||||
background-color: #722ed1 !important;
|
||||
border: 2px solid #531dab !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* Group name text styling */
|
||||
.wx-gantt-row[data-task-type="summary"] .wx-gantt-cell-text {
|
||||
font-weight: 700 !important;
|
||||
font-size: 14px !important;
|
||||
color: #722ed1 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.5px !important;
|
||||
}
|
||||
|
||||
/* Group row background highlighting */
|
||||
.wx-gantt-row[data-task-type="summary"] {
|
||||
background-color: ${isDarkMode ? 'rgba(114, 46, 209, 0.1)' : 'rgba(114, 46, 209, 0.05)'} !important;
|
||||
}
|
||||
|
||||
/* Different colors for different group types */
|
||||
.gantt-group-row .wx-gantt-cell-text,
|
||||
.wx-gantt-row[data-task-id*="group-"] .wx-gantt-cell-text {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Todo/To Do groups - Red */
|
||||
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("Todo")) .wx-gantt-cell-text,
|
||||
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("TO DO")) .wx-gantt-cell-text,
|
||||
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("To Do")) .wx-gantt-cell-text {
|
||||
color: #f5222d !important;
|
||||
background: linear-gradient(90deg, rgba(245, 34, 45, 0.1) 0%, transparent 100%) !important;
|
||||
padding-left: 8px !important;
|
||||
border-left: 4px solid #f5222d !important;
|
||||
}
|
||||
|
||||
/* Doing/In Progress groups - Orange */
|
||||
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("Doing")) .wx-gantt-cell-text,
|
||||
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("IN PROGRESS")) .wx-gantt-cell-text,
|
||||
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("In Progress")) .wx-gantt-cell-text {
|
||||
color: #fa8c16 !important;
|
||||
background: linear-gradient(90deg, rgba(250, 140, 22, 0.1) 0%, transparent 100%) !important;
|
||||
padding-left: 8px !important;
|
||||
border-left: 4px solid #fa8c16 !important;
|
||||
}
|
||||
|
||||
/* Done/Completed groups - Green */
|
||||
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("Done")) .wx-gantt-cell-text,
|
||||
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("COMPLETED")) .wx-gantt-cell-text,
|
||||
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("Completed")) .wx-gantt-cell-text {
|
||||
color: #52c41a !important;
|
||||
background: linear-gradient(90deg, rgba(82, 196, 26, 0.1) 0%, transparent 100%) !important;
|
||||
padding-left: 8px !important;
|
||||
border-left: 4px solid #52c41a !important;
|
||||
}
|
||||
|
||||
${isDarkMode ? `
|
||||
.wx-gantt-task {
|
||||
background-color: #37a9ef !important;
|
||||
border: 1px solid #098cdc !important;
|
||||
}
|
||||
.wx-gantt-summary {
|
||||
background-color: #9254de !important;
|
||||
border: 2px solid #722ed1 !important;
|
||||
}
|
||||
.wx-gantt-row[data-task-type="summary"] .wx-gantt-cell-text {
|
||||
color: #b37feb !important;
|
||||
}
|
||||
` : ''}
|
||||
`}</style>
|
||||
|
||||
@@ -338,13 +405,16 @@ const ProjectViewGantt = () => {
|
||||
<Gantt
|
||||
tasks={ganttData.tasks}
|
||||
links={ganttData.links}
|
||||
start={new Date(2024, 0, 1)}
|
||||
end={new Date(2024, 0, 31)}
|
||||
start={dateRange.start}
|
||||
end={dateRange.end}
|
||||
scales={[
|
||||
{ unit: 'month', step: 1, format: 'MMMM yyyy' },
|
||||
{ unit: 'day', step: 1, format: 'd' }
|
||||
]}
|
||||
columns={[
|
||||
{ id: 'text', header: 'Task Name', width: 200 }
|
||||
{ id: 'text', header: 'Task Name', width: 200 },
|
||||
{ id: 'start', header: 'Start Date', width: 100 },
|
||||
{ id: 'end', header: 'End Date', width: 100 }
|
||||
]}
|
||||
/>
|
||||
</WillowDark>
|
||||
@@ -353,13 +423,16 @@ const ProjectViewGantt = () => {
|
||||
<Gantt
|
||||
tasks={ganttData.tasks}
|
||||
links={ganttData.links}
|
||||
start={new Date(2024, 0, 1)}
|
||||
end={new Date(2024, 0, 31)}
|
||||
start={dateRange.start}
|
||||
end={dateRange.end}
|
||||
scales={[
|
||||
{ unit: 'month', step: 1, format: 'MMMM yyyy' },
|
||||
{ unit: 'day', step: 1, format: 'd' }
|
||||
]}
|
||||
columns={[
|
||||
{ id: 'text', header: 'Task Name', width: 200 }
|
||||
{ id: 'text', header: 'Task Name', width: 200 },
|
||||
{ id: 'start', header: 'Start Date', width: 100 },
|
||||
{ id: 'end', header: 'End Date', width: 100 }
|
||||
]}
|
||||
/>
|
||||
</Willow>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Flex,
|
||||
Input,
|
||||
Popconfirm,
|
||||
@@ -14,7 +15,10 @@ import {
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
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';
|
||||
@@ -41,8 +45,13 @@ interface PaginationType {
|
||||
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('');
|
||||
@@ -58,12 +67,14 @@ const RatecardSettings: React.FC = () => {
|
||||
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 {
|
||||
@@ -77,36 +88,46 @@ const RatecardSettings: React.FC = () => {
|
||||
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]);
|
||||
}, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery, t, messageApi]);
|
||||
|
||||
// Fetch rate cards when drawer state changes
|
||||
useEffect(() => {
|
||||
fetchRateCards();
|
||||
}, [toggleRatecardDrawer, isDrawerOpen]);
|
||||
|
||||
|
||||
}, [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);
|
||||
|
||||
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());
|
||||
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]);
|
||||
}, [dispatch, t, messageApi]);
|
||||
|
||||
// Handle rate card update
|
||||
const handleRatecardUpdate = useCallback((id: string) => {
|
||||
setRatecardDrawerType('update');
|
||||
dispatch(fetchRateCardById(id));
|
||||
@@ -114,26 +135,32 @@ const RatecardSettings: React.FC = () => {
|
||||
dispatch(toggleRatecardDrawer());
|
||||
}, [dispatch]);
|
||||
|
||||
|
||||
|
||||
|
||||
const handleTableChange = useCallback((newPagination: any, filters: any, sorter: any) => {
|
||||
// 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,
|
||||
pageSize: newPagination.pageSize,
|
||||
field: sorter.field || 'name',
|
||||
order: sorter.order === 'ascend' ? 'asc' : 'desc',
|
||||
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={() => setSelectedRatecardId(record.id ?? null)}>
|
||||
<Typography.Text
|
||||
style={{ color: '#1890ff', cursor: 'pointer' }}
|
||||
onClick={() => record.id && handleRatecardUpdate(record.id)}
|
||||
>
|
||||
{record.name}
|
||||
</Typography.Text>
|
||||
),
|
||||
@@ -142,7 +169,7 @@ const RatecardSettings: React.FC = () => {
|
||||
key: 'created',
|
||||
title: t('createdColumn'),
|
||||
render: (record: RatecardType) => (
|
||||
<Typography.Text onClick={() => setSelectedRatecardId(record.id ?? null)}>
|
||||
<Typography.Text onClick={() => record.id && handleRatecardUpdate(record.id)}>
|
||||
{durationDateFormat(record.created_at)}
|
||||
</Typography.Text>
|
||||
),
|
||||
@@ -152,7 +179,7 @@ const RatecardSettings: React.FC = () => {
|
||||
width: 80,
|
||||
render: (record: RatecardType) => (
|
||||
<Flex gap={8} className="hidden group-hover:flex">
|
||||
<Tooltip title="Edit">
|
||||
<Tooltip title={t('editTooltip') || 'Edit'}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
@@ -166,14 +193,19 @@ const RatecardSettings: React.FC = () => {
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={async () => {
|
||||
setLoading(true);
|
||||
if (record.id) {
|
||||
await dispatch(deleteRateCard(record.id));
|
||||
await fetchRateCards();
|
||||
try {
|
||||
if (record.id) {
|
||||
await dispatch(deleteRateCard(record.id));
|
||||
await fetchRateCards();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete rate card:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Delete">
|
||||
<Tooltip title={t('deleteTooltip') || 'Delete'}>
|
||||
<Button
|
||||
shape="default"
|
||||
icon={<DeleteOutlined />}
|
||||
@@ -184,46 +216,52 @@ const RatecardSettings: React.FC = () => {
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
], [t, handleRatecardUpdate]);
|
||||
], [t, handleRatecardUpdate, fetchRateCards, dispatch, messageApi]);
|
||||
|
||||
return (
|
||||
<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"
|
||||
/>
|
||||
<RatecardDrawer
|
||||
type={ratecardDrawerType}
|
||||
ratecardId={selectedRatecardId || ''}
|
||||
onSaved={fetchRateCards} // Pass the fetch function as a prop
|
||||
/>
|
||||
</Card>
|
||||
<>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user