feat(ratecard): enhance rate card management with CRUD operations and improved type definitions

This commit is contained in:
shancds
2025-05-20 13:38:42 +05:30
parent fbfeaceb9c
commit 26b0b5780a
6 changed files with 446 additions and 177 deletions

View File

@@ -3,11 +3,10 @@ import { API_BASE_URL } from '@/shared/constants';
import { IServerResponse } from '@/types/common.types';
import { IJobTitle, IJobTitlesViewModel } from '@/types/job.types';
import { toQueryString } from '@/utils/toQueryString';
import { RatecardType, IRatecardViewModel } from '@/types/project/ratecard.types';
type IRatecard = {
id: string;}
type IRatecardViewModel = {
id: string;}
const rootUrl = `${API_BASE_URL}/rate-cards`;
@@ -26,18 +25,18 @@ export const rateCardApiService = {
);
return response.data;
},
async getRateCardById(id: string): Promise<IServerResponse<IRatecard>> {
const response = await apiClient.get<IServerResponse<IRatecard>>(`${rootUrl}/${id}`);
async getRateCardById(id: string): Promise<IServerResponse<RatecardType>> {
const response = await apiClient.get<IServerResponse<RatecardType>>(`${rootUrl}/${id}`);
return response.data;
},
async createRateCard(body: IRatecard): Promise<IServerResponse<IRatecard>> {
const response = await apiClient.post<IServerResponse<IRatecard>>(rootUrl, body);
async createRateCard(body: RatecardType): Promise<IServerResponse<RatecardType>> {
const response = await apiClient.post<IServerResponse<RatecardType>>(rootUrl, body);
return response.data;
},
async updateRateCard(id: string, body: IRatecard): Promise<IServerResponse<IRatecard>> {
const response = await apiClient.put<IServerResponse<IRatecard>>(`${rootUrl}/${id}`, body);
async updateRateCard(id: string, body: RatecardType): Promise<IServerResponse<RatecardType>> {
const response = await apiClient.put<IServerResponse<RatecardType>>(`${rootUrl}/${id}`, body);
return response.data;
},

View File

@@ -1,10 +1,15 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service';
import { RatecardType } from '@/types/project/ratecard.types';
import logger from '@/utils/errorLogger';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
type financeState = {
isRatecardDrawerOpen: boolean;
isFinanceDrawerOpen: boolean;
isImportRatecardsDrawerOpen: boolean;
currency: string;
isFinanceDrawerloading?: boolean;
drawerRatecard?: RatecardType | null;
};
const initialState: financeState = {
@@ -12,7 +17,102 @@ const initialState: financeState = {
isFinanceDrawerOpen: false,
isImportRatecardsDrawerOpen: false,
currency: 'LKR',
isFinanceDrawerloading: false,
drawerRatecard: null,
};
interface FetchRateCardsParams {
index: number;
size: number;
field: string | null;
order: string | null;
search: string | null;
}
// Async thunks
export const fetchRateCards = createAsyncThunk(
'ratecards/fetchAll',
async (params: FetchRateCardsParams, { rejectWithValue }) => {
try {
const response = await rateCardApiService.getRateCards(
params.index,
params.size,
params.field,
params.order,
params.search
);
return response.body;
} catch (error) {
logger.error('Fetch RateCards', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to fetch rate cards');
}
}
);
export const fetchRateCardById = createAsyncThunk(
'ratecard/fetchById',
async (id: string, { rejectWithValue }) => {
try {
const response = await rateCardApiService.getRateCardById(id);
return response.body;
} catch (error) {
logger.error('Fetch RateCardById', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to fetch rate card');
}
}
);
export const createRateCard = createAsyncThunk(
'ratecards/create',
async (body: RatecardType, { rejectWithValue }) => {
try {
const response = await rateCardApiService.createRateCard(body);
return response.body;
} catch (error) {
logger.error('Create RateCard', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to create rate card');
}
}
);
export const updateRateCard = createAsyncThunk(
'ratecards/update',
async ({ id, body }: { id: string; body: RatecardType }, { rejectWithValue }) => {
try {
const response = await rateCardApiService.updateRateCard(id, body);
return response.body;
} catch (error) {
logger.error('Update RateCard', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to update rate card');
}
}
);
export const deleteRateCard = createAsyncThunk(
'ratecards/delete',
async (id: string, { rejectWithValue }) => {
try {
await rateCardApiService.deleteRateCard(id);
return id;
} catch (error) {
logger.error('Delete RateCard', error);
if (error instanceof Error) {
return rejectWithValue(error.message);
}
return rejectWithValue('Failed to delete rate card');
}
}
);
const financeSlice = createSlice({
name: 'financeReducer',
@@ -30,6 +130,28 @@ const financeSlice = createSlice({
changeCurrency: (state, action: PayloadAction<string>) => {
state.currency = action.payload;
},
ratecardDrawerLoading: (state, action: PayloadAction<boolean>) => {
state.isFinanceDrawerloading = action.payload;
},
clearDrawerRatecard: (state) => {
state.drawerRatecard = null;
},
},
extraReducers: (builder) => {
builder
// ...other cases...
.addCase(fetchRateCardById.pending, (state) => {
state.isFinanceDrawerloading = true;
state.drawerRatecard = null;
})
.addCase(fetchRateCardById.fulfilled, (state, action) => {
state.isFinanceDrawerloading = false;
state.drawerRatecard = action.payload;
})
.addCase(fetchRateCardById.rejected, (state) => {
state.isFinanceDrawerloading = false;
state.drawerRatecard = null;
});
},
});
@@ -38,5 +160,6 @@ export const {
toggleFinanceDrawer,
toggleImportRatecardsDrawer,
changeCurrency,
ratecardDrawerLoading,
} = financeSlice.actions;
export default financeSlice.reducer;

View File

@@ -1,83 +1,159 @@
import { Drawer, Select, Typography, Flex, Button, Input, Table } from 'antd';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, 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';
import { fetchRateCardById, fetchRateCards, toggleRatecardDrawer, updateRateCard } from '../finance-slice';
import { RatecardType, IJobType } from '@/types/project/ratecard.types';
import { IJobTitlesViewModel } from '@/types/job.types';
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
import { DeleteOutlined } from '@ant-design/icons';
import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service';
interface PaginationType {
current: number;
pageSize: number;
field: string;
order: string;
total: number;
pageSizeOptions: string[];
size: 'small' | 'default';
}
const RatecardDrawer = ({
type,
ratecardId,
onSaved,
}: {
type: 'create' | 'update';
ratecardId: string;
onSaved?: () => void;
}) => {
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
// initial Job Roles List (dummy data)
const [roles, setRoles] = useState<JobType[]>([]);
const [roles, setRoles] = useState<IJobType[]>([]);
// localization
const { t } = useTranslation('settings/ratecard-settings');
// get drawer state from client reducer
const drawerLoading = useAppSelector(state => state.financeReducer.drawerLoading);
const drawerRatecard = useAppSelector(state => state.financeReducer.drawerRatecard);
const isDrawerOpen = useAppSelector(
(state) => state.financeReducer.isRatecardDrawerOpen
);
// get currently using currency from finance reducer
const cur = useAppSelector(
(state) => state.financeReducer.currency
).toUpperCase();
const dispatch = useAppDispatch();
const [isAddingRole, setIsAddingRole] = useState(false);
const [selectedJobTitleId, setSelectedJobTitleId] = useState<string | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState('');
const [currency, setCurrency] = useState(cur);
const [name, setName] = useState<string>('Untitled Rate Card');
const [jobTitles, setJobTitles] = useState<IJobTitlesViewModel>({});
const [pagination, setPagination] = useState<PaginationType>({
current: 1,
pageSize: DEFAULT_PAGE_SIZE,
field: 'name',
order: 'desc',
total: 0,
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
size: 'small',
});
const getJobTitles = useMemo(() => {
return async () => {
const response = await jobTitlesApiService.getJobTitles(
pagination.current,
pagination.pageSize,
pagination.field,
pagination.order,
searchQuery
);
if (response.done) {
setJobTitles(response.body);
setPagination(prev => ({ ...prev, total: response.body.total || 0 }));
}
};
}, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery]);
// fetch rate cards data
useEffect(() => {
fetchData('/finance-mock-data/ratecards-data.json', setRatecardsList);
getJobTitles();
}, []);
// get currently selected ratecard
const selectedRatecard = ratecardsList.find(
(ratecard) => ratecard.ratecardId === ratecardId
(ratecard) => ratecard.id === 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]);
if (type === 'update' && ratecardId) {
dispatch(fetchRateCardById(ratecardId));
}
// ...reset logic for create...
}, [type, ratecardId, dispatch]);
useEffect(() => {
if (type === 'update' && drawerRatecard) {
setRoles(drawerRatecard.jobRolesList || []);
setName(drawerRatecard.name || '');
setCurrency(drawerRatecard.currency || cur);
}
}, [drawerRatecard, 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]);
setIsAddingRole(true);
setSelectedJobTitleId(undefined);
};
const handleDeleteRole = (index: number) => {
const updatedRoles = [...roles];
updatedRoles.splice(index, 1);
setRoles(updatedRoles);
};
const handleSelectJobTitle = (jobTitleId: string) => {
const jobTitle = jobTitles.data?.find(jt => jt.id === jobTitleId);
if (jobTitle) {
const newRole = {
jobId: jobTitleId,
jobTitle: jobTitle.name || 'New Role',
ratePerHour: 0,
};
setRoles([...roles, newRole]);
}
setIsAddingRole(false);
setSelectedJobTitleId(undefined);
};
const handleSave = async () => {
if (type === 'update' && ratecardId) {
try {
await dispatch(updateRateCard({
id: ratecardId,
body: {
name,
currency,
jobRolesList: roles,
},
}) as any);
// Refresh the rate cards list in Redux
await dispatch(fetchRateCards({
index: 1,
size: 10,
field: 'name',
order: 'desc',
search: '',
}) as any);
if (onSaved) onSaved();
dispatch(toggleRatecardDrawer());
} catch (error) {
console.error('Failed to update rate card', error);
}
}
};
// table columns
@@ -125,6 +201,17 @@ const RatecardDrawer = ({
/>
),
},
{
title: t('actionsColumn') || 'Actions',
dataIndex: 'actions',
render: (_: any, __: any, index: number) => (
<Button
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteRole(index)}
/>
),
},
];
return (
@@ -132,20 +219,33 @@ const RatecardDrawer = ({
title={
<Flex align="center" justify="space-between">
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{type === 'update'
? selectedRatecard?.ratecardName
: 'Untitled Rate Card'}
<Input
value={name}
placeholder="Enter rate card name"
style={{
fontWeight: 500,
fontSize: 16,
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
}}
onChange={e => {
setName(e.target.value);
}}
/>
</Typography.Text>
<Flex gap={8} align="center">
<Typography.Text>{t('currency')}</Typography.Text>
<Select
defaultValue={'lkr'}
value={currency.toLowerCase()}
options={[
{ value: 'lkr', label: 'LKR' },
{ value: 'usd', label: 'USD' },
{ value: 'inr', label: 'INR' },
]}
onChange={(value) => setCurrency(value.toUpperCase())}
/>
</Flex>
</Flex>
@@ -161,18 +261,39 @@ const RatecardDrawer = ({
rowKey={(record) => record.jobId}
pagination={false}
footer={() => (
<Button
type="dashed"
onClick={handleAddRole}
style={{ width: 'fit-content' }}
>
{t('addRoleButton')}
</Button>
isAddingRole ? (
<Select
showSearch
style={{ minWidth: 200 }}
placeholder={t('selectJobTitle')}
optionFilterProp="children"
value={selectedJobTitleId}
onChange={handleSelectJobTitle}
onBlur={() => setIsAddingRole(false)}
filterOption={(input, option) =>
(option?.children as string).toLowerCase().includes(input.toLowerCase())
}
>
{jobTitles.data?.map((jt) => (
<Select.Option key={jt.id} value={jt.id}>
{jt.name}
</Select.Option>
))}
</Select>
) : (
<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>
<Button onClick={handleSave} type="primary">{t('saveButton')}</Button>
</Flex>
</Drawer>
);

View File

@@ -15,189 +15,207 @@ import {
Tooltip,
Typography,
} from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { colors } from '../../../styles/colors';
import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '../../../hooks/useDoumentTItle';
import { durationDateFormat } from '../../../utils/durationDateFormat';
import { toggleRatecardDrawer } from '../../../features/finance/finance-slice';
import { createRateCard, deleteRateCard, toggleRatecardDrawer } from '../../../features/finance/finance-slice';
import RatecardDrawer from '../../../features/finance/ratecard-drawer/ratecard-drawer';
import { fetchData } from '../../../utils/fetchData';
import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service';
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
import { RatecardType } from '@/types/project/ratecard.types';
import { useAppSelector } from '../../../hooks/useAppSelector';
const RatecardSettings = () => {
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
// get currently selected ratecard id
const [selectedRatecardId, setSelectedRatecardId] = useState<string | null>(
null
);
const [ratecardDrawerType, setRatecardDrawerType] = useState<
'create' | 'update'
>('create');
interface PaginationType {
current: number;
pageSize: number;
field: string;
order: string;
total: number;
pageSizeOptions: string[];
size: 'small' | 'default';
}
// localization
const RatecardSettings: React.FC = () => {
const { t } = useTranslation('/settings/ratecard-settings');
const dispatch = useAppDispatch();
useDocumentTitle('Manage Rate Cards');
// Fetch rate cards data
const [loading, setLoading] = useState(false);
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedRatecardId, setSelectedRatecardId] = useState<string | null>(null);
const [ratecardDrawerType, setRatecardDrawerType] = useState<'create' | 'update'>('create');
const [pagination, setPagination] = useState<PaginationType>({
current: 1,
pageSize: DEFAULT_PAGE_SIZE,
field: 'name',
order: 'desc',
total: 0,
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
size: 'small',
});
const fetchRateCards = useCallback(async () => {
setLoading(true);
try {
const response = await rateCardApiService.getRateCards(
pagination.current,
pagination.pageSize,
pagination.field,
pagination.order,
searchQuery
);
if (response.done) {
setRatecardsList(response.body.data || []);
setPagination(prev => ({ ...prev, total: response.body.total || 0 }));
}
} catch (error) {
console.error('Failed to fetch rate cards:', error);
} finally {
setLoading(false);
}
}, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery]);
useEffect(() => {
fetchData('/finance-mock-data/ratecards-data.json', setRatecardsList);
fetchRateCards();
}, []);
const dispatch = useAppDispatch();
// this is for get the current string that type on search bar
const [searchQuery, setSearchQuery] = useState<string>('');
// used useMemo hook for re render the list when searching
const filteredRatecardsData = useMemo(() => {
return ratecardsList.filter((item) =>
item.ratecardName.toLowerCase().includes(searchQuery.toLowerCase())
item.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [ratecardsList, searchQuery]);
// function to create ratecard
const onRatecardCreate = () => {
setRatecardDrawerType('create');
dispatch(toggleRatecardDrawer());
};
const handleRatecardCreate = useCallback(async () => {
// function to update a ratecard
const onRatecardUpdate = (id: string) => {
const resultAction = await dispatch(createRateCard({
name: 'Untitled Rate Card',
jobRolesList: [],
currency: 'LKR',
}) as any);
if (createRateCard.fulfilled.match(resultAction)) {
const created = resultAction.payload;
setRatecardDrawerType('update');
setSelectedRatecardId(created.id ?? null);
dispatch(toggleRatecardDrawer());
}
}, [dispatch]);
const handleRatecardUpdate = useCallback((id: string) => {
setRatecardDrawerType('update');
setSelectedRatecardId(id);
dispatch(toggleRatecardDrawer());
};
}, [dispatch]);
// table columns
const columns: TableProps['columns'] = [
const handleTableChange = useCallback((newPagination: any, filters: any, sorter: any) => {
setPagination(prev => ({
...prev,
current: newPagination.current,
pageSize: newPagination.pageSize,
field: sorter.field || 'name',
order: sorter.order === 'ascend' ? 'asc' : 'desc',
}));
}, []);
const columns: TableProps['columns'] = useMemo(() => [
{
key: 'rateName',
title: t('nameColumn'),
onCell: (record) => ({
onClick: () => {
setSelectedRatecardId(record.ratecardId);
// dispatch(toggleUpdateRateDrawer());
},
}),
render: (record) => (
<Typography.Text className="group-hover:text-[#1890ff]">
{record.ratecardName}
render: (record: RatecardType) => (
<Typography.Text style={{ color: '#1890ff', cursor: 'pointer' }}
onClick={() => setSelectedRatecardId(record.id ?? null)}>
{record.name}
</Typography.Text>
),
},
{
key: 'created',
title: t('createdColumn'),
onCell: (record) => ({
onClick: () => {
setSelectedRatecardId(record.ratecardId);
// dispatch(toggleUpdateRateDrawer());
},
}),
render: (record) => (
<Typography.Text>
{durationDateFormat(record.createdDate)}
render: (record: RatecardType) => (
<Typography.Text onClick={() => setSelectedRatecardId(record.id ?? null)}>
{durationDateFormat(record.created_at)}
</Typography.Text>
),
},
{
key: 'actionBtns',
width: 80,
render: (record) => (
<Flex
gap={8}
style={{ padding: 0 }}
className="hidden group-hover:block"
>
render: (record: RatecardType) => (
<Flex gap={8} className="hidden group-hover:flex">
<Tooltip title="Edit">
<Button
size="small"
icon={<EditOutlined />}
onClick={() => {
onRatecardUpdate(record.ratecardId);
}}
onClick={() => record.id && handleRatecardUpdate(record.id)}
/>
</Tooltip>
<Popconfirm
title={t('deleteConfirmationTitle')}
icon={
<ExclamationCircleFilled
style={{ color: colors.vibrantOrange }}
/>
}
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')}
// onConfirm={() => dispatch(deleteRatecard(record.ratecardId))}
onConfirm={() => {
setLoading(true);
record.id && dispatch(deleteRateCard(record.id));
setLoading(false);
}}
>
<Tooltip title="Delete">
<Button
shape="default"
icon={<DeleteOutlined />}
size="small"
style={{ marginInlineStart: 8 }}
/>
</Tooltip>
</Popconfirm>
</Flex>
),
},
];
], [t, handleRatecardUpdate]);
return (
<Card
style={{ width: '100%' }}
title={
<Flex justify="flex-end">
<Flex
gap={8}
align="center"
justify="flex-end"
style={{ width: '100%', maxWidth: 400 }}
>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
placeholder={t('searchPlaceholder')}
style={{ maxWidth: 232 }}
suffix={<SearchOutlined />}
/>
<Button type="primary" onClick={onRatecardCreate}>
{t('createRatecard')}
</Button>
</Flex>
<Flex justify="flex-end" align="center" gap={8}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
style={{ maxWidth: 232 }}
suffix={<SearchOutlined />}
/>
<Button type="primary" onClick={handleRatecardCreate}>
{t('createRatecard')}
</Button>
</Flex>
}
>
<Table
loading={loading}
className="custom-two-colors-row-table"
dataSource={filteredRatecardsData}
columns={columns}
rowKey={(record) => record.rateId}
rowKey="id"
pagination={{
...pagination,
showSizeChanger: true,
defaultPageSize: 20,
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
size: 'small',
}}
onRow={(record) => {
return {
className: 'group',
style: {
cursor: 'pointer',
height: 36,
},
};
onChange: (page, pageSize) => setPagination(prev => ({ ...prev, current: page, pageSize })),
}}
onChange={handleTableChange}
rowClassName="group"
/>
{/* rate drawers */}
<RatecardDrawer
type={ratecardDrawerType}
ratecardId={selectedRatecardId || ''}
onSaved={fetchRateCards} // Pass the fetch function as a prop
/>
</Card>
);

View File

@@ -1,4 +1,4 @@
export type JobType = {
export interface IJobType {
jobId: string;
jobTitle: string;
ratePerHour?: number;

View File

@@ -1,14 +1,22 @@
import { JobType } from "./job.types";
export interface JobRoleType extends JobType {
export interface IJobType {
jobId: string;
jobTitle: string;
ratePerHour?: number;
};
export interface JobRoleType extends IJobType {
members: string[] | null;
}
export type RatecardType = {
ratecardId: string;
ratecardName: string;
jobRolesList: JobType[];
createdDate: Date;
export interface RatecardType {
id?: string;
created_at?: string;
name?: string;
jobRolesList?: IJobType[];
currency?: string;
};
export interface IRatecardViewModel {
total?: number;
data?: RatecardType[];
}