feat(project-ratecard): implement project rate card management with CRUD operations and integrate into frontend
This commit is contained in:
@@ -8,8 +8,10 @@ type financeState = {
|
||||
isFinanceDrawerOpen: boolean;
|
||||
isImportRatecardsDrawerOpen: boolean;
|
||||
currency: string;
|
||||
isRatecardsLoading?: boolean;
|
||||
isFinanceDrawerloading?: boolean;
|
||||
drawerRatecard?: RatecardType | null;
|
||||
ratecardsList?: RatecardType[] | null;
|
||||
};
|
||||
|
||||
const initialState: financeState = {
|
||||
@@ -17,8 +19,10 @@ const initialState: financeState = {
|
||||
isFinanceDrawerOpen: false,
|
||||
isImportRatecardsDrawerOpen: false,
|
||||
currency: 'LKR',
|
||||
isRatecardsLoading: false,
|
||||
isFinanceDrawerloading: false,
|
||||
drawerRatecard: null,
|
||||
ratecardsList: null,
|
||||
};
|
||||
interface FetchRateCardsParams {
|
||||
index: number;
|
||||
@@ -139,7 +143,21 @@ const financeSlice = createSlice({
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
// ...other cases...
|
||||
.addCase(fetchRateCards.pending, (state) => {
|
||||
state.isRatecardsLoading = true;
|
||||
})
|
||||
.addCase(fetchRateCards.fulfilled, (state, action) => {
|
||||
state.isRatecardsLoading = false;
|
||||
state.ratecardsList = Array.isArray(action.payload.data)
|
||||
? action.payload.data
|
||||
: Array.isArray(action.payload)
|
||||
? action.payload
|
||||
: [];
|
||||
})
|
||||
.addCase(fetchRateCards.rejected, (state) => {
|
||||
state.isRatecardsLoading = false;
|
||||
state.ratecardsList = [];
|
||||
})
|
||||
.addCase(fetchRateCardById.pending, (state) => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
state.drawerRatecard = null;
|
||||
|
||||
247
worklenz-frontend/src/features/finance/project-finance-slice.ts
Normal file
247
worklenz-frontend/src/features/finance/project-finance-slice.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { projectRateCardApiService, IProjectRateCardRole } from '@/api/project-finance-ratecard/project-finance-rate-cards.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { JobRoleType } from '@/types/project/ratecard.types';
|
||||
|
||||
type ProjectFinanceRateCardState = {
|
||||
isDrawerOpen: boolean;
|
||||
isLoading: boolean;
|
||||
rateCardRoles: JobRoleType[] | null;
|
||||
drawerRole: IProjectRateCardRole | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
const initialState: ProjectFinanceRateCardState = {
|
||||
isDrawerOpen: false,
|
||||
isLoading: false,
|
||||
rateCardRoles: null,
|
||||
drawerRole: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Async thunks
|
||||
export const fetchProjectRateCardRoles = createAsyncThunk(
|
||||
'projectFinance/fetchAll',
|
||||
async (project_id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.getFromProjectId(project_id);
|
||||
console.log('Project RateCard Roles:', response);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Project RateCard Roles', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to fetch project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchProjectRateCardRoleById = createAsyncThunk(
|
||||
'projectFinance/fetchById',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.getFromId(id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Project RateCard Role By Id', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to fetch project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const insertProjectRateCardRoles = createAsyncThunk(
|
||||
'projectFinance/insertMany',
|
||||
async ({ project_id, roles }: { project_id: string; roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[] }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.insertMany(project_id, roles);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Insert Project RateCard Roles', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to insert project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateProjectRateCardRoleById = createAsyncThunk(
|
||||
'projectFinance/updateById',
|
||||
async ({ id, body }: { id: string; body: { job_title_id: string; rate: string } }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.updateFromId(id, body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Update Project RateCard Role By Id', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to update project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateProjectRateCardRolesByProjectId = createAsyncThunk(
|
||||
'projectFinance/updateByProjectId',
|
||||
async ({ project_id, roles }: { project_id: string; roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[] }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.updateFromProjectId(project_id, roles);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Update Project RateCard Roles By ProjectId', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to update project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteProjectRateCardRoleById = createAsyncThunk(
|
||||
'projectFinance/deleteById',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.deleteFromId(id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Delete Project RateCard Role By Id', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to delete project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteProjectRateCardRolesByProjectId = createAsyncThunk(
|
||||
'projectFinance/deleteByProjectId',
|
||||
async (project_id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.deleteFromProjectId(project_id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Delete Project RateCard Roles By ProjectId', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to delete project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const projectFinanceSlice = createSlice({
|
||||
name: 'projectFinanceRateCard',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleDrawer: (state) => {
|
||||
state.isDrawerOpen = !state.isDrawerOpen;
|
||||
},
|
||||
clearDrawerRole: (state) => {
|
||||
state.drawerRole = null;
|
||||
},
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
// Fetch all
|
||||
.addCase(fetchProjectRateCardRoles.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoles.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = action.payload || [];
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoles.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
state.rateCardRoles = [];
|
||||
})
|
||||
// Fetch by id
|
||||
.addCase(fetchProjectRateCardRoleById.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.drawerRole = null;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.drawerRole = action.payload || null;
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoleById.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
state.drawerRole = null;
|
||||
})
|
||||
// Insert many
|
||||
.addCase(insertProjectRateCardRoles.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(insertProjectRateCardRoles.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = action.payload || [];
|
||||
})
|
||||
.addCase(insertProjectRateCardRoles.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Update by id
|
||||
.addCase(updateProjectRateCardRoleById.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(updateProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
if (state.rateCardRoles && action.payload) {
|
||||
state.rateCardRoles = state.rateCardRoles.map((role) =>
|
||||
role.id === action.payload.id ? action.payload : role
|
||||
);
|
||||
}
|
||||
})
|
||||
.addCase(updateProjectRateCardRoleById.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Update by project id
|
||||
.addCase(updateProjectRateCardRolesByProjectId.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(updateProjectRateCardRolesByProjectId.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = action.payload || [];
|
||||
})
|
||||
.addCase(updateProjectRateCardRolesByProjectId.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Delete by id
|
||||
.addCase(deleteProjectRateCardRoleById.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(deleteProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
if (state.rateCardRoles && action.payload) {
|
||||
state.rateCardRoles = state.rateCardRoles.filter((role) => role.id !== action.payload.id);
|
||||
}
|
||||
})
|
||||
.addCase(deleteProjectRateCardRoleById.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Delete by project id
|
||||
.addCase(deleteProjectRateCardRolesByProjectId.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(deleteProjectRateCardRolesByProjectId.fulfilled, (state) => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = [];
|
||||
})
|
||||
.addCase(deleteProjectRateCardRolesByProjectId.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
toggleDrawer,
|
||||
clearDrawerRole,
|
||||
clearError,
|
||||
} = projectFinanceSlice.actions;
|
||||
|
||||
export default projectFinanceSlice.reducer;
|
||||
@@ -1,47 +1,66 @@
|
||||
import { Drawer, Typography, Button, Table, Menu, Flex } from 'antd';
|
||||
import { Drawer, Typography, Button, Table, Menu, Flex, Spin } 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
|
||||
);
|
||||
import { fetchRateCards, toggleImportRatecardsDrawer } from '../finance-slice';
|
||||
import { fetchRateCardById } from '../finance-slice';
|
||||
import { insertProjectRateCardRoles } from '../project-finance-slice';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
// localization
|
||||
const ImportRatecardsDrawer: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { projectId } = useParams();
|
||||
const { t } = useTranslation('project-view-finance');
|
||||
|
||||
// get drawer state from client reducer
|
||||
const drawerRatecard = useAppSelector(
|
||||
(state) => state.financeReducer.drawerRatecard
|
||||
);
|
||||
const ratecardsList = useAppSelector(
|
||||
(state) => state.financeReducer.ratecardsList || []
|
||||
);
|
||||
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;
|
||||
// Loading states
|
||||
const isRatecardsLoading = useAppSelector(
|
||||
(state) => state.financeReducer.isRatecardsLoading
|
||||
);
|
||||
|
||||
const [selectedRatecardId, setSelectedRatecardId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRatecardId) {
|
||||
dispatch(fetchRateCardById(selectedRatecardId));
|
||||
}
|
||||
}, [selectedRatecardId, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDrawerOpen) {
|
||||
dispatch(fetchRateCards({
|
||||
index: 1,
|
||||
size: 1000,
|
||||
field: 'name',
|
||||
order: 'asc',
|
||||
search: '',
|
||||
}));
|
||||
}
|
||||
}, [isDrawerOpen, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ratecardsList.length > 0 && !selectedRatecardId) {
|
||||
setSelectedRatecardId(ratecardsList[0].id || null);
|
||||
}
|
||||
}, [ratecardsList, selectedRatecardId]);
|
||||
|
||||
// table columns
|
||||
const columns = [
|
||||
{
|
||||
title: t('jobTitleColumn'),
|
||||
dataIndex: 'jobTitle',
|
||||
dataIndex: 'jobtitle',
|
||||
render: (text: string) => (
|
||||
<Typography.Text className="group-hover:text-[#1890ff]">
|
||||
{text}
|
||||
@@ -50,7 +69,7 @@ const ImportRatecardsDrawer: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: `${t('ratePerHourColumn')} (${currency})`,
|
||||
dataIndex: 'ratePerHour',
|
||||
dataIndex: 'rate',
|
||||
render: (text: number) => <Typography.Text>{text}</Typography.Text>,
|
||||
},
|
||||
];
|
||||
@@ -64,7 +83,31 @@ const ImportRatecardsDrawer: React.FC = () => {
|
||||
}
|
||||
footer={
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Button type="primary">Import</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
if (!projectId) {
|
||||
// Handle missing project id (show error, etc.)
|
||||
return;
|
||||
}
|
||||
if (drawerRatecard?.jobRolesList?.length) {
|
||||
dispatch(
|
||||
insertProjectRateCardRoles({
|
||||
project_id: projectId,
|
||||
roles: drawerRatecard.jobRolesList
|
||||
.filter((role) => typeof role.rate !== 'undefined')
|
||||
.map((role) => ({
|
||||
...role,
|
||||
rate: Number(role.rate),
|
||||
})),
|
||||
})
|
||||
);
|
||||
}
|
||||
dispatch(toggleImportRatecardsDrawer());
|
||||
}}
|
||||
>
|
||||
{t('import')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
open={isDrawerOpen}
|
||||
@@ -72,43 +115,44 @@ const ImportRatecardsDrawer: React.FC = () => {
|
||||
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>
|
||||
{/* Sidebar menu with loading */}
|
||||
<Spin spinning={isRatecardsLoading} style={{ width: '20%' }}>
|
||||
<Menu
|
||||
mode="vertical"
|
||||
style={{ width: '100%' }}
|
||||
selectedKeys={
|
||||
selectedRatecardId
|
||||
? [selectedRatecardId]
|
||||
: ratecardsList[0]?.id
|
||||
? [ratecardsList[0].id]
|
||||
: []
|
||||
}
|
||||
onClick={({ key }) => setSelectedRatecardId(key)}
|
||||
>
|
||||
{ratecardsList.map((ratecard) => (
|
||||
<Menu.Item key={ratecard.id}>
|
||||
{ratecard.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
</Spin>
|
||||
|
||||
{/* table for job roles */}
|
||||
{/* Table for job roles with loading */}
|
||||
<Table
|
||||
style={{ flex: 1 }}
|
||||
dataSource={selectedRatecard?.jobRolesList || []}
|
||||
dataSource={drawerRatecard?.jobRolesList || []}
|
||||
columns={columns}
|
||||
rowKey={(record) => record.jobId}
|
||||
onRow={() => {
|
||||
return {
|
||||
className: 'group',
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
};
|
||||
}}
|
||||
rowKey={(record) => record.job_title_id}
|
||||
onRow={() => ({
|
||||
className: 'group',
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
pagination={false}
|
||||
loading={isRatecardsLoading}
|
||||
/>
|
||||
</Flex>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportRatecardsDrawer;
|
||||
export default ImportRatecardsDrawer;
|
||||
Reference in New Issue
Block a user