Merge branch 'Worklenz:feature/project-finance' into feature/project-finance

This commit is contained in:
Tharindu Kosgahakumbura
2025-05-27 08:51:31 +05:30
committed by GitHub
33 changed files with 1915 additions and 735 deletions

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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;
},
}

View File

@@ -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,
},
});

View File

@@ -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>
);

View 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;

View 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;

View File

@@ -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>
);

View File

@@ -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,

View File

@@ -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%',

View File

@@ -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;

View File

@@ -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 (

View 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;

View File

@@ -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',
},

View File

@@ -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
}))
}));

View File

@@ -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>

View File

@@ -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 />
</>
);
};

View File

@@ -0,0 +1 @@
/* Finance Table Styles */

View File

@@ -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>
);
};

View File

@@ -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';

View File

@@ -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' ? (

View File

@@ -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')}
</>
);
};

View File

@@ -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);

View File

@@ -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,

View File

@@ -28,4 +28,5 @@ export interface ILocalSession extends IUserType {
subscription_status?: string;
subscription_type?: string;
trial_expire_date?: string;
valid_till_date?: string;
}

View File

@@ -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';

View 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`;
};