From db1108a48d6fa9069c4b61cdfb62d00219c1b1a0 Mon Sep 17 00:00:00 2001 From: shancds Date: Wed, 21 May 2025 12:26:01 +0530 Subject: [PATCH 1/4] feat(ratecard): add 'Add All' and 'Remove All' buttons, enhance role management, and implement drawer close logic --- .../en/settings/ratecard-settings.json | 6 +- .../ratecard-drawer/ratecard-drawer.tsx | 120 +++++++++++++----- 2 files changed, 95 insertions(+), 31 deletions(-) diff --git a/worklenz-frontend/public/locales/en/settings/ratecard-settings.json b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json index 8dae2b36..374b69a8 100644 --- a/worklenz-frontend/public/locales/en/settings/ratecard-settings.json +++ b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json @@ -16,5 +16,9 @@ "createRatecardErrorMessage": "Create Rate Card failed!", "updateRatecardSuccessMessage": "Update Rate Card success!", "updateRatecardErrorMessage": "Update Rate Card failed!", - "currency": "Currency" + "currency": "Currency", + "actionsColumn": "Actions", + "addAllButton": "Add All", + "removeAllButton": "Remove All" + } 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 9f3cc432..afe8183c 100644 --- a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx @@ -3,10 +3,9 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAppSelector } from '../../../hooks/useAppSelector'; import { useAppDispatch } from '../../../hooks/useAppDispatch'; -import { clearDrawerRatecard, fetchRateCardById, fetchRateCards, toggleRatecardDrawer, updateRateCard } from '../finance-slice'; +import { deleteRateCard, 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'; @@ -31,7 +30,7 @@ const RatecardDrawer = ({ const [ratecardsList, setRatecardsList] = useState([]); // initial Job Roles List (dummy data) const [roles, setRoles] = useState([]); - + const [addingRowIndex, setAddingRowIndex] = useState(null); const { t } = useTranslation('settings/ratecard-settings'); // get drawer state from client reducer const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading); @@ -49,13 +48,14 @@ const RatecardDrawer = ({ const [jobTitles, setJobTitles] = useState({}); const [pagination, setPagination] = useState({ current: 1, - pageSize: DEFAULT_PAGE_SIZE, + pageSize: 10000, field: 'name', order: 'desc', total: 0, pageSizeOptions: ['5', '10', '15', '20', '50', '100'], size: 'small', }); + const [editingRowIndex, setEditingRowIndex] = useState(null); const getJobTitles = useMemo(() => { return async () => { @@ -102,23 +102,33 @@ const RatecardDrawer = ({ // Add All handler const handleAddAllRoles = () => { if (!jobTitles.data) return; - // Filter out job titles already in roles + // Get current job_title_ids in roles const existingIds = new Set(roles.map(r => r.job_title_id)); + // Only add job titles not already present const newRoles = jobTitles.data - .filter(jt => !existingIds.has(jt.id!)) + .filter(jt => jt.id && !existingIds.has(jt.id)) .map(jt => ({ jobtitle: jt.name, rate_card_id: ratecardId, job_title_id: jt.id!, rate: 0, })); - setRoles([...roles, ...newRoles]); + // Prevent any accidental duplicates by merging and filtering again + const mergedRoles = [...roles, ...newRoles].filter( + (role, idx, arr) => + arr.findIndex(r => r.job_title_id === role.job_title_id) === idx + ); + setRoles(mergedRoles); }; - // add new job role handler const handleAddRole = () => { - setIsAddingRole(true); - setSelectedJobTitleId(undefined); + // Only allow adding if there are job titles not already in roles + const existingIds = new Set(roles.map(r => r.job_title_id)); + const availableJobTitles = jobTitles.data?.filter(jt => !existingIds.has(jt.id!)); + if (availableJobTitles && availableJobTitles.length > 0) { + setRoles([...roles, { job_title_id: '', rate: 0 }]); + setAddingRowIndex(roles.length); // index of the new row + } }; const handleDeleteRole = (index: number) => { const updatedRoles = [...roles]; @@ -126,6 +136,12 @@ const RatecardDrawer = ({ setRoles(updatedRoles); }; const handleSelectJobTitle = (jobTitleId: string) => { + // Prevent duplicate job_title_id + if (roles.some(role => role.job_title_id === jobTitleId)) { + setIsAddingRole(false); + setSelectedJobTitleId(undefined); + return; + } const jobTitle = jobTitles.data?.find(jt => jt.id === jobTitleId); if (jobTitle) { const newRole = { @@ -143,12 +159,14 @@ const RatecardDrawer = ({ const handleSave = async () => { if (type === 'update' && ratecardId) { try { + // Filter out roles with no jobtitle or empty jobtitle + const filteredRoles = roles.filter(role => role.jobtitle && role.jobtitle.trim() !== ''); await dispatch(updateRateCard({ id: ratecardId, body: { name, currency, - jobRolesList: roles, + jobRolesList: filteredRoles, }, }) as any); // Refresh the rate cards list in Redux @@ -177,24 +195,56 @@ const RatecardDrawer = ({ { title: t('jobTitleColumn'), dataIndex: 'jobtitle', - render: (text: string, record: any, index: number) => ( - { - const updatedRoles = [...roles]; - updatedRoles[index].jobtitle = e.target.value; - setRoles(updatedRoles); - }} - /> - ), + render: (text: string, record: any, index: number) => { + if (index === addingRowIndex || index === editingRowIndex) { + return ( + + ); + } + // Render as clickable text for existing rows + return ( + setEditingRowIndex(index)} + > + {record.jobtitle} + + ); + }, }, { title: `${t('ratePerHourColumn')} (${currency})`, @@ -230,10 +280,21 @@ const RatecardDrawer = ({ ), }, ]; + const handleDrawerClose = async () => { + if ( + drawerRatecard && + (drawerRatecard.jobRolesList?.length === 0 || !drawerRatecard.jobRolesList) && + name === 'Untitled Rate Card' + ) { + await dispatch(deleteRateCard(drawerRatecard.id as string)); + } + dispatch(toggleRatecardDrawer()); + }; return ( @@ -272,7 +333,6 @@ const RatecardDrawer = ({ } open={isDrawerOpen} - onClose={() => dispatch(toggleRatecardDrawer())} width={700} footer={ From c3bec7489774461aea59566d20d9c58ebf1e24cc Mon Sep 17 00:00:00 2001 From: shancds Date: Wed, 21 May 2025 17:16:35 +0530 Subject: [PATCH 2/4] 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={() => ( + } open={isDrawerOpen} diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx index 6119c8b9..4ec0ee07 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx @@ -18,7 +18,7 @@ const RatecardTab = () => { > {t('ratecardImportantNotice')} - + */} {/* import ratecards drawer */} 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 141208c0..90517fbe 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,56 +1,176 @@ -import { Avatar, Button, Input, Popconfirm, Table, TableProps } from 'antd'; -import React, { useEffect } from 'react'; +import { Avatar, Button, Input, Popconfirm, Table, TableProps, Select, Flex } from 'antd'; +import React, { useEffect, useState } from 'react'; import CustomAvatar from '../../../../../../components/CustomAvatar'; -import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; +import { DeleteOutlined, PlusOutlined, SaveOutlined } 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'; -import { deleteProjectRateCardRoleById, fetchProjectRateCardRoles } from '@/features/finance/project-finance-slice'; +import { JobRoleType, IJobType } from '@/types/project/ratecard.types'; +import { + deleteProjectRateCardRoleById, + fetchProjectRateCardRoles, + updateProjectRateCardRolesByProjectId, +} from '@/features/finance/project-finance-slice'; import { useParams } from 'react-router-dom'; +import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service'; const RatecardTable: React.FC = () => { const dispatch = useAppDispatch(); const { t } = useTranslation('project-view-finance'); const { projectId } = useParams(); - // Fetch roles from Redux - const roles = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || []; + // Redux state + const rolesRedux = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || []; const isLoading = useAppSelector((state) => state.projectFinanceRateCard.isLoading); + const currency = useAppSelector((state) => state.financeReducer.currency).toUpperCase(); - // get currently using currency from finance reducer - const currency = useAppSelector( - (state) => state.financeReducer.currency - ).toUpperCase(); + // Local state for editing + const [roles, setRoles] = useState(rolesRedux); + const [addingRow, setAddingRow] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [jobTitles, setJobTitles] = useState([]); + // Fetch job titles for selection + useEffect(() => { + (async () => { + const res = await jobTitlesApiService.getJobTitles(1, 1000, 'name', 'asc', ''); + setJobTitles(res.body?.data || []); + })(); + }, []); + + // Sync local roles with redux roles + useEffect(() => { + console.log('Roles Redux:', rolesRedux); + setRoles(rolesRedux); + }, [rolesRedux]); + + // Fetch roles on mount useEffect(() => { if (projectId) { dispatch(fetchProjectRateCardRoles(projectId)); } }, [dispatch, projectId]); + // Add new role row const handleAddRole = () => { - // You can implement add role logic here if needed + setAddingRow(true); }; + // Save all roles (bulk update) + const handleSaveAll = () => { + if (projectId) { + // Only send roles with job_title_id and rate + const filteredRoles = roles + .filter((r) => r.job_title_id && typeof r.rate !== 'undefined') + .map((r) => ({ + job_title_id: r.job_title_id, + jobtitle: r.jobtitle || r.name || '', + rate: Number(r.rate), + })); + dispatch(updateProjectRateCardRolesByProjectId({ project_id: projectId, roles: filteredRoles })); + } + }; + + // Handle job title select for new row + const handleSelectJobTitle = (jobTitleId: string) => { + const jobTitle = jobTitles.find((jt) => jt.id === jobTitleId); + if (!jobTitle) return; + // Prevent duplicates + if (roles.some((r) => r.job_title_id === jobTitleId)) return; + setRoles([ + ...roles, + { + job_title_id: jobTitleId, + jobtitle: jobTitle.name || '', + rate: 0, + members: [], + }, + ]); + setAddingRow(false); + }; + + // Handle rate change + const handleRateChange = (value: string | number, index: number) => { + const updatedRoles = roles.map((role, idx) => + idx === index ? { ...role, rate: Number(value) } : role + ); + setRoles(updatedRoles); + }; + + // Handle delete + const handleDelete = (record: JobRoleType, index: number) => { + if (record.id) { + dispatch(deleteProjectRateCardRoleById(record.id)); + } else { + // Remove unsaved row + setRoles(roles.filter((_, idx) => idx !== index)); + } + }; + + // Columns const columns: TableProps['columns'] = [ { title: t('jobTitleColumn'), dataIndex: 'jobtitle', - render: (text: string) => ( - {text} - ), + render: (text: string, record: JobRoleType, index: number) => { + // Only show Select if addingRow and this is the last row (new row) + if (addingRow && index === roles.length) { + return ( + + ); + } + return ( + setEditingIndex(index)} + > + {text || record.name} + + ); + }, }, { title: `${t('ratePerHourColumn')} (${currency})`, dataIndex: 'rate', - render: (text: number) => {text}, + render: (value: number, record: JobRoleType, index: number) => ( + handleRateChange(e.target.value, index)} + /> + ), }, { title: t('membersColumn'), dataIndex: 'members', - render: (members: string[]) => - members?.length > 0 ? ( + render: (members: string[] | null | undefined) => + members && members.length > 0 ? ( {members.map((member, i) => ( @@ -77,14 +197,10 @@ const RatecardTable: React.FC = () => { { title: t('actions'), key: 'actions', - render: (_: any, record: JobRoleType) => ( + render: (_: any, record: JobRoleType, index: number) => ( { - if (record.id) { - dispatch(deleteProjectRateCardRoleById(record.id)); - } - }} + onConfirm={() => handleDelete(record, index)} okText={t('yes')} cancelText={t('no')} > @@ -100,19 +216,41 @@ const RatecardTable: React.FC = () => { return (
record.id || record.job_title_id} + rowKey={(record, idx) => record.id || record.job_title_id || idx} pagination={false} loading={isLoading} footer={() => ( - + + + + )} /> ); diff --git a/worklenz-frontend/src/types/project/ratecard.types.ts b/worklenz-frontend/src/types/project/ratecard.types.ts index 5d29681c..67eb7ee8 100644 --- a/worklenz-frontend/src/types/project/ratecard.types.ts +++ b/worklenz-frontend/src/types/project/ratecard.types.ts @@ -7,6 +7,7 @@ export interface IJobType { rate_card_id?: string; job_title_id: string; rate?: number; + name?: string; }; export interface JobRoleType extends IJobType { members?: string[] | null; From b104cf2d3fa9ee411d660a8076db08b664f840c7 Mon Sep 17 00:00:00 2001 From: shancds Date: Wed, 21 May 2025 19:01:22 +0530 Subject: [PATCH 4/4] feat(ratecard): add RatecardType import for enhanced type support in rate card functionality --- .../finance/ratecard-tab/reatecard-table/ratecard-table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 90517fbe..923fc600 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 @@ -5,7 +5,7 @@ import { DeleteOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons'; import { useAppSelector } from '../../../../../../hooks/useAppSelector'; import { useAppDispatch } from '../../../../../../hooks/useAppDispatch'; import { useTranslation } from 'react-i18next'; -import { JobRoleType, IJobType } from '@/types/project/ratecard.types'; +import { JobRoleType, IJobType, RatecardType } from '@/types/project/ratecard.types'; import { deleteProjectRateCardRoleById, fetchProjectRateCardRoles,