diff --git a/worklenz-frontend/src/api/settings/rate-cards/rate-cards.api.service.ts b/worklenz-frontend/src/api/settings/rate-cards/rate-cards.api.service.ts index dc313f29..6007474f 100644 --- a/worklenz-frontend/src/api/settings/rate-cards/rate-cards.api.service.ts +++ b/worklenz-frontend/src/api/settings/rate-cards/rate-cards.api.service.ts @@ -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> { - const response = await apiClient.get>(`${rootUrl}/${id}`); + async getRateCardById(id: string): Promise> { + const response = await apiClient.get>(`${rootUrl}/${id}`); return response.data; }, - async createRateCard(body: IRatecard): Promise> { - const response = await apiClient.post>(rootUrl, body); + async createRateCard(body: RatecardType): Promise> { + const response = await apiClient.post>(rootUrl, body); return response.data; }, - async updateRateCard(id: string, body: IRatecard): Promise> { - const response = await apiClient.put>(`${rootUrl}/${id}`, body); + async updateRateCard(id: string, body: RatecardType): Promise> { + const response = await apiClient.put>(`${rootUrl}/${id}`, body); return response.data; }, diff --git a/worklenz-frontend/src/features/finance/finance-slice.ts b/worklenz-frontend/src/features/finance/finance-slice.ts index 9a2bce12..3cc011f0 100644 --- a/worklenz-frontend/src/features/finance/finance-slice.ts +++ b/worklenz-frontend/src/features/finance/finance-slice.ts @@ -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) => { state.currency = action.payload; }, + ratecardDrawerLoading: (state, action: PayloadAction) => { + 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; diff --git a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx index 10a8dc5d..f069453a 100644 --- a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx @@ -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([]); // initial Job Roles List (dummy data) - const [roles, setRoles] = useState([]); + const [roles, setRoles] = useState([]); - // 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(undefined); + const [searchQuery, setSearchQuery] = useState(''); + const [currency, setCurrency] = useState(cur); + const [name, setName] = useState('Untitled Rate Card'); + const [jobTitles, setJobTitles] = useState({}); + const [pagination, setPagination] = useState({ + 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) => ( + + ) )} /> - + ); diff --git a/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx b/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx index 7bdf508e..b5421083 100644 --- a/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx +++ b/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx @@ -15,192 +15,210 @@ 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([]); - // get currently selected ratecard id - const [selectedRatecardId, setSelectedRatecardId] = useState( - 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([]); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedRatecardId, setSelectedRatecardId] = useState(null); + const [ratecardDrawerType, setRatecardDrawerType] = useState<'create' | 'update'>('create'); + const [pagination, setPagination] = useState({ + 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(''); - - // 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) => ( - - {record.ratecardName} + render: (record: RatecardType) => ( + setSelectedRatecardId(record.id ?? null)}> + {record.name} ), }, { key: 'created', title: t('createdColumn'), - onCell: (record) => ({ - onClick: () => { - setSelectedRatecardId(record.ratecardId); - // dispatch(toggleUpdateRateDrawer()); - }, - }), - render: (record) => ( - - {durationDateFormat(record.createdDate)} + render: (record: RatecardType) => ( + setSelectedRatecardId(record.id ?? null)}> + {durationDateFormat(record.created_at)} ), }, { key: 'actionBtns', width: 80, - render: (record) => ( - + render: (record: RatecardType) => ( + - + + setSearchQuery(e.target.value)} + placeholder={t('searchPlaceholder')} + style={{ maxWidth: 232 }} + suffix={} + /> + } > 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 */} ); }; -export default RatecardSettings; +export default RatecardSettings; \ No newline at end of file diff --git a/worklenz-frontend/src/types/project/job.types.ts b/worklenz-frontend/src/types/project/job.types.ts index d0739371..b2f2ba6e 100644 --- a/worklenz-frontend/src/types/project/job.types.ts +++ b/worklenz-frontend/src/types/project/job.types.ts @@ -1,4 +1,4 @@ -export type JobType = { +export interface IJobType { jobId: string; jobTitle: string; ratePerHour?: number; diff --git a/worklenz-frontend/src/types/project/ratecard.types.ts b/worklenz-frontend/src/types/project/ratecard.types.ts index 8afb7e44..b53403fd 100644 --- a/worklenz-frontend/src/types/project/ratecard.types.ts +++ b/worklenz-frontend/src/types/project/ratecard.types.ts @@ -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[]; +}