feat: Implement Ratecard Drawer and Finance Table

This commit is contained in:
shancds
2025-05-14 22:20:50 +05:30
parent 19deef9298
commit 6847eec603
20 changed files with 1659 additions and 1 deletions

View File

@@ -69,7 +69,7 @@ import projectReportsTableColumnsReducer from '../features/reporting/projectRepo
import projectReportsReducer from '../features/reporting/projectReports/project-reports-slice';
import membersReportsReducer from '../features/reporting/membersReports/membersReportsSlice';
import timeReportsOverviewReducer from '@features/reporting/time-reports/time-reports-overview.slice';
import financeReducer from '../features/finance/finance-slice';
import roadmapReducer from '../features/roadmap/roadmap-slice';
import teamMembersReducer from '@features/team-members/team-members.slice';
import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
@@ -155,6 +155,7 @@ export const store = configureStore({
roadmapReducer: roadmapReducer,
groupByFilterDropdownReducer: groupByFilterDropdownReducer,
timeReportsOverviewReducer: timeReportsOverviewReducer,
financeReducer: financeReducer,
},
});

View File

@@ -0,0 +1,195 @@
import React, { useEffect, useState } from 'react';
import { Drawer, Typography } 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';
const FinanceDrawer = ({ task }: { task: any }) => {
const [selectedTask, setSelectedTask] = useState(task);
useEffect(() => {
setSelectedTask(task);
}, [task]);
// localization
const { t } = useTranslation('project-view-finance');
// 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
).toUpperCase();
// function handle drawer close
const handleClose = () => {
setSelectedTask(null);
dispatch(toggleFinanceDrawer());
};
// 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')}
</Typography.Text>
}
open={isDrawerOpen}
onClose={handleClose}
destroyOnClose={true}
width={480}
>
<div>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
marginBottom: '16px',
}}
>
<thead>
<tr
style={{
height: 48,
backgroundColor: themeWiseColor(
'#F5F5F5',
'#1d1d1d',
themeMode
),
}}
>
<th
style={{
textAlign: 'left',
padding: 8,
}}
></th>
<th
style={{
textAlign: 'right',
padding: 8,
}}
>
{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
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,
}}
>
{group.laborHours}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{group.cost}
</td>
</tr>
{/* Member Rows */}
{group.members.map((member: any, index: number) => (
<tr
key={`${group.jobRole}-${index}`}
className="border-b-[1px]"
style={{ height: 56 }}
>
<td
style={{
padding: 8,
paddingLeft: 32,
}}
>
{member.name}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.laborHours}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.cost}
</td>
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
</Drawer>
);
};
export default FinanceDrawer;

View File

@@ -0,0 +1,42 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
type financeState = {
isRatecardDrawerOpen: boolean;
isFinanceDrawerOpen: boolean;
isImportRatecardsDrawerOpen: boolean;
currency: string;
};
const initialState: financeState = {
isRatecardDrawerOpen: false,
isFinanceDrawerOpen: false,
isImportRatecardsDrawerOpen: false,
currency: 'LKR',
};
const financeSlice = createSlice({
name: 'financeReducer',
initialState,
reducers: {
toggleRatecardDrawer: (state) => {
state.isRatecardDrawerOpen = !state.isRatecardDrawerOpen;
},
toggleFinanceDrawer: (state) => {
state.isFinanceDrawerOpen = !state.isFinanceDrawerOpen;
},
toggleImportRatecardsDrawer: (state) => {
state.isImportRatecardsDrawerOpen = !state.isImportRatecardsDrawerOpen;
},
changeCurrency: (state, action: PayloadAction<string>) => {
state.currency = action.payload;
},
},
});
export const {
toggleRatecardDrawer,
toggleFinanceDrawer,
toggleImportRatecardsDrawer,
changeCurrency,
} = financeSlice.actions;
export default financeSlice.reducer;

View File

@@ -0,0 +1,114 @@
import { Drawer, Typography, Button, Table, Menu, Flex } from 'antd';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { fetchData } from '../../../utils/fetchData';
import { toggleImportRatecardsDrawer } from '../finance-slice';
import { RatecardType } from '@/types/project/ratecard.types';
const ImportRatecardsDrawer: React.FC = () => {
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
const [selectedRatecardId, setSelectedRatecardId] = useState<string | null>(
null
);
// localization
const { t } = useTranslation('project-view-finance');
// get drawer state from client reducer
const isDrawerOpen = useAppSelector(
(state) => state.financeReducer.isImportRatecardsDrawerOpen
);
const dispatch = useAppDispatch();
// fetch rate cards data
useEffect(() => {
fetchData('/finance-mock-data/ratecards-data.json', setRatecardsList);
}, []);
// get currently using currency from finance reducer
const currency = useAppSelector(
(state) => state.financeReducer.currency
).toUpperCase();
// find the selected rate card's job roles
const selectedRatecard =
ratecardsList.find(
(ratecard) => ratecard.ratecardId === selectedRatecardId
) || null;
// table columns
const columns = [
{
title: t('jobTitleColumn'),
dataIndex: 'jobTitle',
render: (text: string) => (
<Typography.Text className="group-hover:text-[#1890ff]">
{text}
</Typography.Text>
),
},
{
title: `${t('ratePerHourColumn')} (${currency})`,
dataIndex: 'ratePerHour',
render: (text: number) => <Typography.Text>{text}</Typography.Text>,
},
];
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{t('ratecardsPluralText')}
</Typography.Text>
}
footer={
<div style={{ textAlign: 'right' }}>
<Button type="primary">Import</Button>
</div>
}
open={isDrawerOpen}
onClose={() => dispatch(toggleImportRatecardsDrawer())}
width={1000}
>
<Flex gap={12}>
{/* sidebar menu */}
<Menu
mode="vertical"
style={{ width: '20%' }}
selectedKeys={
selectedRatecardId
? [selectedRatecardId]
: [ratecardsList[0]?.ratecardId]
}
onClick={({ key }) => setSelectedRatecardId(key)}
>
{ratecardsList.map((ratecard) => (
<Menu.Item key={ratecard.ratecardId}>
{ratecard.ratecardName}
</Menu.Item>
))}
</Menu>
{/* table for job roles */}
<Table
style={{ flex: 1 }}
dataSource={selectedRatecard?.jobRolesList || []}
columns={columns}
rowKey={(record) => record.jobId}
onRow={() => {
return {
className: 'group',
style: {
cursor: 'pointer',
},
};
}}
pagination={false}
/>
</Flex>
</Drawer>
);
};
export default ImportRatecardsDrawer;

View File

@@ -0,0 +1,181 @@
import { Drawer, Select, Typography, Flex, Button, Input, Table } from 'antd';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { fetchData } from '../../../utils/fetchData';
import { toggleRatecardDrawer } from '../finance-slice';
import { RatecardType } from '@/types/project/ratecard.types';
import { JobType } from '@/types/project/job.types';
const RatecardDrawer = ({
type,
ratecardId,
}: {
type: 'create' | 'update';
ratecardId: string;
}) => {
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
// initial Job Roles List (dummy data)
const [roles, setRoles] = useState<JobType[]>([]);
// localization
const { t } = useTranslation('ratecard-settings');
// get drawer state from client reducer
const isDrawerOpen = useAppSelector(
(state) => state.financeReducer.isRatecardDrawerOpen
);
const dispatch = useAppDispatch();
// fetch rate cards data
useEffect(() => {
fetchData('/finance-mock-data/ratecards-data.json', setRatecardsList);
}, []);
// get currently selected ratecard
const selectedRatecard = ratecardsList.find(
(ratecard) => ratecard.ratecardId === ratecardId
);
useEffect(() => {
type === 'update'
? setRoles(selectedRatecard?.jobRolesList || [])
: setRoles([
{
jobId: 'J001',
jobTitle: 'Project Manager',
ratePerHour: 50,
},
{
jobId: 'J002',
jobTitle: 'Senior Software Engineer',
ratePerHour: 40,
},
{
jobId: 'J003',
jobTitle: 'Junior Software Engineer',
ratePerHour: 25,
},
{
jobId: 'J004',
jobTitle: 'UI/UX Designer',
ratePerHour: 30,
},
]);
}, [selectedRatecard?.jobRolesList, type]);
// get currently using currency from finance reducer
const currency = useAppSelector(
(state) => state.financeReducer.currency
).toUpperCase();
// add new job role handler
const handleAddRole = () => {
const newRole = {
jobId: `J00${roles.length + 1}`,
jobTitle: 'New Role',
ratePerHour: 0,
};
setRoles([...roles, newRole]);
};
// table columns
const columns = [
{
title: t('jobTitleColumn'),
dataIndex: 'jobTitle',
render: (text: string, record: any, index: number) => (
<Input
value={text}
placeholder="Enter job title"
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
color: '#1890ff',
}}
onChange={(e) => {
const updatedRoles = [...roles];
updatedRoles[index].jobTitle = e.target.value;
setRoles(updatedRoles);
}}
/>
),
},
{
title: `${t('ratePerHourColumn')} (${currency})`,
dataIndex: 'ratePerHour',
render: (text: number, record: any, index: number) => (
<Input
type="number"
value={text}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
}}
onChange={(e) => {
const updatedRoles = [...roles];
updatedRoles[index].ratePerHour = parseInt(e.target.value, 10) || 0;
setRoles(updatedRoles);
}}
/>
),
},
];
return (
<Drawer
title={
<Flex align="center" justify="space-between">
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{type === 'update'
? selectedRatecard?.ratecardName
: 'Untitled Rate Card'}
</Typography.Text>
<Flex gap={8} align="center">
<Typography.Text>{t('currency')}</Typography.Text>
<Select
defaultValue={'lkr'}
options={[
{ value: 'lkr', label: 'LKR' },
{ value: 'usd', label: 'USD' },
{ value: 'inr', label: 'INR' },
]}
/>
</Flex>
</Flex>
}
open={isDrawerOpen}
onClose={() => dispatch(toggleRatecardDrawer())}
width={800}
>
{/* ratecard Table directly inside the Drawer */}
<Table
dataSource={roles}
columns={columns}
rowKey={(record) => record.jobId}
pagination={false}
footer={() => (
<Button
type="dashed"
onClick={handleAddRole}
style={{ width: 'fit-content' }}
>
{t('addRoleButton')}
</Button>
)}
/>
<Flex justify="end" gap={16} style={{ marginTop: 16 }}>
<Button type="primary">{t('saveButton')}</Button>
</Flex>
</Drawer>
);
};
export default RatecardDrawer;

View File

@@ -5,6 +5,7 @@ import ProjectViewMembers from '@/pages/projects/projectView/members/project-vie
import ProjectViewUpdates from '@/pages/projects/project-view-1/updates/project-view-updates';
import ProjectViewTaskList from '@/pages/projects/projectView/taskList/project-view-task-list';
import ProjectViewBoard from '@/pages/projects/projectView/board/project-view-board';
import ProjectViewFinance from '@/pages/projects/projectView/finance/project-view-finance';
// type of a tab items
type TabItems = {
@@ -67,4 +68,10 @@ export const tabItems: TabItems[] = [
label: 'Updates',
element: React.createElement(ProjectViewUpdates),
},
{
index: 8,
key: 'finance',
label: 'Finance',
element: React.createElement(ProjectViewFinance),
},
];

View File

@@ -0,0 +1,59 @@
type FinanceTableColumnsType = {
key: string;
name: string;
width: number;
type: 'string' | 'hours' | 'currency';
};
// finance table columns
export const financeTableColumns: FinanceTableColumnsType[] = [
{
key: 'task',
name: 'task',
width: 240,
type: 'string',
},
{
key: 'members',
name: 'members',
width: 160,
type: 'string',
},
{
key: 'hours',
name: 'hours',
width: 80,
type: 'hours',
},
{
key: 'cost',
name: 'cost',
width: 120,
type: 'currency',
},
{
key: 'fixedCost',
name: 'fixedCost',
width: 120,
type: 'currency',
},
{
key: 'totalBudget',
name: 'totalBudgetedCost',
width: 120,
type: 'currency',
},
{
key: 'totalActual',
name: 'totalActualCost',
width: 120,
type: 'currency',
},
{
key: 'variance',
name: 'variance',
width: 120,
type: 'currency',
},
];

View File

@@ -0,0 +1,44 @@
import React, { useEffect, useMemo, useState } from 'react';
import FinanceTableWrapper from './finance-table/finance-table-wrapper';
import { fetchData } from '../../../../../utils/fetchData';
const FinanceTab = ({
groupType,
}: {
groupType: 'status' | 'priority' | 'phases';
}) => {
// Save each table's list according to the groups
const [statusTables, setStatusTables] = useState<any[]>([]);
const [priorityTables, setPriorityTables] = useState<any[]>([]);
const [activeTablesList, setActiveTablesList] = useState<any[]>([]);
// Fetch data for status tables
useMemo(() => {
fetchData('/finance-mock-data/finance-task-status.json', setStatusTables);
}, []);
// Fetch data for priority tables
useMemo(() => {
fetchData(
'/finance-mock-data/finance-task-priority.json',
setPriorityTables
);
}, []);
// Update activeTablesList based on groupType and fetched data
useEffect(() => {
if (groupType === 'status') {
setActiveTablesList(statusTables);
} else if (groupType === 'priority') {
setActiveTablesList(priorityTables);
}
}, [groupType, priorityTables, statusTables]);
return (
<div>
<FinanceTableWrapper activeTablesList={activeTablesList} />
</div>
);
};
export default FinanceTab;

View File

@@ -0,0 +1,253 @@
import React, { useEffect, useState } from 'react';
import { Checkbox, Flex, Typography } 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 FinanceTable from './finance-table';
import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer';
const FinanceTableWrapper = ({
activeTablesList,
}: {
activeTablesList: any;
}) => {
const [isScrolling, setIsScrolling] = useState(false);
//? this state for inside this state individualy in finance table only display the data of the last table's task when a task is clicked The selectedTask state does not synchronize across tables so thats why move the selectedTask state to a parent component
const [selectedTask, setSelectedTask] = useState(null);
// localization
const { t } = useTranslation('project-view-finance');
const dispatch = useAppDispatch();
// function on task click
const onTaskClick = (task: any) => {
setSelectedTask(task);
dispatch(toggleFinanceDrawer());
};
// trigger the table scrolling
useEffect(() => {
const tableContainer = document.querySelector('.tasklist-container');
const handleScroll = () => {
if (tableContainer) {
setIsScrolling(tableContainer.scrollLeft > 0);
}
};
// add the scroll event listener
tableContainer?.addEventListener('scroll', handleScroll);
// cleanup on unmount
return () => {
tableContainer?.removeEventListener('scroll', handleScroll);
};
}, []);
// get theme data from theme reducer
const themeMode = useAppSelector((state) => state.themeReducer.mode);
// get tasklist and currently using currency from finance reducer
const { currency } = useAppSelector((state) => state.financeReducer);
// totals of all the tasks
const totals = activeTablesList.reduce(
(
acc: {
hours: number;
cost: number;
fixedCost: number;
totalBudget: number;
totalActual: number;
variance: number;
},
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,
}
);
const renderFinancialTableHeaderContent = (columnKey: any) => {
switch (columnKey) {
case 'hours':
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.hours}
</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':
return (
<Typography.Text
style={{
color: totals.variance < 0 ? '#FF0000' : '#6DC376',
fontSize: 18,
}}
>
{totals.variance}
</Typography.Text>
);
default:
return null;
}
};
// layout styles for table and the columns
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 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' : ''}`}`;
return (
<>
<Flex
vertical
className="tasklist-container min-h-0 max-w-full overflow-x-auto"
>
<table>
<tbody>
<tr
style={{
height: 56,
fontWeight: 600,
backgroundColor: themeWiseColor(
'#fafafa',
'#1d1d1d',
themeMode
),
borderBlockEnd: `2px solid rgb(0 0 0 / 0.05)`,
}}
>
<td
style={{ width: 32, paddingInline: 16 }}
className={customColumnHeaderStyles('selector')}
>
<Checkbox />
</td>
{financeTableColumns.map((col) => (
<td
key={col.key}
style={{
minWidth: col.width,
paddingInline: 16,
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()})`}
</Typography.Text>
</td>
))}
</tr>
<tr
style={{
height: 56,
fontWeight: 500,
backgroundColor: themeWiseColor(
'#fbfbfb',
'#141414',
themeMode
),
}}
>
<td
colSpan={3}
style={{
paddingInline: 16,
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>
{activeTablesList.map((table: any, index: number) => (
<FinanceTable
key={index}
table={table}
isScrolling={isScrolling}
onTaskClick={onTaskClick}
/>
))}
</tbody>
</table>
</Flex>
{selectedTask && <FinanceDrawer task={selectedTask} />}
</>
);
};
export default FinanceTableWrapper;

View File

@@ -0,0 +1,287 @@
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 {
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';
type FinanceTableProps = {
table: any;
isScrolling: boolean;
onTaskClick: (task: any) => void;
};
const FinanceTable = ({
table,
isScrolling,
onTaskClick,
}: FinanceTableProps) => {
const [isCollapse, setIsCollapse] = useState<boolean>(false);
const [selectedTask, setSelectedTask] = useState(null);
// 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]
);
useEffect(() => {
console.log('Selected Task:', selectedTask);
}, [selectedTask]);
const renderFinancialTableHeaderContent = (columnKey: any) => {
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>
);
default:
return null;
}
};
const renderFinancialTableColumnContent = (columnKey: any, task: any) => {
switch (columnKey) {
case 'task':
return (
<Tooltip title={task.task}>
<Flex gap={8} align="center">
<Typography.Text
ellipsis={{ expanded: false }}
style={{ maxWidth: 160 }}
>
{task.task}
</Typography.Text>
{task.isbBillable && <DollarCircleOutlined />}
</Flex>
</Tooltip>
);
case 'members':
return (
<Avatar.Group>
{task.members.map((member: any) => (
<CustomAvatar avatarName={member.name} size={26} />
))}
</Avatar.Group>
);
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 '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',
}}
>
{task.variance}
</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]'}`;
return (
<>
{/* header row */}
<tr
style={{
height: 40,
backgroundColor: themeWiseColor(
table.color_code,
table.color_code_dark,
themeMode
),
fontWeight: 600,
}}
className="group"
>
<td
colSpan={3}
style={{
width: 48,
textTransform: 'capitalize',
textAlign: 'left',
paddingInline: 16,
backgroundColor: themeWiseColor(
table.color_code,
table.color_code_dark,
themeMode
),
cursor: 'pointer',
}}
className={customColumnHeaderStyles('tableTitle')}
onClick={(e) => setIsCollapse((prev) => !prev)}
>
<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' && (
<td
key={col.key}
style={{
width: col.width,
paddingInline: 16,
textAlign: 'end',
}}
>
{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>
))}
</>
);
};
export default FinanceTable;

View File

@@ -0,0 +1,56 @@
import { CaretDownFilled } from '@ant-design/icons';
import { Flex, Select } from 'antd';
import React from 'react';
import { useSelectedProject } from '../../../../../hooks/useSelectedProject';
import { useAppSelector } from '../../../../../hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
type GroupByFilterDropdownProps = {
activeGroup: 'status' | 'priority' | 'phases';
setActiveGroup: (group: 'status' | 'priority' | 'phases') => void;
};
const GroupByFilterDropdown = ({
activeGroup,
setActiveGroup,
}: GroupByFilterDropdownProps) => {
// localization
const { t } = useTranslation('project-view-finance');
const handleChange = (value: string) => {
setActiveGroup(value as 'status' | 'priority' | 'phases');
};
// get selected project from useSelectedPro
const selectedProject = useSelectedProject();
//get phases details from phases slice
const phase =
useAppSelector((state) => state.phaseReducer.phaseList).find(
(phase) => phase?.projectId === selectedProject?.projectId
) || null;
const groupDropdownMenuItems = [
{ key: 'status', value: 'status', label: t('statusText') },
{ key: 'priority', value: 'priority', label: t('priorityText') },
{
key: 'phase',
value: 'phase',
label: phase ? phase?.phase : t('phaseText'),
},
];
return (
<Flex align="center" gap={4} style={{ marginInlineStart: 12 }}>
{t('groupByText')}:
<Select
defaultValue={'status'}
options={groupDropdownMenuItems}
onChange={handleChange}
suffixIcon={<CaretDownFilled />}
/>
</Flex>
);
};
export default GroupByFilterDropdown;

View File

@@ -0,0 +1,86 @@
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';
import { useTranslation } from 'react-i18next';
import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice';
type ProjectViewFinanceHeaderProps = {
activeTab: 'finance' | 'ratecard';
setActiveTab: (tab: 'finance' | 'ratecard') => void;
activeGroup: 'status' | 'priority' | 'phases';
setActiveGroup: (group: 'status' | 'priority' | 'phases') => void;
};
const ProjectViewFinanceHeader = ({
activeTab,
setActiveTab,
activeGroup,
setActiveGroup,
}: ProjectViewFinanceHeaderProps) => {
// localization
const { t } = useTranslation('project-view-finance');
const dispatch = useAppDispatch();
return (
<ConfigProvider wave={{ disabled: true }}>
<Flex gap={16} align="center" justify="space-between">
<Flex gap={16} align="center">
<Flex>
<Button
className={`${activeTab === 'finance' && 'border-[#1890ff] text-[#1890ff]'} rounded-r-none`}
onClick={() => setActiveTab('finance')}
>
{t('financeText')}
</Button>
<Button
className={`${activeTab === 'ratecard' && 'border-[#1890ff] text-[#1890ff]'} rounded-l-none`}
onClick={() => setActiveTab('ratecard')}
>
{t('ratecardSingularText')}
</Button>
</Flex>
{activeTab === 'finance' && (
<GroupByFilterDropdown
activeGroup={activeGroup}
setActiveGroup={setActiveGroup}
/>
)}
</Flex>
{activeTab === 'finance' ? (
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
{t('exportButton')}
</Button>
) : (
<Flex gap={8} align="center">
<Flex gap={8} align="center">
<Typography.Text>{t('currencyText')}</Typography.Text>
<Select
defaultValue={'lkr'}
options={[
{ value: 'lkr', label: 'LKR' },
{ value: 'usd', label: 'USD' },
{ value: 'inr', label: 'INR' },
]}
onChange={(value) => dispatch(changeCurrency(value))}
/>
</Flex>
<Button
type="primary"
onClick={() => dispatch(toggleImportRatecardsDrawer())}
>
{t('importButton')}
</Button>
</Flex>
)}
</Flex>
</ConfigProvider>
);
};
export default ProjectViewFinanceHeader;

View File

@@ -0,0 +1,32 @@
import { Flex } from 'antd';
import React, { useState } from 'react';
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';
type FinanceTabType = 'finance' | 'ratecard';
type GroupTypes = 'status' | 'priority' | 'phases';
const ProjectViewFinance = () => {
const [activeTab, setActiveTab] = useState<FinanceTabType>('finance');
const [activeGroup, setActiveGroup] = useState<GroupTypes>('status');
return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
<ProjectViewFinanceHeader
activeTab={activeTab}
setActiveTab={setActiveTab}
activeGroup={activeGroup}
setActiveGroup={setActiveGroup}
/>
{activeTab === 'finance' ? (
<FinanceTab groupType={activeGroup} />
) : (
<RatecardTab />
)}
</Flex>
);
};
export default ProjectViewFinance;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import RatecardTable from './reatecard-table/ratecard-table';
import { Button, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import ImportRatecardsDrawer from '@/features/finance/ratecard-drawer/import-ratecards-drawer';
const RatecardTab = () => {
// localization
const { t } = useTranslation('project-view-finance');
return (
<Flex vertical gap={8}>
<RatecardTable />
<Typography.Text
type="danger"
style={{ display: 'block', marginTop: '10px' }}
>
{t('ratecardImportantNotice')}
</Typography.Text>
<Button
type="primary"
style={{
marginTop: '10px',
width: 'fit-content',
alignSelf: 'flex-end',
}}
>
{t('saveButton')}
</Button>
{/* import ratecards drawer */}
<ImportRatecardsDrawer />
</Flex>
);
};
export default RatecardTab;

View File

@@ -0,0 +1,150 @@
import { Avatar, Button, Input, Table, TableProps } from 'antd';
import React, { useState } from 'react';
import CustomAvatar from '../../../../../../components/CustomAvatar';
import { PlusOutlined } from '@ant-design/icons';
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { JobRoleType } from '@/types/project/ratecard.types';
const initialJobRolesList: JobRoleType[] = [
{
jobId: 'J001',
jobTitle: 'Project Manager',
ratePerHour: 50,
members: ['Alice Johnson', 'Bob Smith'],
},
{
jobId: 'J002',
jobTitle: 'Senior Software Engineer',
ratePerHour: 40,
members: ['Charlie Brown', 'Diana Prince'],
},
{
jobId: 'J003',
jobTitle: 'Junior Software Engineer',
ratePerHour: 25,
members: ['Eve Davis', 'Frank Castle'],
},
{
jobId: 'J004',
jobTitle: 'UI/UX Designer',
ratePerHour: 30,
members: null,
},
];
const RatecardTable: React.FC = () => {
const [roles, setRoles] = useState<JobRoleType[]>(initialJobRolesList);
// localization
const { t } = useTranslation('project-view-finance');
// get currently using currency from finance reducer
const currency = useAppSelector(
(state) => state.financeReducer.currency
).toUpperCase();
const handleAddRole = () => {
const newRole: JobRoleType = {
jobId: `J00${roles.length + 1}`,
jobTitle: 'New Role',
ratePerHour: 0,
members: [],
};
setRoles([...roles, newRole]);
};
const columns: TableProps<JobRoleType>['columns'] = [
{
title: t('jobTitleColumn'),
dataIndex: 'jobTitle',
render: (text: string, record: JobRoleType, index: number) => (
<Input
value={text}
placeholder="Enter job title"
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
color: '#1890ff',
}}
onChange={(e) => {
const updatedRoles = [...roles];
updatedRoles[index].jobTitle = e.target.value;
setRoles(updatedRoles);
}}
/>
),
},
{
title: `${t('ratePerHourColumn')} (${currency})`,
dataIndex: 'ratePerHour',
render: (text: number, record: JobRoleType, index: number) => (
<Input
type="number"
value={text}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
}}
onChange={(e) => {
const updatedRoles = [...roles];
updatedRoles[index].ratePerHour = parseInt(e.target.value, 10) || 0;
setRoles(updatedRoles);
}}
/>
),
},
{
title: t('membersColumn'),
dataIndex: 'members',
render: (members: string[]) =>
members?.length > 0 ? (
<Avatar.Group>
{members.map((member, i) => (
<CustomAvatar key={i} avatarName={member} size={26} />
))}
</Avatar.Group>
) : (
<Button
shape="circle"
icon={
<PlusOutlined
style={{
fontSize: 12,
width: 22,
height: 22,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
}
/>
),
},
];
return (
<Table
dataSource={roles}
columns={columns}
rowKey={(record) => record.jobId}
pagination={false}
footer={() => (
<Button
type="dashed"
onClick={handleAddRole}
style={{ width: 'fit-content' }}
>
{t('addRoleButton')}
</Button>
)}
/>
);
};
export default RatecardTable;

View File

@@ -0,0 +1,6 @@
export type JobType = {
jobId: string;
jobTitle: string;
ratePerHour?: number;
};

View File

@@ -0,0 +1,14 @@
import { JobType } from "./job.types";
export interface JobRoleType extends JobType {
members: string[] | null;
}
export type RatecardType = {
ratecardId: string;
ratecardName: string;
jobRolesList: JobType[];
createdDate: Date;
currency?: string;
};