Merge branch 'Worklenz:feature/project-finance' into feature/project-finance
This commit is contained in:
@@ -11,13 +11,16 @@
|
||||
|
||||
"taskColumn": "Task",
|
||||
"membersColumn": "Members",
|
||||
"hoursColumn": "Hours",
|
||||
"hoursColumn": "Estimated Hours",
|
||||
"totalTimeLoggedColumn": "Total Time Logged",
|
||||
"costColumn": "Cost",
|
||||
"estimatedCostColumn": "Estimated Cost",
|
||||
"fixedCostColumn": "Fixed Cost",
|
||||
"totalBudgetedCostColumn": "Total Budgeted Cost",
|
||||
"totalActualCostColumn": "Total Actual Cost",
|
||||
"varianceColumn": "Variance",
|
||||
"totalText": "Total",
|
||||
"noTasksFound": "No tasks found",
|
||||
|
||||
"addRoleButton": "+ Add Role",
|
||||
"ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.",
|
||||
|
||||
@@ -11,13 +11,16 @@
|
||||
|
||||
"taskColumn": "Tarea",
|
||||
"membersColumn": "Miembros",
|
||||
"hoursColumn": "Horas",
|
||||
"hoursColumn": "Horas Estimadas",
|
||||
"totalTimeLoggedColumn": "Tiempo Total Registrado",
|
||||
"costColumn": "Costo",
|
||||
"estimatedCostColumn": "Costo Estimado",
|
||||
"fixedCostColumn": "Costo Fijo",
|
||||
"totalBudgetedCostColumn": "Costo Total Presupuestado",
|
||||
"totalActualCostColumn": "Costo Total Real",
|
||||
"varianceColumn": "Diferencia",
|
||||
"totalText": "Total",
|
||||
"noTasksFound": "No se encontraron tareas",
|
||||
|
||||
"addRoleButton": "+ Agregar Rol",
|
||||
"ratecardImportantNotice": "* Esta tarifa se genera en función de los títulos de trabajo y tarifas estándar de la empresa. Sin embargo, tienes la flexibilidad de modificarla según el proyecto. Estos cambios no afectarán los títulos de trabajo y tarifas estándar de la organización.",
|
||||
|
||||
@@ -11,13 +11,16 @@
|
||||
|
||||
"taskColumn": "Tarefa",
|
||||
"membersColumn": "Membros",
|
||||
"hoursColumn": "Horas",
|
||||
"hoursColumn": "Horas Estimadas",
|
||||
"totalTimeLoggedColumn": "Tempo Total Registrado",
|
||||
"costColumn": "Custo",
|
||||
"estimatedCostColumn": "Custo Estimado",
|
||||
"fixedCostColumn": "Custo Fixo",
|
||||
"totalBudgetedCostColumn": "Custo Total Orçado",
|
||||
"totalActualCostColumn": "Custo Total Real",
|
||||
"varianceColumn": "Variação",
|
||||
"totalText": "Total",
|
||||
"noTasksFound": "Nenhuma tarefa encontrada",
|
||||
|
||||
"addRoleButton": "+ Adicionar Função",
|
||||
"ratecardImportantNotice": "* Esta tabela de taxas é gerada com base nos cargos e taxas padrão da empresa. No entanto, você tem a flexibilidade de modificá-la de acordo com o projeto. Essas alterações não impactarão os cargos e taxas padrão da organização.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { API_BASE_URL } from "@/shared/constants";
|
||||
import { IServerResponse } from "@/types/common.types";
|
||||
import apiClient from "../api-client";
|
||||
import { IProjectFinanceGroup } from "@/types/project/project-finance.types";
|
||||
import { IProjectFinanceResponse, ITaskBreakdownResponse } from "@/types/project/project-finance.types";
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/project-finance`;
|
||||
|
||||
@@ -9,13 +9,34 @@ export const projectFinanceApiService = {
|
||||
getProjectTasks: async (
|
||||
projectId: string,
|
||||
groupBy: 'status' | 'priority' | 'phases' = 'status'
|
||||
): Promise<IServerResponse<IProjectFinanceGroup[]>> => {
|
||||
const response = await apiClient.get<IServerResponse<IProjectFinanceGroup[]>>(
|
||||
): Promise<IServerResponse<IProjectFinanceResponse>> => {
|
||||
const response = await apiClient.get<IServerResponse<IProjectFinanceResponse>>(
|
||||
`${rootUrl}/project/${projectId}/tasks`,
|
||||
{
|
||||
params: { group_by: groupBy }
|
||||
}
|
||||
);
|
||||
console.log(response.data);
|
||||
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;
|
||||
},
|
||||
}
|
||||
@@ -73,6 +73,7 @@ import financeReducer from '../features/finance/finance-slice';
|
||||
import roadmapReducer from '../features/roadmap/roadmap-slice';
|
||||
import teamMembersReducer from '@features/team-members/team-members.slice';
|
||||
import projectFinanceRateCardReducer from '../features/finance/project-finance-slice';
|
||||
import projectFinancesReducer from '../features/projects/finance/project-finance.slice';
|
||||
import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
|
||||
import homePageApiService from '@/api/home-page/home-page.api.service';
|
||||
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||
@@ -158,6 +159,7 @@ export const store = configureStore({
|
||||
timeReportsOverviewReducer: timeReportsOverviewReducer,
|
||||
financeReducer: financeReducer,
|
||||
projectFinanceRateCard: projectFinanceRateCardReducer,
|
||||
projectFinances: projectFinancesReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,19 +4,20 @@ import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
interface AvatarsProps {
|
||||
members: InlineMember[];
|
||||
maxCount?: number;
|
||||
allowClickThrough?: boolean;
|
||||
}
|
||||
|
||||
const renderAvatar = (member: InlineMember, index: number) => (
|
||||
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={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<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={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar
|
||||
size={28}
|
||||
key={member.team_member_id || index}
|
||||
@@ -32,12 +33,12 @@ const renderAvatar = (member: InlineMember, index: number) => (
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const Avatars: React.FC<AvatarsProps> = ({ members, maxCount }) => {
|
||||
const Avatars: React.FC<AvatarsProps> = ({ members, maxCount, allowClickThrough = false }) => {
|
||||
const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
|
||||
return (
|
||||
<div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<div onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar.Group>
|
||||
{visibleMembers.map((member, index) => renderAvatar(member, index))}
|
||||
{visibleMembers.map((member, index) => renderAvatar(member, index, allowClickThrough))}
|
||||
</Avatar.Group>
|
||||
</div>
|
||||
);
|
||||
|
||||
61
worklenz-frontend/src/components/conditional-alert.tsx
Normal file
61
worklenz-frontend/src/components/conditional-alert.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Alert } from 'antd';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface ConditionalAlertProps {
|
||||
message?: string;
|
||||
type?: 'success' | 'info' | 'warning' | 'error';
|
||||
showInitially?: boolean;
|
||||
onClose?: () => void;
|
||||
condition?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ConditionalAlert = ({
|
||||
message = '',
|
||||
type = 'info',
|
||||
showInitially = false,
|
||||
onClose,
|
||||
condition,
|
||||
className = ''
|
||||
}: ConditionalAlertProps) => {
|
||||
const [visible, setVisible] = useState(showInitially);
|
||||
|
||||
useEffect(() => {
|
||||
if (condition !== undefined) {
|
||||
setVisible(condition);
|
||||
}
|
||||
}, [condition]);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const alertStyles = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
} as const;
|
||||
|
||||
if (!visible || !message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
message={message}
|
||||
type={type}
|
||||
closable
|
||||
onClose={handleClose}
|
||||
style={alertStyles}
|
||||
showIcon
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConditionalAlert;
|
||||
184
worklenz-frontend/src/components/license-alert.tsx
Normal file
184
worklenz-frontend/src/components/license-alert.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Alert, Button, Space } from 'antd';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CrownOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import { ILocalSession } from '@/types/auth/local-session.types';
|
||||
import { LICENSE_ALERT_KEY } from '@/shared/constants';
|
||||
import { format, isSameDay, differenceInDays, addDays, isAfter } from 'date-fns';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface LicenseAlertProps {
|
||||
currentSession: ILocalSession;
|
||||
onVisibilityChange?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
interface AlertConfig {
|
||||
type: 'success' | 'info' | 'warning' | 'error';
|
||||
message: React.ReactNode;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
licenseType: 'trial' | 'expired' | 'expiring';
|
||||
daysRemaining: number;
|
||||
}
|
||||
|
||||
const LicenseAlert = ({ currentSession, onVisibilityChange }: LicenseAlertProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [alertConfig, setAlertConfig] = useState<AlertConfig | null>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setLastAlertDate(new Date());
|
||||
};
|
||||
|
||||
const getLastAlertDate = () => {
|
||||
const lastAlertDate = localStorage.getItem(LICENSE_ALERT_KEY);
|
||||
return lastAlertDate ? new Date(lastAlertDate) : null;
|
||||
};
|
||||
|
||||
const setLastAlertDate = (date: Date) => {
|
||||
localStorage.setItem(LICENSE_ALERT_KEY, format(date, 'yyyy-MM-dd'));
|
||||
};
|
||||
|
||||
const handleUpgrade = () => {
|
||||
navigate('/worklenz/admin-center/billing');
|
||||
};
|
||||
|
||||
const handleExtend = () => {
|
||||
navigate('/worklenz/admin-center/billing');
|
||||
};
|
||||
|
||||
const getVisibleAndConfig = (): { visible: boolean; config: AlertConfig | null } => {
|
||||
const lastAlertDate = getLastAlertDate();
|
||||
|
||||
// Check if alert was already shown today
|
||||
if (lastAlertDate && isSameDay(lastAlertDate, new Date())) {
|
||||
return { visible: false, config: null };
|
||||
}
|
||||
|
||||
if (!currentSession.valid_till_date) {
|
||||
return { visible: false, config: null };
|
||||
}
|
||||
|
||||
let validTillDate = new Date(currentSession.valid_till_date);
|
||||
const today = new Date();
|
||||
|
||||
// If validTillDate is after today, add 1 day (matching Angular logic)
|
||||
if (isAfter(validTillDate, today)) {
|
||||
validTillDate = addDays(validTillDate, 1);
|
||||
}
|
||||
|
||||
// Calculate the difference in days between the two dates
|
||||
const daysDifference = differenceInDays(validTillDate, today);
|
||||
|
||||
// Don't show if no valid_till_date or difference is >= 7 days
|
||||
if (daysDifference >= 7) {
|
||||
return { visible: false, config: null };
|
||||
}
|
||||
|
||||
const absDaysDifference = Math.abs(daysDifference);
|
||||
const dayText = `${absDaysDifference} day${absDaysDifference === 1 ? '' : 's'}`;
|
||||
|
||||
let string1 = '';
|
||||
let string2 = dayText;
|
||||
let licenseType: 'trial' | 'expired' | 'expiring' = 'expiring';
|
||||
let alertType: 'success' | 'info' | 'warning' | 'error' = 'warning';
|
||||
|
||||
if (currentSession.subscription_status === 'trialing') {
|
||||
licenseType = 'trial';
|
||||
if (daysDifference < 0) {
|
||||
string1 = 'Your Worklenz trial expired';
|
||||
string2 = string2 + ' ago';
|
||||
alertType = 'error';
|
||||
licenseType = 'expired';
|
||||
} else if (daysDifference !== 0 && daysDifference < 7) {
|
||||
string1 = 'Your Worklenz trial expires in';
|
||||
} else if (daysDifference === 0 && daysDifference < 7) {
|
||||
string1 = 'Your Worklenz trial expires';
|
||||
string2 = 'today';
|
||||
}
|
||||
} else if (currentSession.subscription_status === 'active') {
|
||||
if (daysDifference < 0) {
|
||||
string1 = 'Your Worklenz subscription expired';
|
||||
string2 = string2 + ' ago';
|
||||
alertType = 'error';
|
||||
licenseType = 'expired';
|
||||
} else if (daysDifference !== 0 && daysDifference < 7) {
|
||||
string1 = 'Your Worklenz subscription expires in';
|
||||
} else if (daysDifference === 0 && daysDifference < 7) {
|
||||
string1 = 'Your Worklenz subscription expires';
|
||||
string2 = 'today';
|
||||
}
|
||||
} else {
|
||||
return { visible: false, config: null };
|
||||
}
|
||||
|
||||
const config: AlertConfig = {
|
||||
type: alertType,
|
||||
message: (
|
||||
<>
|
||||
Action required! {string1} <strong>{string2}</strong>
|
||||
</>
|
||||
),
|
||||
description: '',
|
||||
icon: licenseType === 'expired' || licenseType === 'trial' ? <CrownOutlined /> : <ClockCircleOutlined />,
|
||||
licenseType,
|
||||
daysRemaining: absDaysDifference
|
||||
};
|
||||
|
||||
return { visible: true, config };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const { visible: shouldShow, config } = getVisibleAndConfig();
|
||||
setVisible(shouldShow);
|
||||
setAlertConfig(config);
|
||||
|
||||
// Notify parent about visibility change
|
||||
if (onVisibilityChange) {
|
||||
onVisibilityChange(shouldShow);
|
||||
}
|
||||
}, [currentSession, onVisibilityChange]);
|
||||
|
||||
const alertStyles = {
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
} as const;
|
||||
|
||||
const actionButtons = alertConfig && (
|
||||
<Space>
|
||||
{/* Show button only if user is owner or admin */}
|
||||
{(currentSession.owner || currentSession.is_admin) && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={currentSession.subscription_status === 'trialing' ? handleUpgrade : handleExtend}
|
||||
>
|
||||
{currentSession.subscription_status === 'trialing' ? 'Upgrade now' : 'Go to Billing'}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
if (!visible || !alertConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-license-alert>
|
||||
<Alert
|
||||
message={alertConfig.message}
|
||||
type={alertConfig.type}
|
||||
closable
|
||||
onClose={handleClose}
|
||||
style={{
|
||||
...alertStyles,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
showIcon
|
||||
action={actionButtons}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LicenseAlert;
|
||||
@@ -1,17 +1,40 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Drawer, Typography } from 'antd';
|
||||
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 { toggleFinanceDrawer } from '../finance-slice';
|
||||
import { closeFinanceDrawer } from '../finance-slice';
|
||||
import { projectFinanceApiService } from '../../../api/project-finance-ratecard/project-finance.api.service';
|
||||
import { ITaskBreakdownResponse } from '../../../types/project/project-finance.types';
|
||||
|
||||
const FinanceDrawer = ({ task }: { task: any }) => {
|
||||
const [selectedTask, setSelectedTask] = useState(task);
|
||||
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(() => {
|
||||
setSelectedTask(task);
|
||||
}, [task]);
|
||||
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');
|
||||
@@ -19,9 +42,6 @@ const FinanceDrawer = ({ task }: { task: any }) => {
|
||||
// get theme data from theme reducer
|
||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||
|
||||
const isDrawerOpen = useAppSelector(
|
||||
(state) => state.financeReducer.isFinanceDrawerOpen
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
const currency = useAppSelector(
|
||||
(state) => state.financeReducer.currency
|
||||
@@ -29,41 +49,15 @@ const FinanceDrawer = ({ task }: { task: any }) => {
|
||||
|
||||
// function handle drawer close
|
||||
const handleClose = () => {
|
||||
setSelectedTask(null);
|
||||
dispatch(toggleFinanceDrawer());
|
||||
setTaskBreakdown(null);
|
||||
dispatch(closeFinanceDrawer());
|
||||
};
|
||||
|
||||
// group members by job roles and calculate labor hours and costs
|
||||
const groupedMembers =
|
||||
selectedTask?.members?.reduce((acc: any, member: any) => {
|
||||
const memberHours = selectedTask.hours / selectedTask.members.length;
|
||||
const memberCost = memberHours * member.hourlyRate;
|
||||
|
||||
if (!acc[member.jobRole]) {
|
||||
acc[member.jobRole] = {
|
||||
jobRole: member.jobRole,
|
||||
laborHours: 0,
|
||||
cost: 0,
|
||||
members: [],
|
||||
};
|
||||
}
|
||||
|
||||
acc[member.jobRole].laborHours += memberHours;
|
||||
acc[member.jobRole].cost += memberCost;
|
||||
acc[member.jobRole].members.push({
|
||||
name: member.name,
|
||||
laborHours: memberHours,
|
||||
cost: memberCost,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {}) || {};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||
{selectedTask?.task || t('noTaskSelected')}
|
||||
{taskBreakdown?.task?.name || selectedTask?.name || t('noTaskSelected')}
|
||||
</Typography.Text>
|
||||
}
|
||||
open={isDrawerOpen}
|
||||
@@ -72,98 +66,77 @@ const FinanceDrawer = ({ task }: { task: any }) => {
|
||||
width={480}
|
||||
>
|
||||
<div>
|
||||
<table
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
height: 48,
|
||||
backgroundColor: themeWiseColor(
|
||||
'#F5F5F5',
|
||||
'#1d1d1d',
|
||||
themeMode
|
||||
),
|
||||
}}
|
||||
>
|
||||
<th
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<table
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: 8,
|
||||
}}
|
||||
></th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
height: 48,
|
||||
backgroundColor: themeWiseColor(
|
||||
'#F5F5F5',
|
||||
'#1d1d1d',
|
||||
themeMode
|
||||
),
|
||||
}}
|
||||
>
|
||||
{t('labourHoursColumn')}
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{t('costColumn')} ({currency})
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<div className="mb-4"></div>
|
||||
|
||||
<tbody>
|
||||
{Object.values(groupedMembers).map((group: any) => (
|
||||
<React.Fragment key={group.jobRole}>
|
||||
{/* Group Header */}
|
||||
<tr
|
||||
<th
|
||||
style={{
|
||||
backgroundColor: themeWiseColor(
|
||||
'#D9D9D9',
|
||||
'#000',
|
||||
themeMode
|
||||
),
|
||||
height: 56,
|
||||
textAlign: 'left',
|
||||
padding: 8,
|
||||
}}
|
||||
></th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
}}
|
||||
className="border-b-[1px] font-semibold"
|
||||
>
|
||||
<td style={{ padding: 8 }}>{group.jobRole}</td>
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{group.laborHours}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{group.cost}
|
||||
</td>
|
||||
</tr>
|
||||
{/* Member Rows */}
|
||||
{group.members.map((member: any, index: number) => (
|
||||
{t('labourHoursColumn')}
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{t('costColumn')} ({currency})
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{taskBreakdown?.grouped_members?.map((group: any) => (
|
||||
<React.Fragment key={group.jobRole}>
|
||||
{/* Group Header */}
|
||||
<tr
|
||||
key={`${group.jobRole}-${index}`}
|
||||
className="border-b-[1px]"
|
||||
style={{ height: 56 }}
|
||||
style={{
|
||||
backgroundColor: themeWiseColor(
|
||||
'#D9D9D9',
|
||||
'#000',
|
||||
themeMode
|
||||
),
|
||||
height: 56,
|
||||
}}
|
||||
className="border-b-[1px] font-semibold"
|
||||
>
|
||||
<td style={{ padding: 8 }}>{group.jobRole}</td>
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
paddingLeft: 32,
|
||||
}}
|
||||
>
|
||||
{member.name}
|
||||
{group.estimated_hours?.toFixed(2) || '0.00'}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
@@ -171,22 +144,47 @@ const FinanceDrawer = ({ task }: { task: any }) => {
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{member.laborHours}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{member.cost}
|
||||
{group.estimated_cost?.toFixed(2) || '0.00'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* 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.estimated_hours?.toFixed(2) || '0.00'}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{member.estimated_cost?.toFixed(2) || '0.00'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ type financeState = {
|
||||
isFinanceDrawerloading?: boolean;
|
||||
drawerRatecard?: RatecardType | null;
|
||||
ratecardsList?: RatecardType[] | null;
|
||||
selectedTask?: any | null;
|
||||
};
|
||||
|
||||
const initialState: financeState = {
|
||||
@@ -23,6 +24,7 @@ const initialState: financeState = {
|
||||
isFinanceDrawerloading: false,
|
||||
drawerRatecard: null,
|
||||
ratecardsList: null,
|
||||
selectedTask: null,
|
||||
};
|
||||
interface FetchRateCardsParams {
|
||||
index: number;
|
||||
@@ -128,6 +130,17 @@ const financeSlice = createSlice({
|
||||
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;
|
||||
},
|
||||
@@ -176,6 +189,9 @@ const financeSlice = createSlice({
|
||||
export const {
|
||||
toggleRatecardDrawer,
|
||||
toggleFinanceDrawer,
|
||||
openFinanceDrawer,
|
||||
closeFinanceDrawer,
|
||||
setSelectedTask,
|
||||
toggleImportRatecardsDrawer,
|
||||
changeCurrency,
|
||||
ratecardDrawerLoading,
|
||||
|
||||
@@ -90,6 +90,7 @@ const Navbar = () => {
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
|
||||
<Col
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -101,14 +102,14 @@ const Navbar = () => {
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && (
|
||||
{/* {daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && (
|
||||
<Alert
|
||||
message={daysUntilExpiry > 0 ? `Your license will expire in ${daysUntilExpiry} days` : `Your license has expired ${Math.abs(daysUntilExpiry)} days ago`}
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ width: '100%', marginTop: 12 }}
|
||||
/>
|
||||
)}
|
||||
)} */}
|
||||
<Flex
|
||||
style={{
|
||||
width: '100%',
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { IProjectFinanceGroup, IProjectFinanceTask, IProjectRateCard } from '@/types/project/project-finance.types';
|
||||
|
||||
type FinanceTabType = 'finance' | 'ratecard';
|
||||
type GroupTypes = 'status' | 'priority' | 'phases';
|
||||
|
||||
interface ProjectFinanceState {
|
||||
activeTab: FinanceTabType;
|
||||
activeGroup: GroupTypes;
|
||||
loading: boolean;
|
||||
taskGroups: IProjectFinanceGroup[];
|
||||
projectRateCards: IProjectRateCard[];
|
||||
}
|
||||
|
||||
// Utility functions for frontend calculations
|
||||
const minutesToHours = (minutes: number) => minutes / 60;
|
||||
|
||||
const calculateTaskCosts = (task: IProjectFinanceTask) => {
|
||||
const hours = minutesToHours(task.estimated_hours || 0);
|
||||
const timeLoggedHours = minutesToHours(task.total_time_logged || 0);
|
||||
const fixedCost = task.fixed_cost || 0;
|
||||
|
||||
// Calculate total budget (estimated hours * rate + fixed cost)
|
||||
const totalBudget = task.estimated_cost + fixedCost;
|
||||
|
||||
// Calculate total actual (time logged * rate + fixed cost)
|
||||
const totalActual = task.total_actual || 0;
|
||||
|
||||
// Calculate variance (total actual - total budget)
|
||||
const variance = totalActual - totalBudget;
|
||||
|
||||
return {
|
||||
hours,
|
||||
timeLoggedHours,
|
||||
totalBudget,
|
||||
totalActual,
|
||||
variance
|
||||
};
|
||||
};
|
||||
|
||||
const calculateGroupTotals = (tasks: IProjectFinanceTask[]) => {
|
||||
return tasks.reduce(
|
||||
(acc, task) => {
|
||||
const { hours, timeLoggedHours, totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||
return {
|
||||
hours: acc.hours + hours,
|
||||
total_time_logged: acc.total_time_logged + timeLoggedHours,
|
||||
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0),
|
||||
total_budget: acc.total_budget + totalBudget,
|
||||
total_actual: acc.total_actual + totalActual,
|
||||
variance: acc.variance + variance
|
||||
};
|
||||
},
|
||||
{
|
||||
hours: 0,
|
||||
total_time_logged: 0,
|
||||
estimated_cost: 0,
|
||||
total_budget: 0,
|
||||
total_actual: 0,
|
||||
variance: 0
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const initialState: ProjectFinanceState = {
|
||||
activeTab: 'finance',
|
||||
activeGroup: 'status',
|
||||
loading: false,
|
||||
taskGroups: [],
|
||||
projectRateCards: [],
|
||||
};
|
||||
|
||||
export const fetchProjectFinances = createAsyncThunk(
|
||||
'projectFinances/fetchProjectFinances',
|
||||
async ({ projectId, groupBy }: { projectId: string; groupBy: GroupTypes }) => {
|
||||
const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy);
|
||||
return 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 projectFinancesSlice = createSlice({
|
||||
name: 'projectFinances',
|
||||
initialState,
|
||||
reducers: {
|
||||
setActiveTab: (state, action: PayloadAction<FinanceTabType>) => {
|
||||
state.activeTab = action.payload;
|
||||
},
|
||||
setActiveGroup: (state, action: PayloadAction<GroupTypes>) => {
|
||||
state.activeGroup = action.payload;
|
||||
},
|
||||
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 task = group.tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.fixed_cost = fixedCost;
|
||||
// Recalculate task costs after updating fixed cost
|
||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||
task.total_budget = totalBudget;
|
||||
task.total_actual = totalActual;
|
||||
task.variance = variance;
|
||||
}
|
||||
}
|
||||
},
|
||||
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 task = group.tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.estimated_cost = estimatedCost;
|
||||
// Recalculate task costs after updating estimated cost
|
||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||
task.total_budget = totalBudget;
|
||||
task.total_actual = totalActual;
|
||||
task.variance = variance;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateTaskTimeLogged: (state, action: PayloadAction<{ taskId: string; groupId: string; timeLogged: number }>) => {
|
||||
const { taskId, groupId, timeLogged } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
if (group) {
|
||||
const task = group.tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.total_time_logged = timeLogged;
|
||||
// Recalculate task costs after updating time logged
|
||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||
task.total_budget = totalBudget;
|
||||
task.total_actual = totalActual;
|
||||
task.variance = variance;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchProjectFinances.pending, (state) => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(fetchProjectFinances.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.taskGroups = action.payload.groups;
|
||||
state.projectRateCards = action.payload.project_rate_cards;
|
||||
})
|
||||
.addCase(fetchProjectFinances.rejected, (state) => {
|
||||
state.loading = false;
|
||||
})
|
||||
.addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => {
|
||||
const { taskId, groupId, fixedCost } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
if (group) {
|
||||
const task = group.tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.fixed_cost = fixedCost;
|
||||
// Recalculate task costs after updating fixed cost
|
||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||
task.total_budget = totalBudget;
|
||||
task.total_actual = totalActual;
|
||||
task.variance = variance;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setActiveTab,
|
||||
setActiveGroup,
|
||||
updateTaskFixedCost,
|
||||
updateTaskEstimatedCost,
|
||||
updateTaskTimeLogged
|
||||
} = projectFinancesSlice.actions;
|
||||
|
||||
export default projectFinancesSlice.reducer;
|
||||
@@ -5,15 +5,22 @@ import { useAppSelector } from '../hooks/useAppSelector';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import { colors } from '../styles/colors';
|
||||
import { verifyAuthentication } from '@/features/auth/authSlice';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import HubSpot from '@/components/HubSpot';
|
||||
import LicenseAlert from '@/components/license-alert';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { ILocalSession } from '@/types/auth/local-session.types';
|
||||
|
||||
const MainLayout = () => {
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' });
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
// State for alert visibility
|
||||
const [showAlert, setShowAlert] = useState(false);
|
||||
|
||||
const verifyAuthStatus = async () => {
|
||||
const session = await dispatch(verifyAuthentication()).unwrap();
|
||||
@@ -26,6 +33,20 @@ const MainLayout = () => {
|
||||
void verifyAuthStatus();
|
||||
}, [dispatch, navigate]);
|
||||
|
||||
const handleUpgrade = () => {
|
||||
// Handle upgrade logic here
|
||||
console.log('Upgrade clicked');
|
||||
// You can navigate to upgrade page or open a modal
|
||||
};
|
||||
|
||||
const handleExtend = () => {
|
||||
// Handle license extension logic here
|
||||
console.log('Extend license clicked');
|
||||
// You can navigate to renewal page or open a modal
|
||||
};
|
||||
|
||||
const alertHeight = showAlert ? 64 : 0; // Fixed height for license alert
|
||||
|
||||
const headerStyles = {
|
||||
zIndex: 999,
|
||||
position: 'fixed',
|
||||
@@ -34,11 +55,13 @@ const MainLayout = () => {
|
||||
alignItems: 'center',
|
||||
padding: 0,
|
||||
borderBottom: themeMode === 'dark' ? '1px solid #303030' : 'none',
|
||||
top: alertHeight, // Push navbar down when alert is shown
|
||||
} as const;
|
||||
|
||||
const contentStyles = {
|
||||
paddingInline: isDesktop ? 64 : 24,
|
||||
overflowX: 'hidden',
|
||||
marginTop: alertHeight + 64, // Adjust top margin based on alert height + navbar height
|
||||
} as const;
|
||||
|
||||
return (
|
||||
|
||||
61
worklenz-frontend/src/lib/project/finance-table-wrapper.tsx
Normal file
61
worklenz-frontend/src/lib/project/finance-table-wrapper.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Table } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { financeTableColumns } from './project-view-finance-table-columns';
|
||||
|
||||
interface IFinanceTableData {
|
||||
id: string;
|
||||
name: string;
|
||||
estimated_hours: number;
|
||||
estimated_cost: number;
|
||||
fixed_cost: number;
|
||||
total_budgeted_cost: number;
|
||||
total_actual_cost: number;
|
||||
variance: number;
|
||||
total_time_logged: number;
|
||||
assignees: Array<{
|
||||
team_member_id: string;
|
||||
project_member_id: string;
|
||||
name: string;
|
||||
avatar_url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface FinanceTableWrapperProps {
|
||||
data: IFinanceTableData[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ data, loading }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns = financeTableColumns.map(col => ({
|
||||
...col,
|
||||
title: t(`projectViewFinance.${col.name}`),
|
||||
dataIndex: col.key,
|
||||
key: col.key,
|
||||
width: col.width,
|
||||
render: col.render || ((value: any) => {
|
||||
if (col.type === 'hours') {
|
||||
return value ? value.toFixed(2) : '0.00';
|
||||
}
|
||||
if (col.type === 'currency') {
|
||||
return value ? `$${value.toFixed(2)}` : '$0.00';
|
||||
}
|
||||
return value;
|
||||
})
|
||||
}));
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={data}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinanceTableWrapper;
|
||||
@@ -1,57 +1,83 @@
|
||||
export enum FinanceTableColumnKeys {
|
||||
TASK = 'task',
|
||||
MEMBERS = 'members',
|
||||
HOURS = 'hours',
|
||||
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: string;
|
||||
key: FinanceTableColumnKeys;
|
||||
name: string;
|
||||
width: number;
|
||||
type: 'string' | 'hours' | 'currency';
|
||||
render?: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
// finance table columns
|
||||
export const financeTableColumns: FinanceTableColumnsType[] = [
|
||||
{
|
||||
key: 'task',
|
||||
name: 'task',
|
||||
key: FinanceTableColumnKeys.TASK,
|
||||
name: 'taskColumn',
|
||||
width: 240,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
name: 'members',
|
||||
key: FinanceTableColumnKeys.MEMBERS,
|
||||
name: 'membersColumn',
|
||||
width: 160,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'hours',
|
||||
name: 'hours',
|
||||
width: 80,
|
||||
key: FinanceTableColumnKeys.HOURS,
|
||||
name: 'hoursColumn',
|
||||
width: 100,
|
||||
type: 'hours',
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
name: 'cost',
|
||||
key: FinanceTableColumnKeys.TOTAL_TIME_LOGGED,
|
||||
name: 'totalTimeLoggedColumn',
|
||||
width: 120,
|
||||
type: 'hours',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.ESTIMATED_COST,
|
||||
name: 'estimatedCostColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: 'fixedCost',
|
||||
name: 'fixedCost',
|
||||
key: FinanceTableColumnKeys.COST,
|
||||
name: 'costColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: 'totalBudget',
|
||||
name: 'totalBudgetedCost',
|
||||
key: FinanceTableColumnKeys.FIXED_COST,
|
||||
name: 'fixedCostColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: 'totalActual',
|
||||
name: 'totalActualCost',
|
||||
key: FinanceTableColumnKeys.TOTAL_BUDGET,
|
||||
name: 'totalBudgetedCostColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: 'variance',
|
||||
name: 'variance',
|
||||
key: FinanceTableColumnKeys.TOTAL_ACTUAL,
|
||||
name: 'totalActualCostColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.VARIANCE,
|
||||
name: 'varianceColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import FinanceTableWrapper from './finance-table/finance-table-wrapper';
|
||||
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
|
||||
|
||||
@@ -10,26 +9,28 @@ interface FinanceTabProps {
|
||||
|
||||
const FinanceTab = ({
|
||||
groupType,
|
||||
taskGroups,
|
||||
taskGroups = [],
|
||||
loading
|
||||
}: FinanceTabProps) => {
|
||||
// Transform taskGroups into the format expected by FinanceTableWrapper
|
||||
const activeTablesList = taskGroups.map(group => ({
|
||||
id: group.group_id,
|
||||
name: group.group_name,
|
||||
const activeTablesList = (taskGroups || []).map(group => ({
|
||||
group_id: group.group_id,
|
||||
group_name: group.group_name,
|
||||
color_code: group.color_code,
|
||||
color_code_dark: group.color_code_dark,
|
||||
tasks: group.tasks.map(task => ({
|
||||
taskId: task.id,
|
||||
task: task.name,
|
||||
tasks: (group.tasks || []).map(task => ({
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
hours: task.estimated_hours || 0,
|
||||
cost: 0, // TODO: Calculate based on rate and hours
|
||||
fixedCost: 0, // TODO: Add fixed cost field
|
||||
totalBudget: 0, // TODO: Calculate total budget
|
||||
totalActual: task.actual_hours || 0,
|
||||
variance: 0, // TODO: Calculate variance
|
||||
cost: task.estimated_cost || 0,
|
||||
fixedCost: task.fixed_cost || 0,
|
||||
totalBudget: task.total_budget || 0,
|
||||
totalActual: task.total_actual || 0,
|
||||
variance: task.variance || 0,
|
||||
members: task.members || [],
|
||||
isbBillable: task.billable
|
||||
isbBillable: task.billable,
|
||||
total_time_logged: task.total_time_logged || 0,
|
||||
estimated_cost: task.estimated_cost || 0
|
||||
}))
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React from "react";
|
||||
import { Card, Col, Row, Spin } from "antd";
|
||||
import { useThemeContext } from "../../../../../context/theme-context";
|
||||
import { FinanceTable } from "./finance-table";
|
||||
import { IFinanceTable } from "./finance-table.interface";
|
||||
import { Card, Col, Row } from "antd";
|
||||
|
||||
import { IProjectFinanceGroup } from "../../../../../types/project/project-finance.types";
|
||||
import FinanceTable from "./finance-table/finance-table";
|
||||
|
||||
interface Props {
|
||||
activeTablesList: IProjectFinanceGroup[];
|
||||
@@ -32,7 +31,7 @@ export const FinanceTableWrapper: React.FC<Props> = ({ activeTablesList, loading
|
||||
<h3>{table.group_name}</h3>
|
||||
</div>
|
||||
<FinanceTable
|
||||
table={table as unknown as IFinanceTable}
|
||||
table={table}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1,49 +1,34 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Checkbox, Flex, Typography } from 'antd';
|
||||
import { themeWiseColor } from '../../../../../../utils/themeWiseColor';
|
||||
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Flex, InputNumber, Tooltip, Typography, Empty } from 'antd';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '../../../../../../hooks/useAppDispatch';
|
||||
import { toggleFinanceDrawer } from '@/features/finance/finance-slice';
|
||||
import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { openFinanceDrawer } from '@/features/finance/finance-slice';
|
||||
import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns';
|
||||
import FinanceTable from './finance-table';
|
||||
import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer';
|
||||
import { convertToHoursMinutes, formatHoursToReadable } from '@/utils/format-hours-to-readable';
|
||||
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
|
||||
import { updateTaskFixedCostAsync } from '@/features/projects/finance/project-finance.slice';
|
||||
|
||||
interface FinanceTableWrapperProps {
|
||||
activeTablesList: {
|
||||
id: string;
|
||||
name: string;
|
||||
color_code: string;
|
||||
color_code_dark: string;
|
||||
tasks: {
|
||||
taskId: string;
|
||||
task: string;
|
||||
hours: number;
|
||||
cost: number;
|
||||
fixedCost: number;
|
||||
totalBudget: number;
|
||||
totalActual: number;
|
||||
variance: number;
|
||||
members: any[];
|
||||
isbBillable: boolean;
|
||||
}[];
|
||||
}[];
|
||||
activeTablesList: IProjectFinanceGroup[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
|
||||
activeTablesList,
|
||||
loading
|
||||
}) => {
|
||||
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesList, loading }) => {
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
const [editingFixedCost, setEditingFixedCost] = useState<{ taskId: string; groupId: string } | null>(null);
|
||||
|
||||
const { t } = useTranslation('project-view-finance');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Get selected task from Redux store
|
||||
const selectedTask = useAppSelector(state => state.financeReducer.selectedTask);
|
||||
|
||||
const onTaskClick = (task: any) => {
|
||||
setSelectedTask(task);
|
||||
dispatch(toggleFinanceDrawer());
|
||||
dispatch(openFinanceDrawer(task));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -60,74 +45,89 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||
const { currency } = useAppSelector((state) => state.financeReducer);
|
||||
// Handle click outside to close editing
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (editingFixedCost && !(event.target as Element)?.closest('.fixed-cost-input')) {
|
||||
setEditingFixedCost(null);
|
||||
}
|
||||
};
|
||||
|
||||
const totals = activeTablesList.reduce(
|
||||
(
|
||||
acc: {
|
||||
hours: number;
|
||||
cost: number;
|
||||
fixedCost: number;
|
||||
totalBudget: number;
|
||||
totalActual: number;
|
||||
variance: number;
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [editingFixedCost]);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { currency } = useAppSelector(state => state.financeReducer);
|
||||
const taskGroups = useAppSelector(state => state.projectFinances.taskGroups);
|
||||
|
||||
// Use Redux store data for totals calculation to ensure reactivity
|
||||
const totals = useMemo(() => {
|
||||
return taskGroups.reduce(
|
||||
(
|
||||
acc: {
|
||||
hours: number;
|
||||
cost: number;
|
||||
fixedCost: number;
|
||||
totalBudget: number;
|
||||
totalActual: number;
|
||||
variance: number;
|
||||
total_time_logged: number;
|
||||
estimated_cost: number;
|
||||
},
|
||||
table: IProjectFinanceGroup
|
||||
) => {
|
||||
table.tasks.forEach((task) => {
|
||||
acc.hours += (task.estimated_hours / 60) || 0;
|
||||
acc.cost += task.estimated_cost || 0;
|
||||
acc.fixedCost += task.fixed_cost || 0;
|
||||
acc.totalBudget += task.total_budget || 0;
|
||||
acc.totalActual += task.total_actual || 0;
|
||||
acc.variance += task.variance || 0;
|
||||
acc.total_time_logged += (task.total_time_logged / 60) || 0;
|
||||
acc.estimated_cost += task.estimated_cost || 0;
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
table: { tasks: any[] }
|
||||
) => {
|
||||
table.tasks.forEach((task: any) => {
|
||||
acc.hours += task.hours || 0;
|
||||
acc.cost += task.cost || 0;
|
||||
acc.fixedCost += task.fixedCost || 0;
|
||||
acc.totalBudget += task.totalBudget || 0;
|
||||
acc.totalActual += task.totalActual || 0;
|
||||
acc.variance += task.variance || 0;
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
hours: 0,
|
||||
cost: 0,
|
||||
fixedCost: 0,
|
||||
totalBudget: 0,
|
||||
totalActual: 0,
|
||||
variance: 0,
|
||||
}
|
||||
);
|
||||
{
|
||||
hours: 0,
|
||||
cost: 0,
|
||||
fixedCost: 0,
|
||||
totalBudget: 0,
|
||||
totalActual: 0,
|
||||
variance: 0,
|
||||
total_time_logged: 0,
|
||||
estimated_cost: 0,
|
||||
}
|
||||
);
|
||||
}, [taskGroups]);
|
||||
|
||||
const renderFinancialTableHeaderContent = (columnKey: any) => {
|
||||
const handleFixedCostChange = (value: number | null, taskId: string, groupId: string) => {
|
||||
dispatch(updateTaskFixedCostAsync({ taskId, groupId, fixedCost: value || 0 }));
|
||||
setEditingFixedCost(null);
|
||||
};
|
||||
|
||||
const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
|
||||
switch (columnKey) {
|
||||
case 'hours':
|
||||
case FinanceTableColumnKeys.HOURS:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{totals.hours}
|
||||
<Tooltip title={convertToHoursMinutes(totals.hours)}>
|
||||
{formatHoursToReadable(totals.hours).toFixed(2)}
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
);
|
||||
case 'cost':
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{totals.cost}
|
||||
</Typography.Text>
|
||||
);
|
||||
case 'fixedCost':
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{totals.fixedCost}
|
||||
</Typography.Text>
|
||||
);
|
||||
case 'totalBudget':
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{totals.totalBudget}
|
||||
</Typography.Text>
|
||||
);
|
||||
case 'totalActual':
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{totals.totalActual}
|
||||
</Typography.Text>
|
||||
);
|
||||
case 'variance':
|
||||
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={{
|
||||
@@ -135,7 +135,19 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
{totals.variance}
|
||||
{`${totals.variance?.toFixed(2)}`}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{totals.total_time_logged?.toFixed(2)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{`${totals.estimated_cost?.toFixed(2)}`}
|
||||
</Typography.Text>
|
||||
);
|
||||
default:
|
||||
@@ -143,116 +155,103 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const customColumnHeaderStyles = (key: string) =>
|
||||
`px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] 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 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: string) =>
|
||||
`px-2 text-left ${key === 'totalRow' && `sticky left-0 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' : ''}`}`;
|
||||
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"
|
||||
>
|
||||
<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
|
||||
),
|
||||
backgroundColor: themeWiseColor('#fafafa', '#1d1d1d', themeMode),
|
||||
borderBlockEnd: `2px solid rgb(0 0 0 / 0.05)`,
|
||||
}}
|
||||
>
|
||||
<td
|
||||
style={{ width: 32, paddingInline: 16 }}
|
||||
className={customColumnHeaderStyles('selector')}
|
||||
>
|
||||
<Checkbox />
|
||||
</td>
|
||||
{financeTableColumns.map((col) => (
|
||||
{financeTableColumns.map(col => (
|
||||
<td
|
||||
key={col.key}
|
||||
style={{
|
||||
minWidth: col.width,
|
||||
paddingInline: 16,
|
||||
textAlign:
|
||||
col.type === 'hours' || col.type === 'currency'
|
||||
? 'center'
|
||||
: 'start',
|
||||
textAlign: col.type === 'hours' || col.type === 'currency' ? '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'}`}
|
||||
>
|
||||
<Typography.Text>
|
||||
{t(`${col.name}Column`)}{' '}
|
||||
{col.type === 'currency' && `(${currency.toUpperCase()})`}
|
||||
{t(`${col.name}`)} {col.type === 'currency' && `(${currency.toUpperCase()})`}
|
||||
</Typography.Text>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
style={{
|
||||
height: 56,
|
||||
fontWeight: 500,
|
||||
backgroundColor: themeWiseColor(
|
||||
'#fbfbfb',
|
||||
'#141414',
|
||||
themeMode
|
||||
),
|
||||
}}
|
||||
>
|
||||
<td
|
||||
colSpan={3}
|
||||
{hasAnyTasks && (
|
||||
<tr
|
||||
style={{
|
||||
paddingInline: 16,
|
||||
backgroundColor: themeWiseColor(
|
||||
'#fbfbfb',
|
||||
'#141414',
|
||||
themeMode
|
||||
),
|
||||
height: 56,
|
||||
fontWeight: 500,
|
||||
backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode),
|
||||
}}
|
||||
className={customColumnStyles('totalRow')}
|
||||
>
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{t('totalText')}
|
||||
</Typography.Text>
|
||||
</td>
|
||||
{financeTableColumns.map(
|
||||
(col) =>
|
||||
(col.type === 'hours' || col.type === 'currency') && (
|
||||
<td
|
||||
key={col.key}
|
||||
style={{
|
||||
minWidth: col.width,
|
||||
paddingInline: 16,
|
||||
textAlign: 'end',
|
||||
}}
|
||||
>
|
||||
{renderFinancialTableHeaderContent(col.key)}
|
||||
</td>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
{financeTableColumns.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') && renderFinancialTableHeaderContent(col.key)
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{activeTablesList.map((table, index) => (
|
||||
<FinanceTable
|
||||
key={index}
|
||||
table={table}
|
||||
isScrolling={isScrolling}
|
||||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
))}
|
||||
{hasAnyTasks ? (
|
||||
activeTablesList.map((table) => (
|
||||
<FinanceTable
|
||||
key={table.group_id}
|
||||
table={table}
|
||||
isScrolling={isScrolling}
|
||||
onTaskClick={onTaskClick}
|
||||
loading={loading}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={financeTableColumns.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>
|
||||
|
||||
{selectedTask && <FinanceDrawer task={selectedTask} />}
|
||||
<FinanceDrawer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/* Finance Table Styles */
|
||||
@@ -1,283 +1,307 @@
|
||||
import { Avatar, Checkbox, Flex, Input, Tooltip, Typography } from 'antd';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import CustomAvatar from '../../../../../../components/CustomAvatar';
|
||||
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
|
||||
import { Checkbox, Flex, Input, InputNumber, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } 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 } from '@/lib/project/project-view-finance-table-columns';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns';
|
||||
import Avatars from '@/components/avatars/avatars';
|
||||
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
|
||||
import { updateTaskFixedCostAsync, updateTaskFixedCost } from '@/features/projects/finance/project-finance.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import './finance-table.css';
|
||||
|
||||
type FinanceTableProps = {
|
||||
table: any;
|
||||
table: IProjectFinanceGroup;
|
||||
loading: boolean;
|
||||
isScrolling: boolean;
|
||||
onTaskClick: (task: any) => void;
|
||||
};
|
||||
|
||||
const FinanceTable = ({
|
||||
table,
|
||||
loading,
|
||||
isScrolling,
|
||||
onTaskClick,
|
||||
}: FinanceTableProps) => {
|
||||
const [isCollapse, setIsCollapse] = useState<boolean>(false);
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
|
||||
const [tasks, setTasks] = useState<IProjectFinanceTask[]>(table.tasks);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Get the latest task groups from Redux store
|
||||
const taskGroups = useAppSelector((state) => state.projectFinances.taskGroups);
|
||||
|
||||
// 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')) {
|
||||
setSelectedTask(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [selectedTask]);
|
||||
|
||||
// get theme data from theme reducer
|
||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||
|
||||
// totals of the current table
|
||||
const totals = useMemo(
|
||||
() => ({
|
||||
hours: (table?.tasks || []).reduce(
|
||||
(sum: any, task: { hours: any }) => sum + task.hours,
|
||||
0
|
||||
),
|
||||
cost: (table?.tasks || []).reduce(
|
||||
(sum: any, task: { cost: any }) => sum + task.cost,
|
||||
0
|
||||
),
|
||||
fixedCost: (table?.tasks || []).reduce(
|
||||
(sum: any, task: { fixedCost: any }) => sum + task.fixedCost,
|
||||
0
|
||||
),
|
||||
totalBudget: (table?.tasks || []).reduce(
|
||||
(sum: any, task: { totalBudget: any }) => sum + task.totalBudget,
|
||||
0
|
||||
),
|
||||
totalActual: (table?.tasks || []).reduce(
|
||||
(sum: any, task: { totalActual: any }) => sum + task.totalActual,
|
||||
0
|
||||
),
|
||||
variance: (table?.tasks || []).reduce(
|
||||
(sum: any, task: { variance: any }) => sum + task.variance,
|
||||
0
|
||||
),
|
||||
}),
|
||||
[table]
|
||||
);
|
||||
const formatNumber = (value: number | undefined | null) => {
|
||||
if (value === undefined || value === null) return '0.00';
|
||||
return value.toFixed(2);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Selected Task:', selectedTask);
|
||||
}, [selectedTask]);
|
||||
// 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 renderFinancialTableHeaderContent = (columnKey: any) => {
|
||||
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 'hours':
|
||||
return (
|
||||
<Typography.Text style={{ color: colors.darkGray }}>
|
||||
{totals.hours}
|
||||
</Typography.Text>
|
||||
);
|
||||
case 'cost':
|
||||
return (
|
||||
<Typography.Text style={{ color: colors.darkGray }}>
|
||||
{totals.cost}
|
||||
</Typography.Text>
|
||||
);
|
||||
case 'fixedCost':
|
||||
return (
|
||||
<Typography.Text style={{ color: colors.darkGray }}>
|
||||
{totals.fixedCost}
|
||||
</Typography.Text>
|
||||
);
|
||||
case 'totalBudget':
|
||||
return (
|
||||
<Typography.Text style={{ color: colors.darkGray }}>
|
||||
{totals.totalBudget}
|
||||
</Typography.Text>
|
||||
);
|
||||
case 'totalActual':
|
||||
return (
|
||||
<Typography.Text style={{ color: colors.darkGray }}>
|
||||
{totals.totalActual}
|
||||
</Typography.Text>
|
||||
);
|
||||
case 'variance':
|
||||
return (
|
||||
<Typography.Text
|
||||
style={{
|
||||
color:
|
||||
totals.variance < 0
|
||||
? '#FF0000'
|
||||
: themeWiseColor('#6DC376', colors.darkGray, themeMode),
|
||||
}}
|
||||
>
|
||||
{totals.variance}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.HOURS:
|
||||
return <Typography.Text>{formatNumber(totals.hours)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||
return <Typography.Text>{formatNumber(totals.total_time_logged)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||
return <Typography.Text>{formatNumber(totals.estimated_cost)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.FIXED_COST:
|
||||
return <Typography.Text>{formatNumber(totals.fixed_cost)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||
return <Typography.Text>{formatNumber(totals.total_budget)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
||||
return <Typography.Text>{formatNumber(totals.total_actual)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.VARIANCE:
|
||||
return <Typography.Text>{formatNumber(totals.variance)}</Typography.Text>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderFinancialTableColumnContent = (columnKey: any, task: any) => {
|
||||
const handleFixedCostChange = (value: number | null, taskId: string) => {
|
||||
const fixedCost = value || 0;
|
||||
|
||||
// Optimistic update for immediate UI feedback
|
||||
dispatch(updateTaskFixedCost({ taskId, groupId: table.group_id, fixedCost }));
|
||||
|
||||
// Then make the API call to persist the change
|
||||
dispatch(updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost }));
|
||||
};
|
||||
|
||||
const renderFinancialTableColumnContent = (columnKey: FinanceTableColumnKeys, task: IProjectFinanceTask) => {
|
||||
switch (columnKey) {
|
||||
case 'task':
|
||||
case FinanceTableColumnKeys.TASK:
|
||||
return (
|
||||
<Tooltip title={task.task}>
|
||||
<Tooltip title={task.name}>
|
||||
<Flex gap={8} align="center">
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{ maxWidth: 160 }}
|
||||
>
|
||||
{task.task}
|
||||
{task.name}
|
||||
</Typography.Text>
|
||||
|
||||
{task.isbBillable && <DollarCircleOutlined />}
|
||||
{task.billable && <DollarCircleOutlined />}
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'members':
|
||||
return (
|
||||
task?.assignees && <Avatars members={task.assignees} />
|
||||
);
|
||||
case 'hours':
|
||||
return <Typography.Text>{task.hours}</Typography.Text>;
|
||||
case 'cost':
|
||||
return <Typography.Text>{task.cost}</Typography.Text>;
|
||||
case 'fixedCost':
|
||||
return (
|
||||
<Input
|
||||
value={task.fixedCost}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textAlign: 'right',
|
||||
padding: 0,
|
||||
case FinanceTableColumnKeys.MEMBERS:
|
||||
return task.members && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskClick(task);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'totalBudget':
|
||||
return (
|
||||
<Input
|
||||
value={task.totalBudget}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textAlign: 'right',
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'totalActual':
|
||||
return <Typography.Text>{task.totalActual}</Typography.Text>;
|
||||
case 'variance':
|
||||
return (
|
||||
<Typography.Text
|
||||
style={{
|
||||
color: task.variance < 0 ? '#FF0000' : '#6DC376',
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{task.variance}
|
||||
<Avatars
|
||||
members={task.members.map(member => ({
|
||||
...member,
|
||||
avatar_url: member.avatar_url || undefined
|
||||
}))}
|
||||
allowClickThrough={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case FinanceTableColumnKeys.HOURS:
|
||||
return <Typography.Text>{formatNumber(task.estimated_hours / 60)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||
return <Typography.Text>{formatNumber(task.total_time_logged / 60)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||
return <Typography.Text>{formatNumber(task.estimated_cost)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.FIXED_COST:
|
||||
return selectedTask?.id === task.id ? (
|
||||
<InputNumber
|
||||
value={task.fixed_cost}
|
||||
onBlur={(e) => {
|
||||
handleFixedCostChange(Number(e.target.value), task.id);
|
||||
setSelectedTask(null);
|
||||
}}
|
||||
onPressEnter={(e) => {
|
||||
handleFixedCostChange(Number((e.target as HTMLInputElement).value), task.id);
|
||||
setSelectedTask(null);
|
||||
}}
|
||||
autoFocus
|
||||
style={{ width: '100%', textAlign: 'right' }}
|
||||
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: 'pointer', width: '100%', display: 'block' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedTask(task);
|
||||
}}
|
||||
>
|
||||
{formatNumber(task.fixed_cost)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.VARIANCE:
|
||||
return <Typography.Text>{formatNumber(task.variance)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||
return <Typography.Text>{formatNumber(task.total_budget)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
||||
return <Typography.Text>{formatNumber(task.total_actual)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.COST:
|
||||
return <Typography.Text>{formatNumber(task.estimated_cost || 0)}</Typography.Text>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// layout styles for table and the columns
|
||||
const customColumnHeaderStyles = (key: string) =>
|
||||
`px-2 text-left ${key === 'tableTitle' && `sticky left-0 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 customColumnStyles = (key: string) =>
|
||||
`px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[52px] 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]'}`;
|
||||
// Calculate totals for the current table
|
||||
const totals = useMemo(() => {
|
||||
return tasks.reduce(
|
||||
(acc, task) => ({
|
||||
hours: acc.hours + (task.estimated_hours / 60),
|
||||
total_time_logged: acc.total_time_logged + (task.total_time_logged / 60),
|
||||
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0),
|
||||
fixed_cost: acc.fixed_cost + (task.fixed_cost || 0),
|
||||
total_budget: acc.total_budget + (task.total_budget || 0),
|
||||
total_actual: acc.total_actual + (task.total_actual || 0),
|
||||
variance: acc.variance + (task.variance || 0)
|
||||
}),
|
||||
{
|
||||
hours: 0,
|
||||
total_time_logged: 0,
|
||||
estimated_cost: 0,
|
||||
fixed_cost: 0,
|
||||
total_budget: 0,
|
||||
total_actual: 0,
|
||||
variance: 0
|
||||
}
|
||||
);
|
||||
}, [tasks]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* header row */}
|
||||
<tr
|
||||
style={{
|
||||
height: 40,
|
||||
backgroundColor: themeWiseColor(
|
||||
table.color_code,
|
||||
table.color_code_dark,
|
||||
themeMode
|
||||
),
|
||||
fontWeight: 600,
|
||||
}}
|
||||
className="group"
|
||||
>
|
||||
<td
|
||||
colSpan={3}
|
||||
<Skeleton active loading={loading}>
|
||||
<>
|
||||
{/* header row */}
|
||||
<tr
|
||||
style={{
|
||||
width: 48,
|
||||
textTransform: 'capitalize',
|
||||
textAlign: 'left',
|
||||
paddingInline: 16,
|
||||
height: 40,
|
||||
backgroundColor: themeWiseColor(
|
||||
table.color_code,
|
||||
table.color_code_dark,
|
||||
themeMode
|
||||
),
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
className={customColumnHeaderStyles('tableTitle')}
|
||||
onClick={(e) => setIsCollapse((prev) => !prev)}
|
||||
className={`group ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
>
|
||||
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
|
||||
{isCollapse ? <RightOutlined /> : <DownOutlined />}
|
||||
{table.name} ({table.tasks.length})
|
||||
</Flex>
|
||||
</td>
|
||||
|
||||
{financeTableColumns.map(
|
||||
(col) =>
|
||||
col.key !== 'task' &&
|
||||
col.key !== 'members' && (
|
||||
{financeTableColumns.map(
|
||||
(col, index) => (
|
||||
<td
|
||||
key={col.key}
|
||||
key={`header-${col.key}`}
|
||||
style={{
|
||||
width: col.width,
|
||||
paddingInline: 16,
|
||||
textAlign: 'end',
|
||||
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}
|
||||
>
|
||||
{renderFinancialTableHeaderContent(col.key)}
|
||||
{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 */}
|
||||
{table.tasks.map((task: any) => (
|
||||
<tr
|
||||
key={task.taskId}
|
||||
style={{ height: 52 }}
|
||||
className={`${isCollapse ? 'hidden' : 'static'} cursor-pointer border-b-[1px] ${themeMode === 'dark' ? 'hover:bg-[#000000]' : 'hover:bg-[#f8f7f9]'} `}
|
||||
onClick={() => onTaskClick(task)}
|
||||
>
|
||||
<td
|
||||
style={{ paddingInline: 16 }}
|
||||
className={customColumnStyles('selector')}
|
||||
>
|
||||
<Checkbox />
|
||||
</td>
|
||||
{financeTableColumns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={customColumnStyles(col.key)}
|
||||
style={{
|
||||
width: col.width,
|
||||
paddingInline: 16,
|
||||
textAlign:
|
||||
col.type === 'hours' || col.type === 'currency'
|
||||
? 'end'
|
||||
: 'start',
|
||||
}}
|
||||
>
|
||||
{renderFinancialTableColumnContent(col.key, task)}
|
||||
</td>
|
||||
))}
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
|
||||
{/* task rows */}
|
||||
{!isCollapse && tasks.map((task, idx) => (
|
||||
<tr
|
||||
key={task.id}
|
||||
style={{
|
||||
height: 40,
|
||||
background: idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode),
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
className={themeMode === 'dark' ? 'dark' : ''}
|
||||
onMouseEnter={e => e.currentTarget.style.background = themeWiseColor('#f0f0f0', '#333', themeMode)}
|
||||
onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)}
|
||||
>
|
||||
{financeTableColumns.map((col) => (
|
||||
<td
|
||||
key={`${task.id}-${col.key}`}
|
||||
style={{
|
||||
width: col.width,
|
||||
paddingInline: 16,
|
||||
textAlign: col.type === 'string' ? 'left' : 'right',
|
||||
backgroundColor: (col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS) ?
|
||||
(idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)) :
|
||||
'transparent',
|
||||
cursor: 'default'
|
||||
}}
|
||||
className={customColumnStyles(col.key)}
|
||||
onClick={
|
||||
col.key === FinanceTableColumnKeys.FIXED_COST
|
||||
? (e) => e.stopPropagation()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{renderFinancialTableColumnContent(col.key, task)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
</Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Button, ConfigProvider, Flex, Select, Typography } from 'antd';
|
||||
import React from 'react';
|
||||
import GroupByFilterDropdown from './group-by-filter-dropdown';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
import { useAppDispatch } from '../../../../../hooks/useAppDispatch';
|
||||
|
||||
@@ -1,61 +1,34 @@
|
||||
import { Flex } from 'antd';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import ProjectViewFinanceHeader from './project-view-finance-header/project-view-finance-header';
|
||||
import FinanceTab from './finance-tab/finance-tab';
|
||||
import RatecardTab from './ratecard-tab/ratecard-tab';
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
|
||||
|
||||
type FinanceTabType = 'finance' | 'ratecard';
|
||||
type GroupTypes = 'status' | 'priority' | 'phases';
|
||||
|
||||
interface TaskGroup {
|
||||
group_id: string;
|
||||
group_name: string;
|
||||
tasks: any[];
|
||||
}
|
||||
|
||||
interface FinanceTabProps {
|
||||
groupType: GroupTypes;
|
||||
taskGroups: TaskGroup[];
|
||||
loading: boolean;
|
||||
}
|
||||
import { fetchProjectFinances, setActiveTab, setActiveGroup } from '@/features/projects/finance/project-finance.slice';
|
||||
import { RootState } from '@/app/store';
|
||||
|
||||
const ProjectViewFinance = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [activeTab, setActiveTab] = useState<FinanceTabType>('finance');
|
||||
const [activeGroup, setActiveGroup] = useState<GroupTypes>('status');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [taskGroups, setTaskGroups] = useState<IProjectFinanceGroup[]>([]);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await projectFinanceApiService.getProjectTasks(projectId, activeGroup);
|
||||
if (response.done) {
|
||||
setTaskGroups(response.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances);
|
||||
const { refreshTimestamp } = useAppSelector((state: RootState) => state.projectReducer);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, [projectId, activeGroup]);
|
||||
if (projectId) {
|
||||
dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup }));
|
||||
}
|
||||
}, [projectId, activeGroup, dispatch, refreshTimestamp]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||
<ProjectViewFinanceHeader
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
setActiveTab={(tab) => dispatch(setActiveTab(tab))}
|
||||
activeGroup={activeGroup}
|
||||
setActiveGroup={setActiveGroup}
|
||||
setActiveGroup={(group) => dispatch(setActiveGroup(group))}
|
||||
/>
|
||||
|
||||
{activeTab === 'finance' ? (
|
||||
|
||||
@@ -22,8 +22,18 @@ import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp, getProject } from '@features/project/project.slice';
|
||||
import { addTask, fetchTaskGroups, fetchTaskListColumns, IGroupBy } from '@features/tasks/tasks.slice';
|
||||
import {
|
||||
setProject,
|
||||
setImportTaskTemplateDrawerOpen,
|
||||
setRefreshTimestamp,
|
||||
getProject,
|
||||
} from '@features/project/project.slice';
|
||||
import {
|
||||
addTask,
|
||||
fetchTaskGroups,
|
||||
fetchTaskListColumns,
|
||||
IGroupBy,
|
||||
} from '@features/tasks/tasks.slice';
|
||||
import ProjectStatusIcon from '@/components/common/project-status-icon/project-status-icon';
|
||||
import { formatDate } from '@/utils/timeUtils';
|
||||
import { toggleSaveAsTemplateDrawer } from '@/features/projects/projectsSlice';
|
||||
@@ -60,10 +70,7 @@ const ProjectViewHeader = () => {
|
||||
|
||||
const { socket } = useSocket();
|
||||
|
||||
const {
|
||||
project: selectedProject,
|
||||
projectId,
|
||||
} = useAppSelector(state => state.projectReducer);
|
||||
const { project: selectedProject, projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer);
|
||||
|
||||
const [creatingTask, setCreatingTask] = useState(false);
|
||||
@@ -74,7 +81,7 @@ const ProjectViewHeader = () => {
|
||||
switch (tab) {
|
||||
case 'tasks-list':
|
||||
dispatch(fetchTaskListColumns(projectId));
|
||||
dispatch(fetchPhasesByProjectId(projectId))
|
||||
dispatch(fetchPhasesByProjectId(projectId));
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
break;
|
||||
case 'board':
|
||||
@@ -92,6 +99,9 @@ const ProjectViewHeader = () => {
|
||||
case 'updates':
|
||||
dispatch(setRefreshTimestamp());
|
||||
break;
|
||||
case 'finance':
|
||||
dispatch(setRefreshTimestamp());
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -222,7 +232,7 @@ const ProjectViewHeader = () => {
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{(isOwnerOrAdmin) && (
|
||||
{isOwnerOrAdmin && (
|
||||
<Tooltip title="Save as template">
|
||||
<Button
|
||||
shape="circle"
|
||||
@@ -299,10 +309,9 @@ const ProjectViewHeader = () => {
|
||||
style={{ paddingInline: 0, marginBlockEnd: 12 }}
|
||||
extra={renderHeaderActions()}
|
||||
/>
|
||||
{createPortal(<ProjectDrawer onClose={() => { }} />, document.body, 'project-drawer')}
|
||||
{createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
|
||||
{createPortal(<ImportTaskTemplate />, document.body, 'import-task-template')}
|
||||
{createPortal(<SaveProjectAsTemplate />, document.body, 'save-project-as-template')}
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -48,7 +48,16 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600;
|
||||
return loggedTimeInHours.toFixed(2);
|
||||
}) : [];
|
||||
const colors = Array.isArray(jsonData) ? jsonData.map(item => item.color_code) : [];
|
||||
const colors = Array.isArray(jsonData) ? jsonData.map(item => {
|
||||
const overUnder = parseFloat(item.over_under_utilized_hours || '0');
|
||||
if (overUnder > 0) {
|
||||
return '#ef4444'; // Red for over-utilized
|
||||
} else if (overUnder < 0) {
|
||||
return '#22c55e'; // Green for under-utilized
|
||||
} else {
|
||||
return '#6b7280'; // Gray for exactly on target
|
||||
}
|
||||
}) : [];
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ export const WORKLENZ_REDIRECT_PROJ_KEY = 'worklenz.redirect_proj';
|
||||
export const PROJECT_SORT_FIELD = 'worklenz.projects.sort_field';
|
||||
export const PROJECT_SORT_ORDER = 'worklenz.projects.sort_order';
|
||||
export const PROJECT_LIST_COLUMNS = 'worklenz.reporting.projects.column_list';
|
||||
export const LICENSE_ALERT_KEY = 'worklenz.licensing_close';
|
||||
|
||||
export const PROJECT_STATUS_ICON_MAP = {
|
||||
'check-circle': CheckCircleOutlined,
|
||||
|
||||
@@ -28,4 +28,5 @@ export interface ILocalSession extends IUserType {
|
||||
subscription_status?: string;
|
||||
subscription_type?: string;
|
||||
trial_expire_date?: string;
|
||||
valid_till_date?: string;
|
||||
}
|
||||
|
||||
@@ -10,28 +10,34 @@ export interface IProjectFinanceJobTitle {
|
||||
}
|
||||
|
||||
export interface IProjectFinanceMember {
|
||||
id: string;
|
||||
team_member_id: string;
|
||||
job_title_id: string;
|
||||
rate: number | null;
|
||||
user: IProjectFinanceUser;
|
||||
job_title: IProjectFinanceJobTitle;
|
||||
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;
|
||||
job_title_id: string | null;
|
||||
job_title_name: string | null;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status_id: string;
|
||||
priority_id: string;
|
||||
phase_id: string;
|
||||
estimated_hours: number;
|
||||
actual_hours: number;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
billable: boolean;
|
||||
assignees: any[]; // Using any[] since we don't have the assignee structure yet
|
||||
total_time_logged: number;
|
||||
estimated_cost: number;
|
||||
members: IProjectFinanceMember[];
|
||||
billable: boolean;
|
||||
fixed_cost: number;
|
||||
variance: number;
|
||||
total_budget: number;
|
||||
total_actual: number;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceGroup {
|
||||
@@ -42,4 +48,57 @@ export interface IProjectFinanceGroup {
|
||||
tasks: IProjectFinanceTask[];
|
||||
}
|
||||
|
||||
export interface IProjectRateCard {
|
||||
id: string;
|
||||
project_id: string;
|
||||
job_title_id: string;
|
||||
rate: string;
|
||||
job_title_name: string;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceResponse {
|
||||
groups: IProjectFinanceGroup[];
|
||||
project_rate_cards: IProjectRateCard[];
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
7
worklenz-frontend/src/utils/format-hours-to-readable.ts
Normal file
7
worklenz-frontend/src/utils/format-hours-to-readable.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const formatHoursToReadable = (hours: number) => {
|
||||
return hours / 60;
|
||||
};
|
||||
|
||||
export const convertToHoursMinutes = (hours: number) => {
|
||||
return `${Math.floor(hours / 60)} h ${hours % 60} min`;
|
||||
};
|
||||
Reference in New Issue
Block a user