feat: Implement Ratecard Drawer and Finance Table
This commit is contained in:
@@ -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;
|
||||
42
worklenz-frontend/src/features/finance/finance-slice.ts
Normal file
42
worklenz-frontend/src/features/finance/finance-slice.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user