feat(ratecard): enhance rate card management with CRUD operations and improved type definitions
This commit is contained in:
@@ -3,11 +3,10 @@ import { API_BASE_URL } from '@/shared/constants';
|
|||||||
import { IServerResponse } from '@/types/common.types';
|
import { IServerResponse } from '@/types/common.types';
|
||||||
import { IJobTitle, IJobTitlesViewModel } from '@/types/job.types';
|
import { IJobTitle, IJobTitlesViewModel } from '@/types/job.types';
|
||||||
import { toQueryString } from '@/utils/toQueryString';
|
import { toQueryString } from '@/utils/toQueryString';
|
||||||
|
import { RatecardType, IRatecardViewModel } from '@/types/project/ratecard.types';
|
||||||
|
|
||||||
type IRatecard = {
|
type IRatecard = {
|
||||||
id: string;}
|
id: string;}
|
||||||
type IRatecardViewModel = {
|
|
||||||
id: string;}
|
|
||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/rate-cards`;
|
const rootUrl = `${API_BASE_URL}/rate-cards`;
|
||||||
|
|
||||||
@@ -26,18 +25,18 @@ export const rateCardApiService = {
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
async getRateCardById(id: string): Promise<IServerResponse<IRatecard>> {
|
async getRateCardById(id: string): Promise<IServerResponse<RatecardType>> {
|
||||||
const response = await apiClient.get<IServerResponse<IRatecard>>(`${rootUrl}/${id}`);
|
const response = await apiClient.get<IServerResponse<RatecardType>>(`${rootUrl}/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createRateCard(body: IRatecard): Promise<IServerResponse<IRatecard>> {
|
async createRateCard(body: RatecardType): Promise<IServerResponse<RatecardType>> {
|
||||||
const response = await apiClient.post<IServerResponse<IRatecard>>(rootUrl, body);
|
const response = await apiClient.post<IServerResponse<RatecardType>>(rootUrl, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateRateCard(id: string, body: IRatecard): Promise<IServerResponse<IRatecard>> {
|
async updateRateCard(id: string, body: RatecardType): Promise<IServerResponse<RatecardType>> {
|
||||||
const response = await apiClient.put<IServerResponse<IRatecard>>(`${rootUrl}/${id}`, body);
|
const response = await apiClient.put<IServerResponse<RatecardType>>(`${rootUrl}/${id}`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
type financeState = {
|
||||||
isRatecardDrawerOpen: boolean;
|
isRatecardDrawerOpen: boolean;
|
||||||
isFinanceDrawerOpen: boolean;
|
isFinanceDrawerOpen: boolean;
|
||||||
isImportRatecardsDrawerOpen: boolean;
|
isImportRatecardsDrawerOpen: boolean;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
isFinanceDrawerloading?: boolean;
|
||||||
|
drawerRatecard?: RatecardType | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState: financeState = {
|
const initialState: financeState = {
|
||||||
@@ -12,7 +17,102 @@ const initialState: financeState = {
|
|||||||
isFinanceDrawerOpen: false,
|
isFinanceDrawerOpen: false,
|
||||||
isImportRatecardsDrawerOpen: false,
|
isImportRatecardsDrawerOpen: false,
|
||||||
currency: 'LKR',
|
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({
|
const financeSlice = createSlice({
|
||||||
name: 'financeReducer',
|
name: 'financeReducer',
|
||||||
@@ -30,6 +130,28 @@ const financeSlice = createSlice({
|
|||||||
changeCurrency: (state, action: PayloadAction<string>) => {
|
changeCurrency: (state, action: PayloadAction<string>) => {
|
||||||
state.currency = action.payload;
|
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,
|
toggleFinanceDrawer,
|
||||||
toggleImportRatecardsDrawer,
|
toggleImportRatecardsDrawer,
|
||||||
changeCurrency,
|
changeCurrency,
|
||||||
|
ratecardDrawerLoading,
|
||||||
} = financeSlice.actions;
|
} = financeSlice.actions;
|
||||||
export default financeSlice.reducer;
|
export default financeSlice.reducer;
|
||||||
|
|||||||
@@ -1,83 +1,159 @@
|
|||||||
import { Drawer, Select, Typography, Flex, Button, Input, Table } from 'antd';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useAppSelector } from '../../../hooks/useAppSelector';
|
import { useAppSelector } from '../../../hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
||||||
import { fetchData } from '../../../utils/fetchData';
|
import { fetchData } from '../../../utils/fetchData';
|
||||||
import { toggleRatecardDrawer } from '../finance-slice';
|
import { fetchRateCardById, fetchRateCards, toggleRatecardDrawer, updateRateCard } from '../finance-slice';
|
||||||
import { RatecardType } from '@/types/project/ratecard.types';
|
import { RatecardType, IJobType } from '@/types/project/ratecard.types';
|
||||||
import { JobType } from '@/types/project/job.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 = ({
|
const RatecardDrawer = ({
|
||||||
type,
|
type,
|
||||||
ratecardId,
|
ratecardId,
|
||||||
|
onSaved,
|
||||||
}: {
|
}: {
|
||||||
type: 'create' | 'update';
|
type: 'create' | 'update';
|
||||||
ratecardId: string;
|
ratecardId: string;
|
||||||
|
onSaved?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
|
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
|
||||||
// initial Job Roles List (dummy data)
|
// initial Job Roles List (dummy data)
|
||||||
const [roles, setRoles] = useState<JobType[]>([]);
|
const [roles, setRoles] = useState<IJobType[]>([]);
|
||||||
|
|
||||||
// localization
|
|
||||||
const { t } = useTranslation('settings/ratecard-settings');
|
const { t } = useTranslation('settings/ratecard-settings');
|
||||||
|
|
||||||
// get drawer state from client reducer
|
// get drawer state from client reducer
|
||||||
|
const drawerLoading = useAppSelector(state => state.financeReducer.drawerLoading);
|
||||||
|
const drawerRatecard = useAppSelector(state => state.financeReducer.drawerRatecard);
|
||||||
const isDrawerOpen = useAppSelector(
|
const isDrawerOpen = useAppSelector(
|
||||||
(state) => state.financeReducer.isRatecardDrawerOpen
|
(state) => state.financeReducer.isRatecardDrawerOpen
|
||||||
);
|
);
|
||||||
|
// get currently using currency from finance reducer
|
||||||
|
const cur = useAppSelector(
|
||||||
|
(state) => state.financeReducer.currency
|
||||||
|
).toUpperCase();
|
||||||
const dispatch = useAppDispatch();
|
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
|
// fetch rate cards data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData('/finance-mock-data/ratecards-data.json', setRatecardsList);
|
getJobTitles();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// get currently selected ratecard
|
// get currently selected ratecard
|
||||||
const selectedRatecard = ratecardsList.find(
|
const selectedRatecard = ratecardsList.find(
|
||||||
(ratecard) => ratecard.ratecardId === ratecardId
|
(ratecard) => ratecard.id === ratecardId
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
type === 'update'
|
if (type === 'update' && ratecardId) {
|
||||||
? setRoles(selectedRatecard?.jobRolesList || [])
|
dispatch(fetchRateCardById(ratecardId));
|
||||||
: setRoles([
|
}
|
||||||
{
|
// ...reset logic for create...
|
||||||
jobId: 'J001',
|
}, [type, ratecardId, dispatch]);
|
||||||
jobTitle: 'Project Manager',
|
|
||||||
ratePerHour: 50,
|
useEffect(() => {
|
||||||
},
|
if (type === 'update' && drawerRatecard) {
|
||||||
{
|
setRoles(drawerRatecard.jobRolesList || []);
|
||||||
jobId: 'J002',
|
setName(drawerRatecard.name || '');
|
||||||
jobTitle: 'Senior Software Engineer',
|
setCurrency(drawerRatecard.currency || cur);
|
||||||
ratePerHour: 40,
|
}
|
||||||
},
|
}, [drawerRatecard, type]);
|
||||||
{
|
|
||||||
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
|
// add new job role handler
|
||||||
const handleAddRole = () => {
|
const handleAddRole = () => {
|
||||||
const newRole = {
|
setIsAddingRole(true);
|
||||||
jobId: `J00${roles.length + 1}`,
|
setSelectedJobTitleId(undefined);
|
||||||
jobTitle: 'New Role',
|
};
|
||||||
ratePerHour: 0,
|
const handleDeleteRole = (index: number) => {
|
||||||
};
|
const updatedRoles = [...roles];
|
||||||
setRoles([...roles, newRole]);
|
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
|
// 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 (
|
return (
|
||||||
@@ -132,20 +219,33 @@ const RatecardDrawer = ({
|
|||||||
title={
|
title={
|
||||||
<Flex align="center" justify="space-between">
|
<Flex align="center" justify="space-between">
|
||||||
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||||
{type === 'update'
|
<Input
|
||||||
? selectedRatecard?.ratecardName
|
value={name}
|
||||||
: 'Untitled Rate Card'}
|
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>
|
</Typography.Text>
|
||||||
|
|
||||||
<Flex gap={8} align="center">
|
<Flex gap={8} align="center">
|
||||||
<Typography.Text>{t('currency')}</Typography.Text>
|
<Typography.Text>{t('currency')}</Typography.Text>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={'lkr'}
|
value={currency.toLowerCase()}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'lkr', label: 'LKR' },
|
{ value: 'lkr', label: 'LKR' },
|
||||||
{ value: 'usd', label: 'USD' },
|
{ value: 'usd', label: 'USD' },
|
||||||
{ value: 'inr', label: 'INR' },
|
{ value: 'inr', label: 'INR' },
|
||||||
]}
|
]}
|
||||||
|
onChange={(value) => setCurrency(value.toUpperCase())}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -161,18 +261,39 @@ const RatecardDrawer = ({
|
|||||||
rowKey={(record) => record.jobId}
|
rowKey={(record) => record.jobId}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
footer={() => (
|
footer={() => (
|
||||||
<Button
|
isAddingRole ? (
|
||||||
type="dashed"
|
<Select
|
||||||
onClick={handleAddRole}
|
showSearch
|
||||||
style={{ width: 'fit-content' }}
|
style={{ minWidth: 200 }}
|
||||||
>
|
placeholder={t('selectJobTitle')}
|
||||||
{t('addRoleButton')}
|
optionFilterProp="children"
|
||||||
</Button>
|
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 }}>
|
<Flex justify="end" gap={16} style={{ marginTop: 16 }}>
|
||||||
<Button type="primary">{t('saveButton')}</Button>
|
<Button onClick={handleSave} type="primary">{t('saveButton')}</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,189 +15,207 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||||
import { colors } from '../../../styles/colors';
|
import { colors } from '../../../styles/colors';
|
||||||
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDocumentTitle } from '../../../hooks/useDoumentTItle';
|
import { useDocumentTitle } from '../../../hooks/useDoumentTItle';
|
||||||
import { durationDateFormat } from '../../../utils/durationDateFormat';
|
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 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 { RatecardType } from '@/types/project/ratecard.types';
|
||||||
|
import { useAppSelector } from '../../../hooks/useAppSelector';
|
||||||
|
|
||||||
const RatecardSettings = () => {
|
interface PaginationType {
|
||||||
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
|
current: number;
|
||||||
// get currently selected ratecard id
|
pageSize: number;
|
||||||
const [selectedRatecardId, setSelectedRatecardId] = useState<string | null>(
|
field: string;
|
||||||
null
|
order: string;
|
||||||
);
|
total: number;
|
||||||
const [ratecardDrawerType, setRatecardDrawerType] = useState<
|
pageSizeOptions: string[];
|
||||||
'create' | 'update'
|
size: 'small' | 'default';
|
||||||
>('create');
|
}
|
||||||
|
|
||||||
// localization
|
const RatecardSettings: React.FC = () => {
|
||||||
const { t } = useTranslation('/settings/ratecard-settings');
|
const { t } = useTranslation('/settings/ratecard-settings');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
useDocumentTitle('Manage Rate Cards');
|
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(() => {
|
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(() => {
|
const filteredRatecardsData = useMemo(() => {
|
||||||
return ratecardsList.filter((item) =>
|
return ratecardsList.filter((item) =>
|
||||||
item.ratecardName.toLowerCase().includes(searchQuery.toLowerCase())
|
item.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
}, [ratecardsList, searchQuery]);
|
}, [ratecardsList, searchQuery]);
|
||||||
|
|
||||||
// function to create ratecard
|
const handleRatecardCreate = useCallback(async () => {
|
||||||
const onRatecardCreate = () => {
|
|
||||||
setRatecardDrawerType('create');
|
|
||||||
dispatch(toggleRatecardDrawer());
|
|
||||||
};
|
|
||||||
|
|
||||||
// function to update a ratecard
|
const resultAction = await dispatch(createRateCard({
|
||||||
const onRatecardUpdate = (id: string) => {
|
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');
|
setRatecardDrawerType('update');
|
||||||
setSelectedRatecardId(id);
|
setSelectedRatecardId(id);
|
||||||
dispatch(toggleRatecardDrawer());
|
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',
|
key: 'rateName',
|
||||||
title: t('nameColumn'),
|
title: t('nameColumn'),
|
||||||
onCell: (record) => ({
|
render: (record: RatecardType) => (
|
||||||
onClick: () => {
|
<Typography.Text style={{ color: '#1890ff', cursor: 'pointer' }}
|
||||||
setSelectedRatecardId(record.ratecardId);
|
onClick={() => setSelectedRatecardId(record.id ?? null)}>
|
||||||
// dispatch(toggleUpdateRateDrawer());
|
{record.name}
|
||||||
},
|
|
||||||
}),
|
|
||||||
render: (record) => (
|
|
||||||
<Typography.Text className="group-hover:text-[#1890ff]">
|
|
||||||
{record.ratecardName}
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'created',
|
key: 'created',
|
||||||
title: t('createdColumn'),
|
title: t('createdColumn'),
|
||||||
onCell: (record) => ({
|
render: (record: RatecardType) => (
|
||||||
onClick: () => {
|
<Typography.Text onClick={() => setSelectedRatecardId(record.id ?? null)}>
|
||||||
setSelectedRatecardId(record.ratecardId);
|
{durationDateFormat(record.created_at)}
|
||||||
// dispatch(toggleUpdateRateDrawer());
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
render: (record) => (
|
|
||||||
<Typography.Text>
|
|
||||||
{durationDateFormat(record.createdDate)}
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actionBtns',
|
key: 'actionBtns',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (record) => (
|
render: (record: RatecardType) => (
|
||||||
<Flex
|
<Flex gap={8} className="hidden group-hover:flex">
|
||||||
gap={8}
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
className="hidden group-hover:block"
|
|
||||||
>
|
|
||||||
<Tooltip title="Edit">
|
<Tooltip title="Edit">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<EditOutlined />}
|
icon={<EditOutlined />}
|
||||||
onClick={() => {
|
onClick={() => record.id && handleRatecardUpdate(record.id)}
|
||||||
onRatecardUpdate(record.ratecardId);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t('deleteConfirmationTitle')}
|
title={t('deleteConfirmationTitle')}
|
||||||
icon={
|
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||||
<ExclamationCircleFilled
|
|
||||||
style={{ color: colors.vibrantOrange }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
okText={t('deleteConfirmationOk')}
|
okText={t('deleteConfirmationOk')}
|
||||||
cancelText={t('deleteConfirmationCancel')}
|
cancelText={t('deleteConfirmationCancel')}
|
||||||
// onConfirm={() => dispatch(deleteRatecard(record.ratecardId))}
|
onConfirm={() => {
|
||||||
|
setLoading(true);
|
||||||
|
record.id && dispatch(deleteRateCard(record.id));
|
||||||
|
setLoading(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip title="Delete">
|
<Tooltip title="Delete">
|
||||||
<Button
|
<Button
|
||||||
shape="default"
|
shape="default"
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ marginInlineStart: 8 }}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Flex>
|
</Flex>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
], [t, handleRatecardUpdate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
title={
|
title={
|
||||||
<Flex justify="flex-end">
|
<Flex justify="flex-end" align="center" gap={8}>
|
||||||
<Flex
|
<Input
|
||||||
gap={8}
|
value={searchQuery}
|
||||||
align="center"
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
justify="flex-end"
|
placeholder={t('searchPlaceholder')}
|
||||||
style={{ width: '100%', maxWidth: 400 }}
|
style={{ maxWidth: 232 }}
|
||||||
>
|
suffix={<SearchOutlined />}
|
||||||
<Input
|
/>
|
||||||
value={searchQuery}
|
<Button type="primary" onClick={handleRatecardCreate}>
|
||||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
{t('createRatecard')}
|
||||||
placeholder={t('searchPlaceholder')}
|
</Button>
|
||||||
style={{ maxWidth: 232 }}
|
|
||||||
suffix={<SearchOutlined />}
|
|
||||||
/>
|
|
||||||
<Button type="primary" onClick={onRatecardCreate}>
|
|
||||||
{t('createRatecard')}
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table
|
<Table
|
||||||
|
loading={loading}
|
||||||
className="custom-two-colors-row-table"
|
className="custom-two-colors-row-table"
|
||||||
dataSource={filteredRatecardsData}
|
dataSource={filteredRatecardsData}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(record) => record.rateId}
|
rowKey="id"
|
||||||
pagination={{
|
pagination={{
|
||||||
|
...pagination,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
defaultPageSize: 20,
|
onChange: (page, pageSize) => setPagination(prev => ({ ...prev, current: page, pageSize })),
|
||||||
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
|
|
||||||
size: 'small',
|
|
||||||
}}
|
|
||||||
onRow={(record) => {
|
|
||||||
return {
|
|
||||||
className: 'group',
|
|
||||||
style: {
|
|
||||||
cursor: 'pointer',
|
|
||||||
height: 36,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}}
|
}}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
rowClassName="group"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* rate drawers */}
|
|
||||||
<RatecardDrawer
|
<RatecardDrawer
|
||||||
type={ratecardDrawerType}
|
type={ratecardDrawerType}
|
||||||
ratecardId={selectedRatecardId || ''}
|
ratecardId={selectedRatecardId || ''}
|
||||||
|
onSaved={fetchRateCards} // Pass the fetch function as a prop
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type JobType = {
|
export interface IJobType {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
jobTitle: string;
|
jobTitle: string;
|
||||||
ratePerHour?: number;
|
ratePerHour?: number;
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { JobType } from "./job.types";
|
|
||||||
|
|
||||||
|
export interface IJobType {
|
||||||
export interface JobRoleType extends JobType {
|
jobId: string;
|
||||||
|
jobTitle: string;
|
||||||
|
ratePerHour?: number;
|
||||||
|
};
|
||||||
|
export interface JobRoleType extends IJobType {
|
||||||
members: string[] | null;
|
members: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RatecardType = {
|
export interface RatecardType {
|
||||||
ratecardId: string;
|
id?: string;
|
||||||
ratecardName: string;
|
created_at?: string;
|
||||||
jobRolesList: JobType[];
|
name?: string;
|
||||||
createdDate: Date;
|
jobRolesList?: IJobType[];
|
||||||
currency?: string;
|
currency?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface IRatecardViewModel {
|
||||||
|
total?: number;
|
||||||
|
data?: RatecardType[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user