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,116 @@
|
||||
import apiClient from '@api/api-client';
|
||||
import { API_BASE_URL } from '@/shared/constants';
|
||||
import { IServerResponse } from '@/types/common.types';
|
||||
import { IJobType, JobRoleType } from '@/types/project/ratecard.types';
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/project-ratecard`;
|
||||
|
||||
export interface IProjectRateCardRole {
|
||||
id?: string;
|
||||
project_id: string;
|
||||
job_title_id: string;
|
||||
jobtitle?: string;
|
||||
rate: number;
|
||||
man_day_rate?: number;
|
||||
data?: object;
|
||||
roles?: IJobType[];
|
||||
}
|
||||
|
||||
export const projectRateCardApiService = {
|
||||
// Insert multiple roles for a project
|
||||
async insertMany(
|
||||
project_id: string,
|
||||
roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[]
|
||||
): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||
const response = await apiClient.post<IServerResponse<IProjectRateCardRole[]>>(rootUrl, {
|
||||
project_id,
|
||||
roles,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
// Insert a single role for a project
|
||||
async insertOne({
|
||||
project_id,
|
||||
job_title_id,
|
||||
rate,
|
||||
man_day_rate,
|
||||
}: {
|
||||
project_id: string;
|
||||
job_title_id: string;
|
||||
rate: number;
|
||||
man_day_rate?: number;
|
||||
}): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||
const response = await apiClient.post<IServerResponse<IProjectRateCardRole>>(
|
||||
`${rootUrl}/create-project-rate-card-role`,
|
||||
{ project_id, job_title_id, rate, man_day_rate }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get all roles for a project
|
||||
async getFromProjectId(project_id: string): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||
const response = await apiClient.get<IServerResponse<IProjectRateCardRole[]>>(
|
||||
`${rootUrl}/project/${project_id}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get a single role by id
|
||||
async getFromId(id: string): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||
const response = await apiClient.get<IServerResponse<IProjectRateCardRole>>(`${rootUrl}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update a single role by id
|
||||
async updateFromId(
|
||||
id: string,
|
||||
body: { job_title_id: string; rate?: string; man_day_rate?: string }
|
||||
): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||
const response = await apiClient.put<IServerResponse<IProjectRateCardRole>>(
|
||||
`${rootUrl}/${id}`,
|
||||
body
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update all roles for a project (delete then insert)
|
||||
async updateFromProjectId(
|
||||
project_id: string,
|
||||
roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[]
|
||||
): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||
const response = await apiClient.put<IServerResponse<IProjectRateCardRole[]>>(
|
||||
`${rootUrl}/project/${project_id}`,
|
||||
{ project_id, roles }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update project member rate card role
|
||||
async updateMemberRateCardRole(
|
||||
project_id: string,
|
||||
member_id: string,
|
||||
project_rate_card_role_id: string
|
||||
): Promise<IServerResponse<JobRoleType>> {
|
||||
const response = await apiClient.put<IServerResponse<JobRoleType>>(
|
||||
`${rootUrl}/project/${project_id}/members/${member_id}/rate-card-role`,
|
||||
{ project_rate_card_role_id }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete a single role by id
|
||||
async deleteFromId(id: string): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||
const response = await apiClient.delete<IServerResponse<IProjectRateCardRole>>(
|
||||
`${rootUrl}/${id}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete all roles for a project
|
||||
async deleteFromProjectId(project_id: string): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||
const response = await apiClient.delete<IServerResponse<IProjectRateCardRole[]>>(
|
||||
`${rootUrl}/project/${project_id}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import { API_BASE_URL } from '@/shared/constants';
|
||||
import { IServerResponse } from '@/types/common.types';
|
||||
import apiClient from '../api-client';
|
||||
import {
|
||||
IProjectFinanceResponse,
|
||||
ITaskBreakdownResponse,
|
||||
IProjectFinanceTask,
|
||||
} from '@/types/project/project-finance.types';
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/project-finance`;
|
||||
|
||||
type BillableFilterType = 'all' | 'billable' | 'non-billable';
|
||||
|
||||
export const projectFinanceApiService = {
|
||||
getProjectTasks: async (
|
||||
projectId: string,
|
||||
groupBy: 'status' | 'priority' | 'phases' = 'status',
|
||||
billableFilter: BillableFilterType = 'billable'
|
||||
): Promise<IServerResponse<IProjectFinanceResponse>> => {
|
||||
const response = await apiClient.get<IServerResponse<IProjectFinanceResponse>>(
|
||||
`${rootUrl}/project/${projectId}/tasks`,
|
||||
{
|
||||
params: {
|
||||
group_by: groupBy,
|
||||
billable_filter: billableFilter,
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSubTasks: async (
|
||||
projectId: string,
|
||||
parentTaskId: string,
|
||||
billableFilter: BillableFilterType = 'billable'
|
||||
): Promise<IServerResponse<IProjectFinanceTask[]>> => {
|
||||
const response = await apiClient.get<IServerResponse<IProjectFinanceTask[]>>(
|
||||
`${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks`,
|
||||
{
|
||||
params: {
|
||||
billable_filter: billableFilter,
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTaskBreakdown: async (taskId: string): Promise<IServerResponse<ITaskBreakdownResponse>> => {
|
||||
const response = await apiClient.get<IServerResponse<ITaskBreakdownResponse>>(
|
||||
`${rootUrl}/task/${taskId}/breakdown`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTaskFixedCost: async (taskId: string, fixedCost: number): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/task/${taskId}/fixed-cost`,
|
||||
{ fixed_cost: fixedCost }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProjectCurrency: async (
|
||||
projectId: string,
|
||||
currency: string
|
||||
): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/project/${projectId}/currency`,
|
||||
{ currency }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProjectBudget: async (projectId: string, budget: number): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/project/${projectId}/budget`,
|
||||
{ budget }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProjectCalculationMethod: async (
|
||||
projectId: string,
|
||||
calculationMethod: 'hourly' | 'man_days',
|
||||
hoursPerDay?: number
|
||||
): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/project/${projectId}/calculation-method`,
|
||||
{
|
||||
calculation_method: calculationMethod,
|
||||
hours_per_day: hoursPerDay,
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTaskEstimatedManDays: async (
|
||||
taskId: string,
|
||||
estimatedManDays: number
|
||||
): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/task/${taskId}/estimated-man-days`,
|
||||
{ estimated_man_days: estimatedManDays }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateRateCardManDayRate: async (
|
||||
rateCardRoleId: string,
|
||||
manDayRate: number
|
||||
): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/rate-card-role/${rateCardRoleId}/man-day-rate`,
|
||||
{ man_day_rate: manDayRate }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
exportFinanceData: async (
|
||||
projectId: string,
|
||||
groupBy: 'status' | 'priority' | 'phases' = 'status',
|
||||
billableFilter: BillableFilterType = 'billable'
|
||||
): Promise<Blob> => {
|
||||
const response = await apiClient.get(`${rootUrl}/project/${projectId}/export`, {
|
||||
params: {
|
||||
groupBy,
|
||||
billable_filter: billableFilter,
|
||||
},
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import apiClient from '@api/api-client';
|
||||
import { API_BASE_URL } from '@/shared/constants';
|
||||
import { IServerResponse } from '@/types/common.types';
|
||||
import { toQueryString } from '@/utils/toQueryString';
|
||||
import { RatecardType, IRatecardViewModel } from '@/types/project/ratecard.types';
|
||||
|
||||
type IRatecard = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/ratecard`;
|
||||
|
||||
export const rateCardApiService = {
|
||||
async getRateCards(
|
||||
index: number,
|
||||
size: number,
|
||||
field: string | null,
|
||||
order: string | null,
|
||||
search?: string | null
|
||||
): Promise<IServerResponse<IRatecardViewModel>> {
|
||||
const s = encodeURIComponent(search || '');
|
||||
const queryString = toQueryString({ index, size, field, order, search: s });
|
||||
const response = await apiClient.get<IServerResponse<IRatecardViewModel>>(
|
||||
`${rootUrl}${queryString}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
async getRateCardById(id: string): Promise<IServerResponse<RatecardType>> {
|
||||
const response = await apiClient.get<IServerResponse<RatecardType>>(`${rootUrl}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createRateCard(body: RatecardType): Promise<IServerResponse<RatecardType>> {
|
||||
const response = await apiClient.post<IServerResponse<RatecardType>>(rootUrl, body);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateRateCard(id: string, body: RatecardType): Promise<IServerResponse<RatecardType>> {
|
||||
const response = await apiClient.put<IServerResponse<RatecardType>>(`${rootUrl}/${id}`, body);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteRateCard(id: string): Promise<IServerResponse<void>> {
|
||||
const response = await apiClient.delete<IServerResponse<void>>(`${rootUrl}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -86,6 +86,10 @@ import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||
import projectViewReducer from '@features/project/project-view-slice';
|
||||
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
|
||||
|
||||
import projectFinanceRateCardReducer from '@/features/finance/project-finance-slice';
|
||||
import projectFinancesReducer from '@/features/projects/finance/project-finance.slice';
|
||||
import financeReducer from '@/features/projects/finance/finance-slice';
|
||||
|
||||
export const store = configureStore({
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
@@ -174,6 +178,11 @@ export const store = configureStore({
|
||||
grouping: groupingReducer,
|
||||
taskManagementSelection: selectionReducer,
|
||||
taskManagementFields: taskManagementFieldsReducer,
|
||||
|
||||
// Finance
|
||||
projectFinanceRateCardReducer: projectFinanceRateCardReducer,
|
||||
projectFinancesReducer: projectFinancesReducer,
|
||||
financeReducer: financeReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,57 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Avatar, Tooltip } from '@/shared/antd-imports';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
|
||||
interface AvatarsProps {
|
||||
members: InlineMember[];
|
||||
maxCount?: number;
|
||||
allowClickThrough?: boolean;
|
||||
}
|
||||
|
||||
const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount }) => {
|
||||
const stopPropagation = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const renderAvatar = useCallback(
|
||||
(member: InlineMember, index: number) => (
|
||||
<Tooltip
|
||||
key={member.team_member_id || index}
|
||||
title={member.end && member.names ? member.names.join(', ') : member.name}
|
||||
>
|
||||
{member.avatar_url ? (
|
||||
<span onClick={stopPropagation}>
|
||||
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
||||
</span>
|
||||
) : (
|
||||
<span onClick={stopPropagation}>
|
||||
<Avatar
|
||||
size={28}
|
||||
key={member.team_member_id || index}
|
||||
style={{
|
||||
backgroundColor: member.color_code || '#ececec',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
),
|
||||
[stopPropagation]
|
||||
);
|
||||
|
||||
const visibleMembers = useMemo(() => {
|
||||
return maxCount ? members.slice(0, maxCount) : members;
|
||||
}, [members, maxCount]);
|
||||
|
||||
const avatarElements = useMemo(() => {
|
||||
return visibleMembers.map((member, index) => renderAvatar(member, index));
|
||||
}, [visibleMembers, renderAvatar]);
|
||||
const renderAvatar = (member: InlineMember, index: number, allowClickThrough: boolean = false) => (
|
||||
<Tooltip
|
||||
key={member.team_member_id || index}
|
||||
title={member.end && member.names ? member.names.join(', ') : member.name}
|
||||
>
|
||||
{member.avatar_url ? (
|
||||
<span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
||||
</span>
|
||||
) : (
|
||||
<span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar
|
||||
size={28}
|
||||
key={member.team_member_id || index}
|
||||
style={{
|
||||
backgroundColor: member.color_code || '#ececec',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount, allowClickThrough = false }) => {
|
||||
const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
|
||||
return (
|
||||
<div onClick={stopPropagation}>
|
||||
<Avatar.Group>{avatarElements}</Avatar.Group>
|
||||
<div onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar.Group>
|
||||
{visibleMembers.map((member, index) => renderAvatar(member, index, allowClickThrough))}
|
||||
</Avatar.Group>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
252
worklenz-frontend/src/features/finance/finance-slice.ts
Normal file
252
worklenz-frontend/src/features/finance/finance-slice.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service';
|
||||
import { RatecardType } from '@/types/project/ratecard.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
type financeState = {
|
||||
isRatecardDrawerOpen: boolean;
|
||||
isFinanceDrawerOpen: boolean;
|
||||
isImportRatecardsDrawerOpen: boolean;
|
||||
currency: string;
|
||||
isRatecardsLoading?: boolean;
|
||||
isFinanceDrawerloading?: boolean;
|
||||
drawerRatecard?: RatecardType | null;
|
||||
ratecardsList?: RatecardType[] | null;
|
||||
selectedTask?: any | null;
|
||||
};
|
||||
|
||||
const initialState: financeState = {
|
||||
isRatecardDrawerOpen: false,
|
||||
isFinanceDrawerOpen: false,
|
||||
isImportRatecardsDrawerOpen: false,
|
||||
currency: 'USD',
|
||||
isRatecardsLoading: false,
|
||||
isFinanceDrawerloading: false,
|
||||
drawerRatecard: null,
|
||||
ratecardsList: null,
|
||||
selectedTask: null,
|
||||
};
|
||||
interface FetchRateCardsParams {
|
||||
index: number;
|
||||
size: number;
|
||||
field: string | null;
|
||||
order: string | null;
|
||||
search: string | null;
|
||||
}
|
||||
// Async thunks
|
||||
export const fetchRateCards = createAsyncThunk(
|
||||
'ratecards/fetchAll',
|
||||
async (params: FetchRateCardsParams, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.getRateCards(
|
||||
params.index,
|
||||
params.size,
|
||||
params.field,
|
||||
params.order,
|
||||
params.search
|
||||
);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch RateCards', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to fetch rate cards');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchRateCardById = createAsyncThunk(
|
||||
'ratecard/fetchById',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.getRateCardById(id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch RateCardById', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to fetch rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const createRateCard = createAsyncThunk(
|
||||
'ratecards/create',
|
||||
async (body: RatecardType, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.createRateCard(body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Create RateCard', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to create rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateRateCard = createAsyncThunk(
|
||||
'ratecards/update',
|
||||
async ({ id, body }: { id: string; body: RatecardType }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.updateRateCard(id, body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Update RateCard', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to update rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteRateCard = createAsyncThunk(
|
||||
'ratecards/delete',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
await rateCardApiService.deleteRateCard(id);
|
||||
return id;
|
||||
} catch (error) {
|
||||
logger.error('Delete RateCard', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to delete rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const financeSlice = createSlice({
|
||||
name: 'financeReducer',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleRatecardDrawer: (state) => {
|
||||
state.isRatecardDrawerOpen = !state.isRatecardDrawerOpen;
|
||||
},
|
||||
toggleFinanceDrawer: (state) => {
|
||||
state.isFinanceDrawerOpen = !state.isFinanceDrawerOpen;
|
||||
},
|
||||
openFinanceDrawer: (state, action: PayloadAction<any>) => {
|
||||
state.isFinanceDrawerOpen = true;
|
||||
state.selectedTask = action.payload;
|
||||
},
|
||||
closeFinanceDrawer: (state) => {
|
||||
state.isFinanceDrawerOpen = false;
|
||||
state.selectedTask = null;
|
||||
},
|
||||
setSelectedTask: (state, action: PayloadAction<any>) => {
|
||||
state.selectedTask = action.payload;
|
||||
},
|
||||
toggleImportRatecardsDrawer: (state) => {
|
||||
state.isImportRatecardsDrawerOpen = !state.isImportRatecardsDrawerOpen;
|
||||
},
|
||||
changeCurrency: (state, action: PayloadAction<string>) => {
|
||||
state.currency = action.payload;
|
||||
},
|
||||
ratecardDrawerLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.isFinanceDrawerloading = action.payload;
|
||||
},
|
||||
clearDrawerRatecard: (state) => {
|
||||
state.drawerRatecard = null;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchRateCards.pending, (state) => {
|
||||
state.isRatecardsLoading = true;
|
||||
})
|
||||
.addCase(fetchRateCards.fulfilled, (state, action) => {
|
||||
state.isRatecardsLoading = false;
|
||||
state.ratecardsList = Array.isArray(action.payload.data)
|
||||
? action.payload.data
|
||||
: Array.isArray(action.payload)
|
||||
? action.payload
|
||||
: [];
|
||||
})
|
||||
.addCase(fetchRateCards.rejected, (state) => {
|
||||
state.isRatecardsLoading = false;
|
||||
state.ratecardsList = [];
|
||||
})
|
||||
.addCase(fetchRateCardById.pending, (state) => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
state.drawerRatecard = null;
|
||||
})
|
||||
.addCase(fetchRateCardById.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
state.drawerRatecard = action.payload;
|
||||
})
|
||||
.addCase(fetchRateCardById.rejected, (state) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
state.drawerRatecard = null;
|
||||
})
|
||||
// Create rate card
|
||||
.addCase(createRateCard.pending, (state) => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(createRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
if (state.ratecardsList) {
|
||||
state.ratecardsList.push(action.payload);
|
||||
} else {
|
||||
state.ratecardsList = [action.payload];
|
||||
}
|
||||
})
|
||||
.addCase(createRateCard.rejected, (state) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
})
|
||||
// Update rate card
|
||||
.addCase(updateRateCard.pending, (state) => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(updateRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
// Update the drawerRatecard with the new data
|
||||
state.drawerRatecard = action.payload;
|
||||
// Update the rate card in the list if it exists
|
||||
if (state.ratecardsList && action.payload?.id) {
|
||||
const index = state.ratecardsList.findIndex(rc => rc.id === action.payload.id);
|
||||
if (index !== -1) {
|
||||
state.ratecardsList[index] = action.payload;
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateRateCard.rejected, (state) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
})
|
||||
// Delete rate card
|
||||
.addCase(deleteRateCard.pending, (state) => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(deleteRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
// Remove the deleted rate card from the list
|
||||
if (state.ratecardsList) {
|
||||
state.ratecardsList = state.ratecardsList.filter(rc => rc.id !== action.payload);
|
||||
}
|
||||
// Clear drawer rate card if it was the deleted one
|
||||
if (state.drawerRatecard?.id === action.payload) {
|
||||
state.drawerRatecard = null;
|
||||
}
|
||||
})
|
||||
.addCase(deleteRateCard.rejected, (state) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
toggleRatecardDrawer,
|
||||
toggleFinanceDrawer,
|
||||
openFinanceDrawer,
|
||||
closeFinanceDrawer,
|
||||
setSelectedTask,
|
||||
toggleImportRatecardsDrawer,
|
||||
changeCurrency,
|
||||
ratecardDrawerLoading,
|
||||
clearDrawerRatecard,
|
||||
} = financeSlice.actions;
|
||||
export default financeSlice.reducer;
|
||||
310
worklenz-frontend/src/features/finance/project-finance-slice.ts
Normal file
310
worklenz-frontend/src/features/finance/project-finance-slice.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import {
|
||||
projectRateCardApiService,
|
||||
IProjectRateCardRole,
|
||||
} from '@/api/project-finance-ratecard/project-finance-rate-cards.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { JobRoleType } from '@/types/project/ratecard.types';
|
||||
|
||||
type ProjectFinanceRateCardState = {
|
||||
isDrawerOpen: boolean;
|
||||
isLoading: boolean;
|
||||
rateCardRoles: JobRoleType[] | null;
|
||||
drawerRole: IProjectRateCardRole | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
const initialState: ProjectFinanceRateCardState = {
|
||||
isDrawerOpen: false,
|
||||
isLoading: false,
|
||||
rateCardRoles: null,
|
||||
drawerRole: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Async thunks
|
||||
export const fetchProjectRateCardRoles = createAsyncThunk(
|
||||
'projectFinance/fetchAll',
|
||||
async (project_id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.getFromProjectId(project_id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Project RateCard Roles', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to fetch project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchProjectRateCardRoleById = createAsyncThunk(
|
||||
'projectFinance/fetchById',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.getFromId(id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Project RateCard Role By Id', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to fetch project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const insertProjectRateCardRoles = createAsyncThunk(
|
||||
'projectFinance/insertMany',
|
||||
async (
|
||||
{
|
||||
project_id,
|
||||
roles,
|
||||
}: { project_id: string; roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[] },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.insertMany(project_id, roles);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Insert Project RateCard Roles', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to insert project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const insertProjectRateCardRole = createAsyncThunk(
|
||||
'projectFinance/insertOne',
|
||||
async (
|
||||
{
|
||||
project_id,
|
||||
job_title_id,
|
||||
rate,
|
||||
man_day_rate,
|
||||
}: { project_id: string; job_title_id: string; rate: number; man_day_rate?: number },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.insertOne({
|
||||
project_id,
|
||||
job_title_id,
|
||||
rate,
|
||||
man_day_rate,
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Insert Project RateCard Role', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to insert project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateProjectRateCardRoleById = createAsyncThunk(
|
||||
'projectFinance/updateById',
|
||||
async (
|
||||
{
|
||||
id,
|
||||
body,
|
||||
}: { id: string; body: { job_title_id: string; rate?: string; man_day_rate?: string } },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.updateFromId(id, body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Update Project RateCard Role By Id', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to update project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateProjectRateCardRolesByProjectId = createAsyncThunk(
|
||||
'projectFinance/updateByProjectId',
|
||||
async (
|
||||
{
|
||||
project_id,
|
||||
roles,
|
||||
}: { project_id: string; roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[] },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.updateFromProjectId(project_id, roles);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Update Project RateCard Roles By ProjectId', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to update project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteProjectRateCardRoleById = createAsyncThunk(
|
||||
'projectFinance/deleteById',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.deleteFromId(id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Delete Project RateCard Role By Id', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to delete project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const assignMemberToRateCardRole = createAsyncThunk(
|
||||
'projectFinance/assignMemberToRateCardRole',
|
||||
async ({
|
||||
project_id,
|
||||
member_id,
|
||||
project_rate_card_role_id,
|
||||
}: {
|
||||
project_id: string;
|
||||
member_id: string;
|
||||
project_rate_card_role_id: string;
|
||||
}) => {
|
||||
const response = await projectRateCardApiService.updateMemberRateCardRole(
|
||||
project_id,
|
||||
member_id,
|
||||
project_rate_card_role_id
|
||||
);
|
||||
return response.body;
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteProjectRateCardRolesByProjectId = createAsyncThunk(
|
||||
'projectFinance/deleteByProjectId',
|
||||
async (project_id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.deleteFromProjectId(project_id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Delete Project RateCard Roles By ProjectId', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to delete project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const projectFinanceSlice = createSlice({
|
||||
name: 'projectFinanceRateCard',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleDrawer: state => {
|
||||
state.isDrawerOpen = !state.isDrawerOpen;
|
||||
},
|
||||
clearDrawerRole: state => {
|
||||
state.drawerRole = null;
|
||||
},
|
||||
clearError: state => {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
// Fetch all
|
||||
.addCase(fetchProjectRateCardRoles.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoles.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = action.payload || [];
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoles.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
state.rateCardRoles = [];
|
||||
})
|
||||
// Fetch by id
|
||||
.addCase(fetchProjectRateCardRoleById.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.drawerRole = null;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.drawerRole = action.payload || null;
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoleById.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
state.drawerRole = null;
|
||||
})
|
||||
// Insert many
|
||||
.addCase(insertProjectRateCardRoles.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(insertProjectRateCardRoles.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = action.payload || [];
|
||||
})
|
||||
.addCase(insertProjectRateCardRoles.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Update by id
|
||||
.addCase(updateProjectRateCardRoleById.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(updateProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
if (state.rateCardRoles && action.payload) {
|
||||
state.rateCardRoles = state.rateCardRoles.map(role =>
|
||||
role.id === action.payload.id ? action.payload : role
|
||||
);
|
||||
}
|
||||
})
|
||||
.addCase(updateProjectRateCardRoleById.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Update by project id
|
||||
.addCase(updateProjectRateCardRolesByProjectId.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(updateProjectRateCardRolesByProjectId.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = action.payload || [];
|
||||
})
|
||||
.addCase(updateProjectRateCardRolesByProjectId.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Delete by id
|
||||
.addCase(deleteProjectRateCardRoleById.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(deleteProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
if (state.rateCardRoles && action.payload) {
|
||||
state.rateCardRoles = state.rateCardRoles.filter(role => role.id !== action.payload.id);
|
||||
}
|
||||
})
|
||||
.addCase(deleteProjectRateCardRoleById.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Delete by project id
|
||||
.addCase(deleteProjectRateCardRolesByProjectId.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(deleteProjectRateCardRolesByProjectId.fulfilled, state => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = [];
|
||||
})
|
||||
.addCase(deleteProjectRateCardRolesByProjectId.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { toggleDrawer, clearDrawerRole, clearError } = projectFinanceSlice.actions;
|
||||
|
||||
export default projectFinanceSlice.reducer;
|
||||
@@ -116,6 +116,11 @@ const projectSlice = createSlice({
|
||||
state.project.phase_label = action.payload;
|
||||
}
|
||||
},
|
||||
updateProjectCurrency: (state, action: PayloadAction<string>) => {
|
||||
if (state.project) {
|
||||
state.project.currency = action.payload;
|
||||
}
|
||||
},
|
||||
addTask: (
|
||||
state,
|
||||
action: PayloadAction<{ task: IProjectTask; groupId: string; insert?: boolean }>
|
||||
@@ -143,7 +148,6 @@ const projectSlice = createSlice({
|
||||
} else {
|
||||
insert ? group.tasks.unshift(task) : group.tasks.push(task);
|
||||
}
|
||||
console.log('addTask', group.tasks);
|
||||
},
|
||||
deleteTask: (state, action: PayloadAction<{ taskId: string; index?: number }>) => {
|
||||
const { taskId, index } = action.payload;
|
||||
@@ -215,6 +219,7 @@ export const {
|
||||
setProjectView,
|
||||
updatePhaseLabel,
|
||||
setRefreshTimestamp,
|
||||
updateProjectCurrency
|
||||
} = projectSlice.actions;
|
||||
|
||||
export default projectSlice.reducer;
|
||||
|
||||
252
worklenz-frontend/src/features/projects/finance/finance-slice.ts
Normal file
252
worklenz-frontend/src/features/projects/finance/finance-slice.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service';
|
||||
import { RatecardType } from '@/types/project/ratecard.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
type financeState = {
|
||||
isRatecardDrawerOpen: boolean;
|
||||
isFinanceDrawerOpen: boolean;
|
||||
isImportRatecardsDrawerOpen: boolean;
|
||||
currency: string;
|
||||
isRatecardsLoading?: boolean;
|
||||
isFinanceDrawerloading?: boolean;
|
||||
drawerRatecard?: RatecardType | null;
|
||||
ratecardsList?: RatecardType[] | null;
|
||||
selectedTask?: any | null;
|
||||
};
|
||||
|
||||
const initialState: financeState = {
|
||||
isRatecardDrawerOpen: false,
|
||||
isFinanceDrawerOpen: false,
|
||||
isImportRatecardsDrawerOpen: false,
|
||||
currency: 'USD',
|
||||
isRatecardsLoading: false,
|
||||
isFinanceDrawerloading: false,
|
||||
drawerRatecard: null,
|
||||
ratecardsList: null,
|
||||
selectedTask: null,
|
||||
};
|
||||
interface FetchRateCardsParams {
|
||||
index: number;
|
||||
size: number;
|
||||
field: string | null;
|
||||
order: string | null;
|
||||
search: string | null;
|
||||
}
|
||||
// Async thunks
|
||||
export const fetchRateCards = createAsyncThunk(
|
||||
'ratecards/fetchAll',
|
||||
async (params: FetchRateCardsParams, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.getRateCards(
|
||||
params.index,
|
||||
params.size,
|
||||
params.field,
|
||||
params.order,
|
||||
params.search
|
||||
);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch RateCards', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to fetch rate cards');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchRateCardById = createAsyncThunk(
|
||||
'ratecard/fetchById',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.getRateCardById(id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch RateCardById', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to fetch rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const createRateCard = createAsyncThunk(
|
||||
'ratecards/create',
|
||||
async (body: RatecardType, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.createRateCard(body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Create RateCard', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to create rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateRateCard = createAsyncThunk(
|
||||
'ratecards/update',
|
||||
async ({ id, body }: { id: string; body: RatecardType }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await rateCardApiService.updateRateCard(id, body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Update RateCard', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to update rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteRateCard = createAsyncThunk(
|
||||
'ratecards/delete',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
await rateCardApiService.deleteRateCard(id);
|
||||
return id;
|
||||
} catch (error) {
|
||||
logger.error('Delete RateCard', error);
|
||||
if (error instanceof Error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
return rejectWithValue('Failed to delete rate card');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const financeSlice = createSlice({
|
||||
name: 'financeReducer',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleRatecardDrawer: state => {
|
||||
state.isRatecardDrawerOpen = !state.isRatecardDrawerOpen;
|
||||
},
|
||||
toggleFinanceDrawer: state => {
|
||||
state.isFinanceDrawerOpen = !state.isFinanceDrawerOpen;
|
||||
},
|
||||
openFinanceDrawer: (state, action: PayloadAction<any>) => {
|
||||
state.isFinanceDrawerOpen = true;
|
||||
state.selectedTask = action.payload;
|
||||
},
|
||||
closeFinanceDrawer: state => {
|
||||
state.isFinanceDrawerOpen = false;
|
||||
state.selectedTask = null;
|
||||
},
|
||||
setSelectedTask: (state, action: PayloadAction<any>) => {
|
||||
state.selectedTask = action.payload;
|
||||
},
|
||||
toggleImportRatecardsDrawer: state => {
|
||||
state.isImportRatecardsDrawerOpen = !state.isImportRatecardsDrawerOpen;
|
||||
},
|
||||
changeCurrency: (state, action: PayloadAction<string>) => {
|
||||
state.currency = action.payload;
|
||||
},
|
||||
ratecardDrawerLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.isFinanceDrawerloading = action.payload;
|
||||
},
|
||||
clearDrawerRatecard: state => {
|
||||
state.drawerRatecard = null;
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
.addCase(fetchRateCards.pending, state => {
|
||||
state.isRatecardsLoading = true;
|
||||
})
|
||||
.addCase(fetchRateCards.fulfilled, (state, action) => {
|
||||
state.isRatecardsLoading = false;
|
||||
state.ratecardsList = Array.isArray(action.payload.data)
|
||||
? action.payload.data
|
||||
: Array.isArray(action.payload)
|
||||
? action.payload
|
||||
: [];
|
||||
})
|
||||
.addCase(fetchRateCards.rejected, state => {
|
||||
state.isRatecardsLoading = false;
|
||||
state.ratecardsList = [];
|
||||
})
|
||||
.addCase(fetchRateCardById.pending, state => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
state.drawerRatecard = null;
|
||||
})
|
||||
.addCase(fetchRateCardById.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
state.drawerRatecard = action.payload;
|
||||
})
|
||||
.addCase(fetchRateCardById.rejected, state => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
state.drawerRatecard = null;
|
||||
})
|
||||
// Create rate card
|
||||
.addCase(createRateCard.pending, state => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(createRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
if (state.ratecardsList) {
|
||||
state.ratecardsList.push(action.payload);
|
||||
} else {
|
||||
state.ratecardsList = [action.payload];
|
||||
}
|
||||
})
|
||||
.addCase(createRateCard.rejected, state => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
})
|
||||
// Update rate card
|
||||
.addCase(updateRateCard.pending, state => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(updateRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
// Update the drawerRatecard with the new data
|
||||
state.drawerRatecard = action.payload;
|
||||
// Update the rate card in the list if it exists
|
||||
if (state.ratecardsList && action.payload?.id) {
|
||||
const index = state.ratecardsList.findIndex(rc => rc.id === action.payload.id);
|
||||
if (index !== -1) {
|
||||
state.ratecardsList[index] = action.payload;
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateRateCard.rejected, state => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
})
|
||||
// Delete rate card
|
||||
.addCase(deleteRateCard.pending, state => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(deleteRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
// Remove the deleted rate card from the list
|
||||
if (state.ratecardsList) {
|
||||
state.ratecardsList = state.ratecardsList.filter(rc => rc.id !== action.payload);
|
||||
}
|
||||
// Clear drawer rate card if it was the deleted one
|
||||
if (state.drawerRatecard?.id === action.payload) {
|
||||
state.drawerRatecard = null;
|
||||
}
|
||||
})
|
||||
.addCase(deleteRateCard.rejected, state => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
toggleRatecardDrawer,
|
||||
toggleFinanceDrawer,
|
||||
openFinanceDrawer,
|
||||
closeFinanceDrawer,
|
||||
setSelectedTask,
|
||||
toggleImportRatecardsDrawer,
|
||||
changeCurrency,
|
||||
ratecardDrawerLoading,
|
||||
clearDrawerRatecard,
|
||||
} = financeSlice.actions;
|
||||
export default financeSlice.reducer;
|
||||
@@ -0,0 +1,691 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
import {
|
||||
IProjectFinanceGroup,
|
||||
IProjectFinanceTask,
|
||||
IProjectRateCard,
|
||||
IProjectFinanceProject,
|
||||
} from '@/types/project/project-finance.types';
|
||||
|
||||
type FinanceTabType = 'finance' | 'ratecard';
|
||||
type GroupTypes = 'status' | 'priority' | 'phases';
|
||||
type BillableFilterType = 'all' | 'billable' | 'non-billable';
|
||||
|
||||
interface ProjectFinanceState {
|
||||
activeTab: FinanceTabType;
|
||||
activeGroup: GroupTypes;
|
||||
billableFilter: BillableFilterType;
|
||||
loading: boolean;
|
||||
taskGroups: IProjectFinanceGroup[];
|
||||
projectRateCards: IProjectRateCard[];
|
||||
project: IProjectFinanceProject | null;
|
||||
}
|
||||
|
||||
// Enhanced utility functions for efficient frontend calculations
|
||||
const secondsToHours = (seconds: number) => seconds / 3600;
|
||||
|
||||
const calculateTaskCosts = (task: IProjectFinanceTask) => {
|
||||
const hours = secondsToHours(task.estimated_seconds || 0);
|
||||
const timeLoggedHours = secondsToHours(task.total_time_logged_seconds || 0);
|
||||
|
||||
const totalBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0);
|
||||
// task.total_actual already includes actual_cost_from_logs + fixed_cost from backend
|
||||
const totalActual = task.total_actual || 0;
|
||||
const variance = totalActual - totalBudget;
|
||||
|
||||
return {
|
||||
hours,
|
||||
timeLoggedHours,
|
||||
totalBudget,
|
||||
totalActual,
|
||||
variance,
|
||||
};
|
||||
};
|
||||
|
||||
// Memoization cache for task calculations to improve performance
|
||||
const taskCalculationCache = new Map<
|
||||
string,
|
||||
{
|
||||
task: IProjectFinanceTask;
|
||||
result: IProjectFinanceTask;
|
||||
timestamp: number;
|
||||
}
|
||||
>();
|
||||
|
||||
// Cache cleanup interval (5 minutes)
|
||||
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000;
|
||||
const CACHE_MAX_AGE = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
// Periodic cache cleanup
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
Array.from(taskCalculationCache.entries()).forEach(([key, value]) => {
|
||||
if (now - value.timestamp > CACHE_MAX_AGE) {
|
||||
taskCalculationCache.delete(key);
|
||||
}
|
||||
});
|
||||
}, CACHE_CLEANUP_INTERVAL);
|
||||
|
||||
// Generate cache key for task
|
||||
const generateTaskCacheKey = (task: IProjectFinanceTask): string => {
|
||||
return `${task.id}-${task.estimated_cost}-${task.fixed_cost}-${task.total_actual}-${task.estimated_seconds}-${task.total_time_logged_seconds}`;
|
||||
};
|
||||
|
||||
// Check if task has changed significantly to warrant recalculation
|
||||
const hasTaskChanged = (oldTask: IProjectFinanceTask, newTask: IProjectFinanceTask): boolean => {
|
||||
return (
|
||||
oldTask.estimated_cost !== newTask.estimated_cost ||
|
||||
oldTask.fixed_cost !== newTask.fixed_cost ||
|
||||
oldTask.total_actual !== newTask.total_actual ||
|
||||
oldTask.estimated_seconds !== newTask.estimated_seconds ||
|
||||
oldTask.total_time_logged_seconds !== newTask.total_time_logged_seconds
|
||||
);
|
||||
};
|
||||
|
||||
// Optimized recursive calculation for task hierarchy with memoization
|
||||
const recalculateTaskHierarchy = (tasks: IProjectFinanceTask[]): IProjectFinanceTask[] => {
|
||||
return tasks.map(task => {
|
||||
// If task has loaded subtasks, recalculate from subtasks
|
||||
if (task.sub_tasks && task.sub_tasks.length > 0) {
|
||||
const updatedSubTasks = recalculateTaskHierarchy(task.sub_tasks);
|
||||
|
||||
// Calculate totals from subtasks only (for time and costs from logs)
|
||||
const subtaskTotals = updatedSubTasks.reduce(
|
||||
(acc, subtask) => ({
|
||||
estimated_cost: acc.estimated_cost + (subtask.estimated_cost || 0),
|
||||
fixed_cost: acc.fixed_cost + (subtask.fixed_cost || 0),
|
||||
actual_cost_from_logs: acc.actual_cost_from_logs + (subtask.actual_cost_from_logs || 0),
|
||||
estimated_seconds: acc.estimated_seconds + (subtask.estimated_seconds || 0),
|
||||
total_time_logged_seconds:
|
||||
acc.total_time_logged_seconds + (subtask.total_time_logged_seconds || 0),
|
||||
}),
|
||||
{
|
||||
estimated_cost: 0,
|
||||
fixed_cost: 0,
|
||||
actual_cost_from_logs: 0,
|
||||
estimated_seconds: 0,
|
||||
total_time_logged_seconds: 0,
|
||||
}
|
||||
);
|
||||
|
||||
// For parent tasks with loaded subtasks: use ONLY the subtask totals
|
||||
// The parent's original values were backend-aggregated, now we use frontend subtask aggregation
|
||||
const totalFixedCost = subtaskTotals.fixed_cost; // Only subtask fixed costs
|
||||
const totalEstimatedCost = subtaskTotals.estimated_cost; // Only subtask estimated costs
|
||||
const totalActualCostFromLogs = subtaskTotals.actual_cost_from_logs; // Only subtask logged costs
|
||||
const totalActual = totalActualCostFromLogs + totalFixedCost;
|
||||
|
||||
// Update parent task with aggregated values
|
||||
const updatedTask = {
|
||||
...task,
|
||||
sub_tasks: updatedSubTasks,
|
||||
estimated_cost: totalEstimatedCost,
|
||||
fixed_cost: totalFixedCost,
|
||||
actual_cost_from_logs: totalActualCostFromLogs,
|
||||
total_actual: totalActual,
|
||||
estimated_seconds: subtaskTotals.estimated_seconds,
|
||||
total_time_logged_seconds: subtaskTotals.total_time_logged_seconds,
|
||||
total_budget: totalEstimatedCost + totalFixedCost,
|
||||
variance: totalActual - (totalEstimatedCost + totalFixedCost),
|
||||
};
|
||||
|
||||
return updatedTask;
|
||||
}
|
||||
|
||||
// For parent tasks without loaded subtasks, trust backend-calculated values
|
||||
if (task.sub_tasks_count > 0 && (!task.sub_tasks || task.sub_tasks.length === 0)) {
|
||||
// Parent task with unloaded subtasks - backend has already calculated aggregated values
|
||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||
return {
|
||||
...task,
|
||||
total_budget: totalBudget,
|
||||
total_actual: totalActual,
|
||||
variance: variance,
|
||||
};
|
||||
}
|
||||
|
||||
// For leaf tasks, check cache first
|
||||
const cacheKey = generateTaskCacheKey(task);
|
||||
const cached = taskCalculationCache.get(cacheKey);
|
||||
|
||||
if (cached && !hasTaskChanged(cached.task, task)) {
|
||||
return { ...cached.result, ...task }; // Merge with current task to preserve other properties
|
||||
}
|
||||
|
||||
// For leaf tasks, just recalculate their own values
|
||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||
const updatedTask = {
|
||||
...task,
|
||||
total_budget: totalBudget,
|
||||
total_actual: totalActual,
|
||||
variance: variance,
|
||||
};
|
||||
|
||||
// Cache the result only for leaf tasks
|
||||
taskCalculationCache.set(cacheKey, {
|
||||
task: { ...task },
|
||||
result: updatedTask,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return updatedTask;
|
||||
});
|
||||
};
|
||||
|
||||
// Optimized function to find and update a specific task, then recalculate hierarchy
|
||||
const updateTaskAndRecalculateHierarchy = (
|
||||
tasks: IProjectFinanceTask[],
|
||||
targetId: string,
|
||||
updateFn: (task: IProjectFinanceTask) => IProjectFinanceTask
|
||||
): { updated: boolean; tasks: IProjectFinanceTask[] } => {
|
||||
let updated = false;
|
||||
|
||||
const updatedTasks = tasks.map(task => {
|
||||
if (task.id === targetId) {
|
||||
updated = true;
|
||||
return updateFn(task);
|
||||
}
|
||||
|
||||
// Search in subtasks recursively
|
||||
if (task.sub_tasks && task.sub_tasks.length > 0) {
|
||||
const result = updateTaskAndRecalculateHierarchy(task.sub_tasks, targetId, updateFn);
|
||||
if (result.updated) {
|
||||
updated = true;
|
||||
return {
|
||||
...task,
|
||||
sub_tasks: result.tasks,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return task;
|
||||
});
|
||||
|
||||
// If a task was updated, recalculate the entire hierarchy to ensure parent totals are correct
|
||||
return {
|
||||
updated,
|
||||
tasks: updated ? recalculateTaskHierarchy(updatedTasks) : updatedTasks,
|
||||
};
|
||||
};
|
||||
|
||||
const initialState: ProjectFinanceState = {
|
||||
activeTab: 'finance',
|
||||
activeGroup: 'status',
|
||||
billableFilter: 'billable',
|
||||
loading: false,
|
||||
taskGroups: [],
|
||||
projectRateCards: [],
|
||||
project: null,
|
||||
};
|
||||
|
||||
export const fetchProjectFinances = createAsyncThunk(
|
||||
'projectFinances/fetchProjectFinances',
|
||||
async ({
|
||||
projectId,
|
||||
groupBy,
|
||||
billableFilter,
|
||||
}: {
|
||||
projectId: string;
|
||||
groupBy: GroupTypes;
|
||||
billableFilter?: BillableFilterType;
|
||||
}) => {
|
||||
const response = await projectFinanceApiService.getProjectTasks(
|
||||
projectId,
|
||||
groupBy,
|
||||
billableFilter
|
||||
);
|
||||
return response.body;
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchProjectFinancesSilent = createAsyncThunk(
|
||||
'projectFinances/fetchProjectFinancesSilent',
|
||||
async ({
|
||||
projectId,
|
||||
groupBy,
|
||||
billableFilter,
|
||||
resetExpansions = false,
|
||||
}: {
|
||||
projectId: string;
|
||||
groupBy: GroupTypes;
|
||||
billableFilter?: BillableFilterType;
|
||||
resetExpansions?: boolean;
|
||||
}) => {
|
||||
const response = await projectFinanceApiService.getProjectTasks(
|
||||
projectId,
|
||||
groupBy,
|
||||
billableFilter
|
||||
);
|
||||
return { ...response.body, resetExpansions };
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchSubTasks = createAsyncThunk(
|
||||
'projectFinances/fetchSubTasks',
|
||||
async ({
|
||||
projectId,
|
||||
parentTaskId,
|
||||
billableFilter,
|
||||
}: {
|
||||
projectId: string;
|
||||
parentTaskId: string;
|
||||
billableFilter?: BillableFilterType;
|
||||
}) => {
|
||||
const response = await projectFinanceApiService.getSubTasks(
|
||||
projectId,
|
||||
parentTaskId,
|
||||
billableFilter
|
||||
);
|
||||
return { parentTaskId, subTasks: response.body };
|
||||
}
|
||||
);
|
||||
|
||||
export const updateTaskFixedCostAsync = createAsyncThunk(
|
||||
'projectFinances/updateTaskFixedCostAsync',
|
||||
async ({
|
||||
taskId,
|
||||
groupId,
|
||||
fixedCost,
|
||||
}: {
|
||||
taskId: string;
|
||||
groupId: string;
|
||||
fixedCost: number;
|
||||
}) => {
|
||||
await projectFinanceApiService.updateTaskFixedCost(taskId, fixedCost);
|
||||
return { taskId, groupId, fixedCost };
|
||||
}
|
||||
);
|
||||
|
||||
export const updateProjectCalculationMethodAsync = createAsyncThunk(
|
||||
'projectFinances/updateProjectCalculationMethodAsync',
|
||||
async ({
|
||||
projectId,
|
||||
calculationMethod,
|
||||
hoursPerDay,
|
||||
}: {
|
||||
projectId: string;
|
||||
calculationMethod: 'hourly' | 'man_days';
|
||||
hoursPerDay?: number;
|
||||
}) => {
|
||||
await projectFinanceApiService.updateProjectCalculationMethod(
|
||||
projectId,
|
||||
calculationMethod,
|
||||
hoursPerDay
|
||||
);
|
||||
return { calculationMethod, hoursPerDay };
|
||||
}
|
||||
);
|
||||
|
||||
export const updateTaskEstimatedManDaysAsync = createAsyncThunk(
|
||||
'projectFinances/updateTaskEstimatedManDaysAsync',
|
||||
async ({
|
||||
taskId,
|
||||
groupId,
|
||||
estimatedManDays,
|
||||
}: {
|
||||
taskId: string;
|
||||
groupId: string;
|
||||
estimatedManDays: number;
|
||||
}) => {
|
||||
await projectFinanceApiService.updateTaskEstimatedManDays(taskId, estimatedManDays);
|
||||
return { taskId, groupId, estimatedManDays };
|
||||
}
|
||||
);
|
||||
|
||||
export const updateRateCardManDayRateAsync = createAsyncThunk(
|
||||
'projectFinances/updateRateCardManDayRateAsync',
|
||||
async ({ rateCardRoleId, manDayRate }: { rateCardRoleId: string; manDayRate: number }) => {
|
||||
await projectFinanceApiService.updateRateCardManDayRate(rateCardRoleId, manDayRate);
|
||||
return { rateCardRoleId, manDayRate };
|
||||
}
|
||||
);
|
||||
|
||||
// Function to clear calculation cache (useful for testing or when data is refreshed)
|
||||
const clearCalculationCache = () => {
|
||||
taskCalculationCache.clear();
|
||||
};
|
||||
|
||||
export const projectFinancesSlice = createSlice({
|
||||
name: 'projectFinances',
|
||||
initialState,
|
||||
reducers: {
|
||||
setActiveTab: (state, action: PayloadAction<FinanceTabType>) => {
|
||||
state.activeTab = action.payload;
|
||||
},
|
||||
setActiveGroup: (state, action: PayloadAction<GroupTypes>) => {
|
||||
state.activeGroup = action.payload;
|
||||
},
|
||||
setBillableFilter: (state, action: PayloadAction<BillableFilterType>) => {
|
||||
state.billableFilter = action.payload;
|
||||
},
|
||||
resetAllTaskExpansions: state => {
|
||||
// Recursive function to reset all expansion states
|
||||
const resetExpansionStates = (tasks: IProjectFinanceTask[]): IProjectFinanceTask[] => {
|
||||
return tasks.map(task => ({
|
||||
...task,
|
||||
show_sub_tasks: false,
|
||||
sub_tasks: task.sub_tasks ? resetExpansionStates(task.sub_tasks) : task.sub_tasks,
|
||||
}));
|
||||
};
|
||||
|
||||
// Reset expansion states for all groups
|
||||
state.taskGroups = state.taskGroups.map(group => ({
|
||||
...group,
|
||||
tasks: resetExpansionStates(group.tasks),
|
||||
}));
|
||||
},
|
||||
updateTaskFixedCost: (
|
||||
state,
|
||||
action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>
|
||||
) => {
|
||||
const { taskId, groupId, fixedCost } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
fixed_cost: fixedCost,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateTaskEstimatedCost: (
|
||||
state,
|
||||
action: PayloadAction<{ taskId: string; groupId: string; estimatedCost: number }>
|
||||
) => {
|
||||
const { taskId, groupId, estimatedCost } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
estimated_cost: estimatedCost,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateTaskTimeLogged: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
taskId: string;
|
||||
groupId: string;
|
||||
timeLoggedSeconds: number;
|
||||
timeLoggedString: string;
|
||||
totalActual: number;
|
||||
}>
|
||||
) => {
|
||||
const { taskId, groupId, timeLoggedSeconds, timeLoggedString, totalActual } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
total_time_logged_seconds: timeLoggedSeconds,
|
||||
total_time_logged: timeLoggedString,
|
||||
total_actual: totalActual,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleTaskExpansion: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => {
|
||||
const { taskId, groupId } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
// Recursive function to find and toggle a task in the hierarchy
|
||||
const findAndToggleTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => {
|
||||
for (const task of tasks) {
|
||||
if (task.id === targetId) {
|
||||
task.show_sub_tasks = !task.show_sub_tasks;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in subtasks recursively
|
||||
if (task.sub_tasks && findAndToggleTask(task.sub_tasks, targetId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
findAndToggleTask(group.tasks, taskId);
|
||||
}
|
||||
},
|
||||
updateProjectFinanceCurrency: (state, action: PayloadAction<string>) => {
|
||||
if (state.project) {
|
||||
state.project.currency = action.payload;
|
||||
}
|
||||
},
|
||||
updateProjectCalculationMethod: (
|
||||
state,
|
||||
action: PayloadAction<{ calculationMethod: 'hourly' | 'man_days'; hoursPerDay?: number }>
|
||||
) => {
|
||||
if (state.project) {
|
||||
state.project.calculation_method = action.payload.calculationMethod;
|
||||
if (action.payload.hoursPerDay !== undefined) {
|
||||
state.project.hours_per_day = action.payload.hoursPerDay;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateTaskEstimatedManDays: (
|
||||
state,
|
||||
action: PayloadAction<{ taskId: string; groupId: string; estimatedManDays: number }>
|
||||
) => {
|
||||
const { taskId, groupId, estimatedManDays } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
estimated_man_days: estimatedManDays,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateRateCardManDayRate: (
|
||||
state,
|
||||
action: PayloadAction<{ rateCardRoleId: string; manDayRate: number }>
|
||||
) => {
|
||||
const { rateCardRoleId, manDayRate } = action.payload;
|
||||
const rateCard = state.projectRateCards.find(rc => rc.id === rateCardRoleId);
|
||||
|
||||
if (rateCard) {
|
||||
rateCard.man_day_rate = manDayRate.toString();
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
.addCase(fetchProjectFinances.pending, state => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(fetchProjectFinances.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
// Apply hierarchy recalculation to ensure parent tasks show correct aggregated values
|
||||
const recalculatedGroups = action.payload.groups.map((group: IProjectFinanceGroup) => ({
|
||||
...group,
|
||||
tasks: recalculateTaskHierarchy(group.tasks),
|
||||
}));
|
||||
state.taskGroups = recalculatedGroups;
|
||||
state.projectRateCards = action.payload.project_rate_cards;
|
||||
state.project = action.payload.project;
|
||||
// Clear cache when fresh data is loaded
|
||||
clearCalculationCache();
|
||||
})
|
||||
.addCase(fetchProjectFinances.rejected, state => {
|
||||
state.loading = false;
|
||||
})
|
||||
.addCase(fetchProjectFinancesSilent.fulfilled, (state, action) => {
|
||||
const { resetExpansions, ...payload } = action.payload;
|
||||
|
||||
if (resetExpansions) {
|
||||
// Reset all expansions and load fresh data
|
||||
const recalculatedGroups = payload.groups.map((group: IProjectFinanceGroup) => ({
|
||||
...group,
|
||||
tasks: recalculateTaskHierarchy(group.tasks),
|
||||
}));
|
||||
state.taskGroups = recalculatedGroups;
|
||||
} else {
|
||||
// Helper function to preserve expansion state and sub_tasks during updates
|
||||
const preserveExpansionState = (
|
||||
existingTasks: IProjectFinanceTask[],
|
||||
newTasks: IProjectFinanceTask[]
|
||||
): IProjectFinanceTask[] => {
|
||||
return newTasks.map(newTask => {
|
||||
const existingTask = existingTasks.find(t => t.id === newTask.id);
|
||||
if (existingTask) {
|
||||
// Preserve expansion state and subtasks
|
||||
const updatedTask = {
|
||||
...newTask,
|
||||
show_sub_tasks: existingTask.show_sub_tasks,
|
||||
sub_tasks: existingTask.sub_tasks
|
||||
? preserveExpansionState(existingTask.sub_tasks, newTask.sub_tasks || [])
|
||||
: newTask.sub_tasks,
|
||||
};
|
||||
return updatedTask;
|
||||
}
|
||||
return newTask;
|
||||
});
|
||||
};
|
||||
|
||||
// Update groups while preserving expansion state and applying hierarchy recalculation
|
||||
const updatedTaskGroups = payload.groups.map((newGroup: IProjectFinanceGroup) => {
|
||||
const existingGroup = state.taskGroups.find(g => g.group_id === newGroup.group_id);
|
||||
if (existingGroup) {
|
||||
const tasksWithExpansion = preserveExpansionState(
|
||||
existingGroup.tasks,
|
||||
newGroup.tasks
|
||||
);
|
||||
return {
|
||||
...newGroup,
|
||||
tasks: recalculateTaskHierarchy(tasksWithExpansion),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...newGroup,
|
||||
tasks: recalculateTaskHierarchy(newGroup.tasks),
|
||||
};
|
||||
});
|
||||
state.taskGroups = updatedTaskGroups;
|
||||
}
|
||||
|
||||
// Update data without changing loading state for silent refresh
|
||||
state.projectRateCards = payload.project_rate_cards;
|
||||
state.project = payload.project;
|
||||
// Clear cache when data is refreshed from backend
|
||||
clearCalculationCache();
|
||||
})
|
||||
.addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => {
|
||||
const { taskId, groupId, fixedCost } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
// Update the specific task's fixed cost and recalculate the entire hierarchy
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
fixed_cost: fixedCost,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
clearCalculationCache();
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(fetchSubTasks.fulfilled, (state, action) => {
|
||||
const { parentTaskId, subTasks } = action.payload;
|
||||
|
||||
// Recursive function to find and update a task in the hierarchy
|
||||
const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => {
|
||||
for (const task of tasks) {
|
||||
if (task.id === targetId) {
|
||||
// Found the parent task, add subtasks
|
||||
task.sub_tasks = subTasks.map(subTask => ({
|
||||
...subTask,
|
||||
is_sub_task: true,
|
||||
parent_task_id: targetId,
|
||||
}));
|
||||
task.show_sub_tasks = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in subtasks recursively
|
||||
if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Find the parent task in any group and add the subtasks
|
||||
for (const group of state.taskGroups) {
|
||||
if (findAndUpdateTask(group.tasks, parentTaskId)) {
|
||||
// Recalculate the hierarchy after adding subtasks to ensure parent values are correct
|
||||
group.tasks = recalculateTaskHierarchy(group.tasks);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateProjectCalculationMethodAsync.fulfilled, (state, action) => {
|
||||
if (state.project) {
|
||||
state.project.calculation_method = action.payload.calculationMethod;
|
||||
if (action.payload.hoursPerDay !== undefined) {
|
||||
state.project.hours_per_day = action.payload.hoursPerDay;
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateTaskEstimatedManDaysAsync.fulfilled, (state, action) => {
|
||||
const { taskId, groupId, estimatedManDays } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
estimated_man_days: estimatedManDays,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
clearCalculationCache();
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateRateCardManDayRateAsync.fulfilled, (state, action) => {
|
||||
const { rateCardRoleId, manDayRate } = action.payload;
|
||||
const rateCard = state.projectRateCards.find(rc => rc.id === rateCardRoleId);
|
||||
|
||||
if (rateCard) {
|
||||
rateCard.man_day_rate = manDayRate.toString();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setActiveTab,
|
||||
setActiveGroup,
|
||||
setBillableFilter,
|
||||
resetAllTaskExpansions,
|
||||
updateTaskFixedCost,
|
||||
updateTaskEstimatedCost,
|
||||
updateTaskTimeLogged,
|
||||
toggleTaskExpansion,
|
||||
updateProjectFinanceCurrency,
|
||||
updateProjectCalculationMethod,
|
||||
updateTaskEstimatedManDays,
|
||||
updateRateCardManDayRate,
|
||||
} = projectFinancesSlice.actions;
|
||||
|
||||
export default projectFinancesSlice.reducer;
|
||||
@@ -5,6 +5,7 @@ import i18n from '@/i18n';
|
||||
// Import core components synchronously to avoid suspense in main tabs
|
||||
import ProjectViewEnhancedBoard from '@/pages/projects/projectView/enhancedBoard/project-view-enhanced-board';
|
||||
import TaskListV2 from '@/components/task-list-v2/TaskListV2';
|
||||
import ProjectViewFinance from '@/pages/projects/projectView/finance/ProjectViewFinance';
|
||||
|
||||
// Lazy load less critical components
|
||||
const ProjectViewInsights = React.lazy(
|
||||
@@ -117,6 +118,16 @@ export const tabItems: TabItems[] = [
|
||||
React.createElement(ProjectViewUpdates)
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 6,
|
||||
key: 'finance',
|
||||
label: getTabLabel('finance'),
|
||||
element: React.createElement(
|
||||
Suspense,
|
||||
{ fallback: React.createElement(InlineSuspenseFallback) },
|
||||
React.createElement(ProjectViewFinance)
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Function to update tab labels when language changes
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
export enum FinanceTableColumnKeys {
|
||||
TASK = 'task',
|
||||
MEMBERS = 'members',
|
||||
HOURS = 'hours',
|
||||
MAN_DAYS = 'man_days',
|
||||
TOTAL_TIME_LOGGED = 'total_time_logged',
|
||||
ESTIMATED_COST = 'estimated_cost',
|
||||
COST = 'cost',
|
||||
FIXED_COST = 'fixedCost',
|
||||
TOTAL_BUDGET = 'totalBudget',
|
||||
TOTAL_ACTUAL = 'totalActual',
|
||||
VARIANCE = 'variance',
|
||||
}
|
||||
|
||||
type FinanceTableColumnsType = {
|
||||
key: FinanceTableColumnKeys;
|
||||
name: string;
|
||||
width: number;
|
||||
type: 'string' | 'hours' | 'currency' | 'man_days' | 'effort_variance';
|
||||
render?: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
// finance table columns
|
||||
export const financeTableColumns: FinanceTableColumnsType[] = [
|
||||
{
|
||||
key: FinanceTableColumnKeys.TASK,
|
||||
name: 'taskColumn',
|
||||
width: 240,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.MEMBERS,
|
||||
name: 'membersColumn',
|
||||
width: 120,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.HOURS,
|
||||
name: 'hoursColumn',
|
||||
width: 100,
|
||||
type: 'hours',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.MAN_DAYS,
|
||||
name: 'manDaysColumn',
|
||||
width: 100,
|
||||
type: 'man_days',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.TOTAL_TIME_LOGGED,
|
||||
name: 'totalTimeLoggedColumn',
|
||||
width: 120,
|
||||
type: 'hours',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.ESTIMATED_COST,
|
||||
name: 'estimatedCostColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.COST,
|
||||
name: 'costColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.FIXED_COST,
|
||||
name: 'fixedCostColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.TOTAL_BUDGET,
|
||||
name: 'totalBudgetedCostColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.TOTAL_ACTUAL,
|
||||
name: 'totalActualCostColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.VARIANCE,
|
||||
name: 'varianceColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
];
|
||||
|
||||
// Function to get columns based on calculation method
|
||||
export const getFinanceTableColumns = (
|
||||
calculationMethod: 'hourly' | 'man_days' = 'hourly'
|
||||
): FinanceTableColumnsType[] => {
|
||||
return financeTableColumns.filter(column => {
|
||||
// Always show these columns
|
||||
if (
|
||||
[
|
||||
FinanceTableColumnKeys.TASK,
|
||||
FinanceTableColumnKeys.MEMBERS,
|
||||
FinanceTableColumnKeys.TOTAL_TIME_LOGGED,
|
||||
FinanceTableColumnKeys.ESTIMATED_COST,
|
||||
FinanceTableColumnKeys.COST,
|
||||
FinanceTableColumnKeys.FIXED_COST,
|
||||
FinanceTableColumnKeys.TOTAL_BUDGET,
|
||||
FinanceTableColumnKeys.TOTAL_ACTUAL,
|
||||
FinanceTableColumnKeys.VARIANCE,
|
||||
].includes(column.key)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Show hours column only for hourly calculation
|
||||
if (column.key === FinanceTableColumnKeys.HOURS) {
|
||||
return calculationMethod === 'hourly';
|
||||
}
|
||||
|
||||
// Show man days columns only for man days calculation
|
||||
if ([FinanceTableColumnKeys.MAN_DAYS].includes(column.key)) {
|
||||
return calculationMethod === 'man_days';
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import RateCardSettings from '@/pages/settings/rate-card-settings/RateCardSettings';
|
||||
import {
|
||||
BankOutlined,
|
||||
FileZipOutlined,
|
||||
@@ -12,20 +13,33 @@ import {
|
||||
UserOutlined,
|
||||
UserSwitchOutlined,
|
||||
BulbOutlined,
|
||||
DollarCircleOutlined
|
||||
} from '@/shared/antd-imports';
|
||||
import React, { ReactNode, lazy } from 'react';
|
||||
const ProfileSettings = lazy(() => import('../../pages/settings/profile/profile-settings'));
|
||||
const NotificationsSettings = lazy(() => import('../../pages/settings/notifications/notifications-settings'));
|
||||
const NotificationsSettings = lazy(
|
||||
() => import('../../pages/settings/notifications/notifications-settings')
|
||||
);
|
||||
const ClientsSettings = lazy(() => import('../../pages/settings/clients/clients-settings'));
|
||||
const JobTitlesSettings = lazy(() => import('@/pages/settings/job-titles/job-titles-settings'));
|
||||
const LabelsSettings = lazy(() => import('../../pages/settings/labels/labels-settings'));
|
||||
const CategoriesSettings = lazy(() => import('../../pages/settings/categories/categories-settings'));
|
||||
const ProjectTemplatesSettings = lazy(() => import('@/pages/settings/project-templates/project-templates-settings'));
|
||||
const TaskTemplatesSettings = lazy(() => import('@/pages/settings/task-templates/task-templates-settings'));
|
||||
const TeamMembersSettings = lazy(() => import('@/pages/settings/team-members/team-members-settings'));
|
||||
const CategoriesSettings = lazy(
|
||||
() => import('../../pages/settings/categories/categories-settings')
|
||||
);
|
||||
const ProjectTemplatesSettings = lazy(
|
||||
() => import('@/pages/settings/project-templates/project-templates-settings')
|
||||
);
|
||||
const TaskTemplatesSettings = lazy(
|
||||
() => import('@/pages/settings/task-templates/task-templates-settings')
|
||||
);
|
||||
const TeamMembersSettings = lazy(
|
||||
() => import('@/pages/settings/team-members/team-members-settings')
|
||||
);
|
||||
const TeamsSettings = lazy(() => import('../../pages/settings/teams/teams-settings'));
|
||||
const ChangePassword = lazy(() => import('@/pages/settings/change-password/change-password'));
|
||||
const LanguageAndRegionSettings = lazy(() => import('@/pages/settings/language-and-region/language-and-region-settings'));
|
||||
const LanguageAndRegionSettings = lazy(
|
||||
() => import('@/pages/settings/language-and-region/language-and-region-settings')
|
||||
);
|
||||
const AppearanceSettings = lazy(() => import('@/pages/settings/appearance/appearance-settings'));
|
||||
|
||||
// type of menu item in settings sidebar
|
||||
@@ -132,6 +146,13 @@ export const settingsItems: SettingMenuItems[] = [
|
||||
element: React.createElement(TeamMembersSettings),
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
key: 'ratecard',
|
||||
name: 'Rate Card',
|
||||
endpoint: 'ratecard',
|
||||
icon: React.createElement(DollarCircleOutlined),
|
||||
element: React.createElement(RateCardSettings),
|
||||
},
|
||||
{
|
||||
key: 'teams',
|
||||
name: 'teams',
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -184,6 +184,9 @@ export {
|
||||
CloudDownloadOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
CalculatorOutlined,
|
||||
DollarOutlined,
|
||||
DollarCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
// Re-export all components with React
|
||||
|
||||
54
worklenz-frontend/src/shared/currencies.ts
Normal file
54
worklenz-frontend/src/shared/currencies.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface CurrencyOption {
|
||||
value: string;
|
||||
label: string;
|
||||
symbol?: string;
|
||||
}
|
||||
|
||||
export const CURRENCY_OPTIONS: CurrencyOption[] = [
|
||||
{ value: 'usd', label: 'USD - US Dollar', symbol: '$' },
|
||||
{ value: 'eur', label: 'EUR - Euro', symbol: '€' },
|
||||
{ value: 'gbp', label: 'GBP - British Pound', symbol: '£' },
|
||||
{ value: 'jpy', label: 'JPY - Japanese Yen', symbol: '¥' },
|
||||
{ value: 'cad', label: 'CAD - Canadian Dollar', symbol: 'C$' },
|
||||
{ value: 'aud', label: 'AUD - Australian Dollar', symbol: 'A$' },
|
||||
{ value: 'chf', label: 'CHF - Swiss Franc', symbol: 'CHF' },
|
||||
{ value: 'cny', label: 'CNY - Chinese Yuan', symbol: '¥' },
|
||||
{ value: 'inr', label: 'INR - Indian Rupee', symbol: '₹' },
|
||||
{ value: 'lkr', label: 'LKR - Sri Lankan Rupee', symbol: 'Rs' },
|
||||
{ value: 'sgd', label: 'SGD - Singapore Dollar', symbol: 'S$' },
|
||||
{ value: 'hkd', label: 'HKD - Hong Kong Dollar', symbol: 'HK$' },
|
||||
{ value: 'nzd', label: 'NZD - New Zealand Dollar', symbol: 'NZ$' },
|
||||
{ value: 'sek', label: 'SEK - Swedish Krona', symbol: 'kr' },
|
||||
{ value: 'nok', label: 'NOK - Norwegian Krone', symbol: 'kr' },
|
||||
{ value: 'dkk', label: 'DKK - Danish Krone', symbol: 'kr' },
|
||||
{ value: 'pln', label: 'PLN - Polish Zloty', symbol: 'zł' },
|
||||
{ value: 'czk', label: 'CZK - Czech Koruna', symbol: 'Kč' },
|
||||
{ value: 'huf', label: 'HUF - Hungarian Forint', symbol: 'Ft' },
|
||||
{ value: 'rub', label: 'RUB - Russian Ruble', symbol: '₽' },
|
||||
{ value: 'brl', label: 'BRL - Brazilian Real', symbol: 'R$' },
|
||||
{ value: 'mxn', label: 'MXN - Mexican Peso', symbol: '$' },
|
||||
{ value: 'zar', label: 'ZAR - South African Rand', symbol: 'R' },
|
||||
{ value: 'krw', label: 'KRW - South Korean Won', symbol: '₩' },
|
||||
{ value: 'thb', label: 'THB - Thai Baht', symbol: '฿' },
|
||||
{ value: 'myr', label: 'MYR - Malaysian Ringgit', symbol: 'RM' },
|
||||
{ value: 'idr', label: 'IDR - Indonesian Rupiah', symbol: 'Rp' },
|
||||
{ value: 'php', label: 'PHP - Philippine Peso', symbol: '₱' },
|
||||
{ value: 'vnd', label: 'VND - Vietnamese Dong', symbol: '₫' },
|
||||
{ value: 'aed', label: 'AED - UAE Dirham', symbol: 'د.إ' },
|
||||
{ value: 'sar', label: 'SAR - Saudi Riyal', symbol: '﷼' },
|
||||
{ value: 'egp', label: 'EGP - Egyptian Pound', symbol: '£' },
|
||||
{ value: 'try', label: 'TRY - Turkish Lira', symbol: '₺' },
|
||||
{ value: 'ils', label: 'ILS - Israeli Shekel', symbol: '₪' },
|
||||
];
|
||||
|
||||
export const DEFAULT_CURRENCY = 'usd';
|
||||
|
||||
export const getCurrencySymbol = (currencyCode: string): string => {
|
||||
const currency = CURRENCY_OPTIONS.find(c => c.value === currencyCode.toLowerCase());
|
||||
return currency?.symbol || currencyCode.toUpperCase();
|
||||
};
|
||||
|
||||
export const getCurrencyLabel = (currencyCode: string): string => {
|
||||
const currency = CURRENCY_OPTIONS.find(c => c.value === currencyCode.toLowerCase());
|
||||
return currency?.label || currencyCode.toUpperCase();
|
||||
};
|
||||
127
worklenz-frontend/src/types/project/project-finance.types.ts
Normal file
127
worklenz-frontend/src/types/project/project-finance.types.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
export interface IProjectFinanceUser {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceJobTitle {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceMember {
|
||||
team_member_id: string;
|
||||
project_member_id: string;
|
||||
name: string;
|
||||
email_notifications_enabled: boolean;
|
||||
avatar_url: string | null;
|
||||
user_id: string;
|
||||
email: string;
|
||||
socket_id: string | null;
|
||||
team_id: string;
|
||||
color_code: string;
|
||||
project_rate_card_role_id: string | null;
|
||||
rate: number;
|
||||
man_day_rate: number;
|
||||
job_title_id: string | null;
|
||||
job_title_name: string | null;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceTask {
|
||||
id: string;
|
||||
name: string;
|
||||
estimated_seconds: number;
|
||||
total_minutes: number; // Total estimated time in minutes
|
||||
estimated_hours: string; // Formatted time string like "4h 30m 12s"
|
||||
|
||||
total_time_logged_seconds: number;
|
||||
total_time_logged: string; // Formatted time string like "4h 30m 12s"
|
||||
estimated_cost: number;
|
||||
actual_cost_from_logs: number;
|
||||
members: IProjectFinanceMember[];
|
||||
billable: boolean;
|
||||
fixed_cost: number;
|
||||
variance: number; // Cost variance (currency)
|
||||
effort_variance_man_days?: number | null; // Effort variance in man days (only for man_days projects)
|
||||
actual_man_days?: number | null; // Actual man days spent (only for man_days projects)
|
||||
total_budget: number;
|
||||
total_actual: number;
|
||||
sub_tasks_count: number; // Number of subtasks
|
||||
sub_tasks?: IProjectFinanceTask[]; // Loaded subtasks
|
||||
show_sub_tasks?: boolean; // Whether subtasks are expanded
|
||||
is_sub_task?: boolean; // Whether this is a subtask
|
||||
parent_task_id?: string; // Parent task ID for subtasks
|
||||
}
|
||||
|
||||
export interface IProjectFinanceGroup {
|
||||
group_id: string;
|
||||
group_name: string;
|
||||
color_code: string;
|
||||
color_code_dark: string;
|
||||
tasks: IProjectFinanceTask[];
|
||||
}
|
||||
|
||||
export interface IProjectRateCard {
|
||||
id: string;
|
||||
project_id: string;
|
||||
job_title_id: string;
|
||||
rate: string;
|
||||
man_day_rate: string;
|
||||
job_title_name: string;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceProject {
|
||||
id: string;
|
||||
name: string;
|
||||
currency: string;
|
||||
calculation_method: 'hourly' | 'man_days';
|
||||
hours_per_day: number;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceResponse {
|
||||
groups: IProjectFinanceGroup[];
|
||||
project_rate_cards: IProjectRateCard[];
|
||||
project: IProjectFinanceProject;
|
||||
}
|
||||
|
||||
export interface ITaskBreakdownMember {
|
||||
team_member_id: string;
|
||||
name: string;
|
||||
avatar_url: string;
|
||||
hourly_rate: number;
|
||||
estimated_hours: number;
|
||||
logged_hours: number;
|
||||
estimated_cost: number;
|
||||
actual_cost: number;
|
||||
}
|
||||
|
||||
export interface ITaskBreakdownJobRole {
|
||||
jobRole: string;
|
||||
estimated_hours: number;
|
||||
logged_hours: number;
|
||||
estimated_cost: number;
|
||||
actual_cost: number;
|
||||
members: ITaskBreakdownMember[];
|
||||
}
|
||||
|
||||
export interface ITaskBreakdownTask {
|
||||
id: string;
|
||||
name: string;
|
||||
project_id: string;
|
||||
billable: boolean;
|
||||
estimated_hours: number;
|
||||
logged_hours: number;
|
||||
estimated_labor_cost: number;
|
||||
actual_labor_cost: number;
|
||||
fixed_cost: number;
|
||||
total_estimated_cost: number;
|
||||
total_actual_cost: number;
|
||||
}
|
||||
|
||||
export interface ITaskBreakdownResponse {
|
||||
task: ITaskBreakdownTask;
|
||||
grouped_members: ITaskBreakdownJobRole[];
|
||||
members: Array<ITaskBreakdownMember & { job_title_name: string }>;
|
||||
}
|
||||
|
||||
export type ProjectFinanceGroupType = 'status' | 'priority' | 'phases';
|
||||
@@ -65,4 +65,7 @@ export interface IProjectViewModel extends IProject {
|
||||
use_manual_progress?: boolean;
|
||||
use_weighted_progress?: boolean;
|
||||
use_time_progress?: boolean;
|
||||
currency?: string;
|
||||
budget?: number;
|
||||
calculation_method?: 'hourly' | 'man_days';
|
||||
}
|
||||
|
||||
32
worklenz-frontend/src/types/project/ratecard.types.ts
Normal file
32
worklenz-frontend/src/types/project/ratecard.types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface IJobType {
|
||||
id?: string;
|
||||
jobId?: string;
|
||||
jobtitle?: string;
|
||||
ratePerHour?: number;
|
||||
rate_card_id?: string;
|
||||
job_title_id?: string;
|
||||
rate?: number;
|
||||
man_day_rate?: number;
|
||||
name?: string;
|
||||
}
|
||||
export interface JobRoleType extends IJobType {
|
||||
members?: string[] | null;
|
||||
}
|
||||
|
||||
export interface RatecardType {
|
||||
id?: string;
|
||||
created_at?: string;
|
||||
name?: string;
|
||||
jobRolesList?: IJobType[];
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export interface IRatecardViewModel {
|
||||
total?: number;
|
||||
data?: RatecardType[];
|
||||
}
|
||||
|
||||
export interface IProjectRateCardRole {
|
||||
project_id: string;
|
||||
roles: IJobType[];
|
||||
}
|
||||
81
worklenz-frontend/src/utils/finance-permissions.ts
Normal file
81
worklenz-frontend/src/utils/finance-permissions.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ILocalSession } from '@/types/auth/local-session.types';
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
|
||||
/**
|
||||
* Checks if the current user has permission to edit finance data
|
||||
* Only users with project admin, admin or owner roles should be able to:
|
||||
* - Change fixed cost values
|
||||
* - Add members to rate cards
|
||||
* - Change rate per hour values
|
||||
*/
|
||||
export const hasFinanceEditPermission = (
|
||||
currentSession: ILocalSession | null,
|
||||
currentProject?: IProjectViewModel | null
|
||||
): boolean => {
|
||||
if (!currentSession) return false;
|
||||
|
||||
// Team owner or admin always have permission
|
||||
if (currentSession.owner || currentSession.is_admin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Project manager has permission
|
||||
if (currentProject?.project_manager?.id === currentSession.team_member_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the current user has permission to view finance data
|
||||
* Only project managers, admins, and owners should be able to view the finance tab
|
||||
*/
|
||||
export const hasFinanceViewPermission = (
|
||||
currentSession: ILocalSession | null,
|
||||
currentProject?: IProjectViewModel | null
|
||||
): boolean => {
|
||||
if (!currentSession) return false;
|
||||
|
||||
// Team owner or admin always have permission
|
||||
if (currentSession.owner || currentSession.is_admin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Project manager has permission
|
||||
if (currentProject?.project_manager?.id === currentSession.team_member_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the current user can edit fixed costs
|
||||
*/
|
||||
export const canEditFixedCost = (
|
||||
currentSession: ILocalSession | null,
|
||||
currentProject?: IProjectViewModel | null
|
||||
): boolean => {
|
||||
return hasFinanceEditPermission(currentSession, currentProject);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the current user can edit rate card data
|
||||
*/
|
||||
export const canEditRateCard = (
|
||||
currentSession: ILocalSession | null,
|
||||
currentProject?: IProjectViewModel | null
|
||||
): boolean => {
|
||||
return hasFinanceEditPermission(currentSession, currentProject);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the current user can add members to rate cards
|
||||
*/
|
||||
export const canAddMembersToRateCard = (
|
||||
currentSession: ILocalSession | null,
|
||||
currentProject?: IProjectViewModel | null
|
||||
): boolean => {
|
||||
return hasFinanceEditPermission(currentSession, currentProject);
|
||||
};
|
||||
178
worklenz-frontend/src/utils/man-days-utils.ts
Normal file
178
worklenz-frontend/src/utils/man-days-utils.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Utility functions for converting between hours and man days
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert hours to man days
|
||||
* @param hours - Number of hours
|
||||
* @param hoursPerDay - Working hours per day (default: 8)
|
||||
* @returns Number of man days
|
||||
*/
|
||||
export const hoursToManDays = (hours: number, hoursPerDay: number = 8): number => {
|
||||
if (hours <= 0 || hoursPerDay <= 0) return 0;
|
||||
return Number((hours / hoursPerDay).toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert man days to hours
|
||||
* @param manDays - Number of man days
|
||||
* @param hoursPerDay - Working hours per day (default: 8)
|
||||
* @returns Number of hours
|
||||
*/
|
||||
export const manDaysToHours = (manDays: number, hoursPerDay: number = 8): number => {
|
||||
if (manDays <= 0 || hoursPerDay <= 0) return 0;
|
||||
return Number((manDays * hoursPerDay).toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert seconds to man days
|
||||
* @param seconds - Number of seconds
|
||||
* @param hoursPerDay - Working hours per day (default: 8)
|
||||
* @returns Number of man days
|
||||
*/
|
||||
export const secondsToManDays = (seconds: number, hoursPerDay: number = 8): number => {
|
||||
if (seconds <= 0 || hoursPerDay <= 0) return 0;
|
||||
const hours = seconds / 3600;
|
||||
return hoursToManDays(hours, hoursPerDay);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert man days to seconds
|
||||
* @param manDays - Number of man days
|
||||
* @param hoursPerDay - Working hours per day (default: 8)
|
||||
* @returns Number of seconds
|
||||
*/
|
||||
export const manDaysToSeconds = (manDays: number, hoursPerDay: number = 8): number => {
|
||||
if (manDays <= 0 || hoursPerDay <= 0) return 0;
|
||||
const hours = manDaysToHours(manDays, hoursPerDay);
|
||||
return hours * 3600;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format man days for display
|
||||
* @param manDays - Number of man days
|
||||
* @param precision - Number of decimal places (default: 1)
|
||||
* @param hoursPerDay - Working hours per day (default: 8)
|
||||
* @returns Formatted string (e.g., '2d 3h 30m')
|
||||
*/
|
||||
export const formatManDays = (
|
||||
manDays: number,
|
||||
precision: number = 1,
|
||||
hoursPerDay: number = 8
|
||||
): string => {
|
||||
if (manDays <= 0) return '0d';
|
||||
|
||||
const days = Math.floor(manDays);
|
||||
const remainder = manDays - days;
|
||||
const totalHours = remainder * hoursPerDay;
|
||||
const hours = Math.floor(totalHours);
|
||||
const minutes = Math.round((totalHours - hours) * 60);
|
||||
|
||||
let result = '';
|
||||
if (days > 0) result += `${days}d`;
|
||||
if (hours > 0) result += (result ? ' ' : '') + `${hours}h`;
|
||||
if (minutes > 0) result += (result ? ' ' : '') + `${minutes}m`;
|
||||
if (!result) result = `${manDays.toFixed(precision)}d`;
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse man days from string input
|
||||
* @param input - String input (e.g., "2.5", "2.5d", "2.5 days")
|
||||
* @returns Number of man days or null if invalid
|
||||
*/
|
||||
export const parseManDays = (input: string): number | null => {
|
||||
if (!input || typeof input !== 'string') return null;
|
||||
|
||||
// Remove common suffixes and trim
|
||||
const cleaned = input
|
||||
.toLowerCase()
|
||||
.replace(/\s*(days?|d)\s*$/g, '')
|
||||
.trim();
|
||||
|
||||
const parsed = parseFloat(cleaned);
|
||||
|
||||
if (isNaN(parsed) || parsed < 0) return null;
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate cost based on man days and rate
|
||||
* @param manDays - Number of man days
|
||||
* @param manDayRate - Rate per man day
|
||||
* @returns Total cost
|
||||
*/
|
||||
export const calculateManDaysCost = (manDays: number, manDayRate: number): number => {
|
||||
if (manDays <= 0 || manDayRate <= 0) return 0;
|
||||
return Number((manDays * manDayRate).toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert hourly rate to man day rate
|
||||
* @param hourlyRate - Rate per hour
|
||||
* @param hoursPerDay - Working hours per day (default: 8)
|
||||
* @returns Rate per man day
|
||||
*/
|
||||
export const hourlyRateToManDayRate = (hourlyRate: number, hoursPerDay: number = 8): number => {
|
||||
if (hourlyRate <= 0 || hoursPerDay <= 0) return 0;
|
||||
return Number((hourlyRate * hoursPerDay).toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert man day rate to hourly rate
|
||||
* @param manDayRate - Rate per man day
|
||||
* @param hoursPerDay - Working hours per day (default: 8)
|
||||
* @returns Rate per hour
|
||||
*/
|
||||
export const manDayRateToHourlyRate = (manDayRate: number, hoursPerDay: number = 8): number => {
|
||||
if (manDayRate <= 0 || hoursPerDay <= 0) return 0;
|
||||
return Number((manDayRate / hoursPerDay).toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate effort variance in man days
|
||||
* @param actualManDays - Actual man days spent
|
||||
* @param estimatedManDays - Estimated man days
|
||||
* @returns Effort variance in man days (positive = over estimate, negative = under estimate)
|
||||
*/
|
||||
export const calculateEffortVariance = (
|
||||
actualManDays: number,
|
||||
estimatedManDays: number
|
||||
): number => {
|
||||
if (actualManDays < 0 || estimatedManDays < 0) return 0;
|
||||
return Number((actualManDays - estimatedManDays).toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Format effort variance for display
|
||||
* @param varianceManDays - Variance in man days
|
||||
* @param precision - Number of decimal places (default: 1)
|
||||
* @returns Formatted string with sign and label
|
||||
*/
|
||||
export const formatEffortVariance = (varianceManDays: number, precision: number = 1): string => {
|
||||
if (varianceManDays === 0) return 'On track';
|
||||
|
||||
const absVariance = Math.abs(varianceManDays);
|
||||
const rounded = Number(absVariance.toFixed(precision));
|
||||
const sign = varianceManDays > 0 ? '+' : '-';
|
||||
|
||||
if (rounded === 1) {
|
||||
return `${sign}1 day`;
|
||||
} else if (rounded < 1) {
|
||||
return `${sign}${rounded}d`;
|
||||
} else {
|
||||
return `${sign}${rounded} days`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get variance status color based on effort variance
|
||||
* @param varianceManDays - Variance in man days
|
||||
* @returns Color code for UI display
|
||||
*/
|
||||
export const getVarianceColor = (varianceManDays: number): string => {
|
||||
if (varianceManDays === 0) return '#52c41a'; // Green - on track
|
||||
if (varianceManDays > 0) return '#ff4d4f'; // Red - over estimate
|
||||
return '#1890ff'; // Blue - under estimate
|
||||
};
|
||||
Reference in New Issue
Block a user