From c3bec7489774461aea59566d20d9c58ebf1e24cc Mon Sep 17 00:00:00 2001 From: shancds Date: Wed, 21 May 2025 17:16:35 +0530 Subject: [PATCH] feat(project-ratecard): implement project rate card management with CRUD operations and integrate into frontend --- .../project-ratecard-controller.ts | 121 +++++++++ worklenz-backend/src/routes/apis/index.ts | 2 + .../apis/project-ratecard-api-router.ts | 55 ++++ .../project-finance-rate-cards.api.service.ts | 60 +++++ worklenz-frontend/src/app/store.ts | 2 + .../src/features/finance/finance-slice.ts | 20 +- .../features/finance/project-finance-slice.ts | 247 ++++++++++++++++++ .../import-ratecards-drawer.tsx | 158 +++++++---- .../reatecard-table/ratecard-table.tsx | 127 ++++----- worklenz-frontend/src/types/common.types.ts | 1 + .../src/types/project/ratecard.types.ts | 22 +- 11 files changed, 671 insertions(+), 144 deletions(-) create mode 100644 worklenz-backend/src/controllers/project-ratecard-controller.ts create mode 100644 worklenz-backend/src/routes/apis/project-ratecard-api-router.ts create mode 100644 worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts create mode 100644 worklenz-frontend/src/features/finance/project-finance-slice.ts diff --git a/worklenz-backend/src/controllers/project-ratecard-controller.ts b/worklenz-backend/src/controllers/project-ratecard-controller.ts new file mode 100644 index 00000000..c27ca765 --- /dev/null +++ b/worklenz-backend/src/controllers/project-ratecard-controller.ts @@ -0,0 +1,121 @@ +import db from "../config/db"; +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; +import { ServerResponse } from "../models/server-response"; +import HandleExceptions from "../decorators/handle-exceptions"; +import WorklenzControllerBase from "./worklenz-controller-base"; + +export default class ProjectRateCardController extends WorklenzControllerBase { + // Insert multiple roles for a project + @HandleExceptions() + public static async insertMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id, roles } = req.body; + if (!Array.isArray(roles) || !project_id) { + return res.status(400).send(new ServerResponse(false, null, "Invalid input")); + } + const values = roles.map((role: any) => [ + project_id, + role.job_title_id, + role.rate + ]); + const q = ` + INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate) + VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")} + ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate + RETURNING *; + `; + const flatValues = values.flat(); + const result = await db.query(q, flatValues); + return res.status(200).send(new ServerResponse(true, result.rows)); + } + + // Get all roles for a project + @HandleExceptions() + public static async getFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id } = req.params; + const q = ` + SELECT fprr.*, jt.name as jobtitle + FROM finance_project_rate_card_roles fprr + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE fprr.project_id = $1 + ORDER BY jt.name; + `; + const result = await db.query(q, [project_id]); + return res.status(200).send(new ServerResponse(true, result.rows)); + } + + // Get a single role by id + @HandleExceptions() + public static async getFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { id } = req.params; + const q = ` + SELECT fprr.*, jt.name as jobtitle + FROM finance_project_rate_card_roles fprr + LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id + WHERE fprr.id = $1; + `; + const result = await db.query(q, [id]); + return res.status(200).send(new ServerResponse(true, result.rows[0])); + } + + // Update a single role by id + @HandleExceptions() + public static async updateFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { id } = req.params; + const { job_title_id, rate } = req.body; + const q = ` + UPDATE finance_project_rate_card_roles + SET job_title_id = $1, rate = $2, updated_at = NOW() + WHERE id = $3 + RETURNING *; + `; + const result = await db.query(q, [job_title_id, rate, id]); + return res.status(200).send(new ServerResponse(true, result.rows[0])); + } + + // Update all roles for a project (delete then insert) + @HandleExceptions() + public static async updateFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id, roles } = req.body; + if (!Array.isArray(roles) || !project_id) { + return res.status(400).send(new ServerResponse(false, null, "Invalid input")); + } + // Delete existing + await db.query(`DELETE FROM finance_project_rate_card_roles WHERE project_id = $1`, [project_id]); + // Insert new + if (roles.length === 0) { + return res.status(200).send(new ServerResponse(true, [])); + } + const values = roles.map((role: any) => [ + project_id, + role.job_title_id, + role.rate + ]); + const q = ` + INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate) + VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")} + RETURNING *; + `; + const flatValues = values.flat(); + const result = await db.query(q, flatValues); + return res.status(200).send(new ServerResponse(true, result.rows)); + } + + // Delete a single role by id + @HandleExceptions() + public static async deleteFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { id } = req.params; + const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`; + const result = await db.query(q, [id]); + return res.status(200).send(new ServerResponse(true, result.rows[0])); + } + + // Delete all roles for a project + @HandleExceptions() + public static async deleteFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id } = req.params; + const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`; + const result = await db.query(q, [project_id]); + return res.status(200).send(new ServerResponse(true, result.rows)); + } +} diff --git a/worklenz-backend/src/routes/apis/index.ts b/worklenz-backend/src/routes/apis/index.ts index 3a1df537..bfb52979 100644 --- a/worklenz-backend/src/routes/apis/index.ts +++ b/worklenz-backend/src/routes/apis/index.ts @@ -59,6 +59,7 @@ import taskRecurringApiRouter from "./task-recurring-api-router"; import customColumnsApiRouter from "./custom-columns-api-router"; import ratecardApiRouter from "./ratecard-api-router"; +import projectRatecardApiRouter from "./project-ratecard-api-router"; const api = express.Router(); @@ -67,6 +68,7 @@ api.use("/team-members", teamMembersApiRouter); api.use("/job-titles", jobTitlesApiRouter); api.use("/clients", clientsApiRouter); api.use("/rate-cards", ratecardApiRouter); +api.use("/project-rate-cards", projectRatecardApiRouter); api.use("/teams", teamsApiRouter); api.use("/tasks", tasksApiRouter); api.use("/settings", settingsApiRouter); diff --git a/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts b/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts new file mode 100644 index 00000000..d056e368 --- /dev/null +++ b/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts @@ -0,0 +1,55 @@ +import express from "express"; +import ProjectRateCardController from "../../controllers/project-ratecard-controller"; +import idParamValidator from "../../middlewares/validators/id-param-validator"; +import safeControllerFunction from "../../shared/safe-controller-function"; +import projectManagerValidator from "../../middlewares/validators/project-manager-validator"; + +const projectRatecardApiRouter = express.Router(); + +// Insert multiple roles for a project +projectRatecardApiRouter.post( + "/", + projectManagerValidator, + safeControllerFunction(ProjectRateCardController.insertMany) +); + +// Get all roles for a project +projectRatecardApiRouter.get( + "/project/:project_id", + safeControllerFunction(ProjectRateCardController.getFromProjectId) +); + +// Get a single role by id +projectRatecardApiRouter.get( + "/:id", + idParamValidator, + safeControllerFunction(ProjectRateCardController.getFromId) +); + +// Update a single role by id +projectRatecardApiRouter.put( + "/:id", + idParamValidator, + safeControllerFunction(ProjectRateCardController.updateFromId) +); + +// Update all roles for a project (delete then insert) +projectRatecardApiRouter.put( + "/project/:project_id", + safeControllerFunction(ProjectRateCardController.updateFromProjectId) +); + +// Delete a single role by id +projectRatecardApiRouter.delete( + "/:id", + idParamValidator, + safeControllerFunction(ProjectRateCardController.deleteFromId) +); + +// Delete all roles for a project +projectRatecardApiRouter.delete( + "/project/:project_id", + safeControllerFunction(ProjectRateCardController.deleteFromProjectId) +); + +export default projectRatecardApiRouter; diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts new file mode 100644 index 00000000..a4ba6b5e --- /dev/null +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts @@ -0,0 +1,60 @@ +import apiClient from '@api/api-client'; +import { API_BASE_URL } from '@/shared/constants'; +import { IServerResponse } from '@/types/common.types'; +import { IJobType } from '@/types/project/ratecard.types'; + +const rootUrl = `${API_BASE_URL}/project-rate-cards`; + +export interface IProjectRateCardRole { + id?: string; + project_id: string; + job_title_id: string; + jobtitle?: string; + rate: number; + data?: object; + roles?: IJobType[]; +} + +export const projectRateCardApiService = { + // Insert multiple roles for a project + async insertMany(project_id: string, roles: Omit[]): Promise> { + const response = await apiClient.post>(rootUrl, { project_id, roles }); + return response.data; + }, + + // Get all roles for a project + async getFromProjectId(project_id: string): Promise> { + const response = await apiClient.get>(`${rootUrl}/project/${project_id}`); + return response.data; + }, + + // Get a single role by id + async getFromId(id: string): Promise> { + const response = await apiClient.get>(`${rootUrl}/${id}`); + return response.data; + }, + + // Update a single role by id + async updateFromId(id: string, body: { job_title_id: string; rate: string }): Promise> { + const response = await apiClient.put>(`${rootUrl}/${id}`, body); + return response.data; + }, + + // Update all roles for a project (delete then insert) + async updateFromProjectId(project_id: string, roles: Omit[]): Promise> { + const response = await apiClient.put>(`${rootUrl}/project/${project_id}`, { project_id, roles }); + return response.data; + }, + + // Delete a single role by id + async deleteFromId(id: string): Promise> { + const response = await apiClient.delete>(`${rootUrl}/${id}`); + return response.data; + }, + + // Delete all roles for a project + async deleteFromProjectId(project_id: string): Promise> { + const response = await apiClient.delete>(`${rootUrl}/project/${project_id}`); + return response.data; + }, +}; \ No newline at end of file diff --git a/worklenz-frontend/src/app/store.ts b/worklenz-frontend/src/app/store.ts index 2a34813a..f9f13429 100644 --- a/worklenz-frontend/src/app/store.ts +++ b/worklenz-frontend/src/app/store.ts @@ -72,6 +72,7 @@ import timeReportsOverviewReducer from '@features/reporting/time-reports/time-re import financeReducer from '../features/finance/finance-slice'; import roadmapReducer from '../features/roadmap/roadmap-slice'; import teamMembersReducer from '@features/team-members/team-members.slice'; +import projectFinanceRateCardReducer from '../features/finance/project-finance-slice'; import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice'; import homePageApiService from '@/api/home-page/home-page.api.service'; import { projectsApi } from '@/api/projects/projects.v1.api.service'; @@ -156,6 +157,7 @@ export const store = configureStore({ groupByFilterDropdownReducer: groupByFilterDropdownReducer, timeReportsOverviewReducer: timeReportsOverviewReducer, financeReducer: financeReducer, + projectFinanceRateCard: projectFinanceRateCardReducer, }, }); diff --git a/worklenz-frontend/src/features/finance/finance-slice.ts b/worklenz-frontend/src/features/finance/finance-slice.ts index d07c7f58..e679562b 100644 --- a/worklenz-frontend/src/features/finance/finance-slice.ts +++ b/worklenz-frontend/src/features/finance/finance-slice.ts @@ -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; diff --git a/worklenz-frontend/src/features/finance/project-finance-slice.ts b/worklenz-frontend/src/features/finance/project-finance-slice.ts new file mode 100644 index 00000000..a2986872 --- /dev/null +++ b/worklenz-frontend/src/features/finance/project-finance-slice.ts @@ -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[] }, { 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[] }, { 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; \ No newline at end of file diff --git a/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx b/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx index c5888fb4..4a3ebd08 100644 --- a/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx @@ -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([]); - const [selectedRatecardId, setSelectedRatecardId] = useState( - 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(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) => ( {text} @@ -50,7 +69,7 @@ const ImportRatecardsDrawer: React.FC = () => { }, { title: `${t('ratePerHourColumn')} (${currency})`, - dataIndex: 'ratePerHour', + dataIndex: 'rate', render: (text: number) => {text}, }, ]; @@ -64,7 +83,31 @@ const ImportRatecardsDrawer: React.FC = () => { } footer={
- +
} open={isDrawerOpen} @@ -72,43 +115,44 @@ const ImportRatecardsDrawer: React.FC = () => { width={1000} > - {/* sidebar menu */} - setSelectedRatecardId(key)} - > - {ratecardsList.map((ratecard) => ( - - {ratecard.ratecardName} - - ))} - + {/* Sidebar menu with loading */} + + setSelectedRatecardId(key)} + > + {ratecardsList.map((ratecard) => ( + + {ratecard.name} + + ))} + + - {/* table for job roles */} + {/* Table for job roles with loading */} 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} /> ); }; -export default ImportRatecardsDrawer; +export default ImportRatecardsDrawer; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx index 85d73b25..141208c0 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx @@ -1,102 +1,50 @@ -import { Avatar, Button, Input, Table, TableProps } from 'antd'; -import React, { useState } from 'react'; +import { Avatar, Button, Input, Popconfirm, Table, TableProps } from 'antd'; +import React, { useEffect } from 'react'; import CustomAvatar from '../../../../../../components/CustomAvatar'; -import { PlusOutlined } from '@ant-design/icons'; +import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import { useAppSelector } from '../../../../../../hooks/useAppSelector'; +import { useAppDispatch } from '../../../../../../hooks/useAppDispatch'; import { useTranslation } from 'react-i18next'; import { JobRoleType } from '@/types/project/ratecard.types'; - -const initialJobRolesList: JobRoleType[] = [ - { - jobId: 'J001', - jobTitle: 'Project Manager', - ratePerHour: 50, - members: ['Alice Johnson', 'Bob Smith'], - }, - { - jobId: 'J002', - jobTitle: 'Senior Software Engineer', - ratePerHour: 40, - members: ['Charlie Brown', 'Diana Prince'], - }, - { - jobId: 'J003', - jobTitle: 'Junior Software Engineer', - ratePerHour: 25, - members: ['Eve Davis', 'Frank Castle'], - }, - { - jobId: 'J004', - jobTitle: 'UI/UX Designer', - ratePerHour: 30, - members: null, - }, -]; +import { deleteProjectRateCardRoleById, fetchProjectRateCardRoles } from '@/features/finance/project-finance-slice'; +import { useParams } from 'react-router-dom'; const RatecardTable: React.FC = () => { - const [roles, setRoles] = useState(initialJobRolesList); - - // localization + const dispatch = useAppDispatch(); const { t } = useTranslation('project-view-finance'); + const { projectId } = useParams(); + + // Fetch roles from Redux + const roles = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || []; + const isLoading = useAppSelector((state) => state.projectFinanceRateCard.isLoading); // get currently using currency from finance reducer const currency = useAppSelector( (state) => state.financeReducer.currency ).toUpperCase(); + useEffect(() => { + if (projectId) { + dispatch(fetchProjectRateCardRoles(projectId)); + } + }, [dispatch, projectId]); + const handleAddRole = () => { - const newRole: JobRoleType = { - jobId: `J00${roles.length + 1}`, - jobTitle: 'New Role', - ratePerHour: 0, - members: [], - }; - setRoles([...roles, newRole]); + // You can implement add role logic here if needed }; const columns: TableProps['columns'] = [ { title: t('jobTitleColumn'), - dataIndex: 'jobTitle', - render: (text: string, record: JobRoleType, index: number) => ( - { - const updatedRoles = [...roles]; - updatedRoles[index].jobTitle = e.target.value; - setRoles(updatedRoles); - }} - /> + dataIndex: 'jobtitle', + render: (text: string) => ( + {text} ), }, { title: `${t('ratePerHourColumn')} (${currency})`, - dataIndex: 'ratePerHour', - render: (text: number, record: JobRoleType, index: number) => ( - { - const updatedRoles = [...roles]; - updatedRoles[index].ratePerHour = parseInt(e.target.value, 10) || 0; - setRoles(updatedRoles); - }} - /> - ), + dataIndex: 'rate', + render: (text: number) => {text}, }, { title: t('membersColumn'), @@ -126,14 +74,37 @@ const RatecardTable: React.FC = () => { /> ), }, + { + title: t('actions'), + key: 'actions', + render: (_: any, record: JobRoleType) => ( + { + if (record.id) { + dispatch(deleteProjectRateCardRoleById(record.id)); + } + }} + okText={t('yes')} + cancelText={t('no')} + > +
record.jobId} + rowKey={(record) => record.id || record.job_title_id} pagination={false} + loading={isLoading} footer={() => (