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

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