From 7d81b7784be9a60ca86d7e2e819d836e2842bcda Mon Sep 17 00:00:00 2001 From: shancds Date: Tue, 20 May 2025 17:46:41 +0530 Subject: [PATCH] feat(ratecard): update job role references and enhance rate card functionality --- ...19000000-project-finance-module-tables.sql | 2 +- .../src/controllers/ratecard-controller.ts | 115 ++++++++++++++---- .../src/features/finance/finance-slice.ts | 1 + .../ratecard-drawer/ratecard-drawer.tsx | 29 +++-- .../settings/ratecard/ratecard-settings.tsx | 24 ++-- .../src/types/project/ratecard.types.ts | 7 +- 6 files changed, 135 insertions(+), 43 deletions(-) diff --git a/worklenz-backend/database/migrations/20250519000000-project-finance-module-tables.sql b/worklenz-backend/database/migrations/20250519000000-project-finance-module-tables.sql index f48dc750..57e8971a 100644 --- a/worklenz-backend/database/migrations/20250519000000-project-finance-module-tables.sql +++ b/worklenz-backend/database/migrations/20250519000000-project-finance-module-tables.sql @@ -30,7 +30,7 @@ DROP TABLE IF EXISTS finance_rate_card_roles; CREATE TABLE finance_rate_card_roles ( rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE, - role_id UUID REFERENCES roles (id) ON DELETE SET NULL, + job_title_id UUID REFERENCES job_titles(id) ON DELETE SET NULL, rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP diff --git a/worklenz-backend/src/controllers/ratecard-controller.ts b/worklenz-backend/src/controllers/ratecard-controller.ts index d8e9e851..8b1fcd75 100644 --- a/worklenz-backend/src/controllers/ratecard-controller.ts +++ b/worklenz-backend/src/controllers/ratecard-controller.ts @@ -19,10 +19,10 @@ export default class RateCardController extends WorklenzControllerBase { } @HandleExceptions() -public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const { searchQuery, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, "name"); + public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { searchQuery, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, "name"); - const q = ` + const q = ` SELECT ROW_TO_JSON(rec) AS rate_cards FROM ( SELECT COUNT(*) AS total, @@ -40,35 +40,107 @@ public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise< WHERE team_id = $1 ${searchQuery} ) rec; `; - const result = await db.query(q, [req.user?.team_id || null, size, offset]); - const [data] = result.rows; + const result = await db.query(q, [req.user?.team_id || null, size, offset]); + const [data] = result.rows; - return res.status(200).send(new ServerResponse(true, data.rate_cards || this.paginatedDatasetDefaultStruct)); -} + return res.status(200).send(new ServerResponse(true, data.rate_cards || this.paginatedDatasetDefaultStruct)); + } @HandleExceptions() public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + // 1. Fetch the rate card const q = ` - SELECT id, name, team_id, currency, created_at, updated_at - FROM finance_rate_cards - WHERE id = $1 AND team_id = $2; - `; + SELECT id, name, team_id, currency, created_at, updated_at + FROM finance_rate_cards + WHERE id = $1 AND team_id = $2; + `; const result = await db.query(q, [req.params.id, req.user?.team_id || null]); const [data] = result.rows; - return res.status(200).send(new ServerResponse(true, data)); + + if (!data) { + return res.status(404).send(new ServerResponse(false, null, "Rate card not found")); + } + + // 2. Fetch job roles with job title names + const jobRolesQ = ` + SELECT + rcr.job_title_id, + jt.name AS jobTitle, + rcr.rate, + rcr.rate_card_id + FROM finance_rate_card_roles rcr + LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id + WHERE rcr.rate_card_id = $1 + `; + const jobRolesResult = await db.query(jobRolesQ, [req.params.id]); + const jobRolesList = jobRolesResult.rows; + + // 3. Return the rate card with jobRolesList + return res.status(200).send( + new ServerResponse(true, { + ...data, + jobRolesList, + }) + ); } @HandleExceptions() public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const q = ` - UPDATE finance_rate_cards - SET name = $3, currency = $4, updated_at = NOW() - WHERE id = $1 AND team_id = $2 - RETURNING id, name, team_id, currency, created_at, updated_at; - `; - const result = await db.query(q, [req.params.id, req.user?.team_id || null, req.body.name, req.body.currency]); - const [data] = result.rows; - return res.status(200).send(new ServerResponse(true, data)); + // 1. Update the rate card + const updateRateCardQ = ` + UPDATE finance_rate_cards + SET name = $3, currency = $4, updated_at = NOW() + WHERE id = $1 AND team_id = $2 + RETURNING id, name, team_id, currency, created_at, updated_at; + `; + const result = await db.query(updateRateCardQ, [ + req.params.id, + req.user?.team_id || null, + req.body.name, + req.body.currency, + ]); + const [rateCardData] = result.rows; + + // 2. Update job roles (delete old, insert new) + if (Array.isArray(req.body.jobRolesList)) { + // Delete existing roles for this rate card + await db.query( + `DELETE FROM finance_rate_card_roles WHERE rate_card_id = $1;`, + [req.params.id] + ); + + // Insert new roles + for (const role of req.body.jobRolesList) { + if (role.job_title_id) { + await db.query( + `INSERT INTO finance_rate_card_roles (rate_card_id, job_title_id, rate) + VALUES ($1, $2, $3);`, + [req.params.id, role.job_title_id, role.rate ?? 0] + ); + } + } + } + + // 3. Get jobRolesList with job title names + const jobRolesQ = ` + SELECT + rcr.job_title_id, + jt.name AS jobTitle, + rcr.rate + FROM finance_rate_card_roles rcr + LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id + WHERE rcr.rate_card_id = $1 + `; + const jobRolesResult = await db.query(jobRolesQ, [req.params.id]); + const jobRolesList = jobRolesResult.rows; + + // 4. Return the updated rate card with jobRolesList + return res.status(200).send( + new ServerResponse(true, { + ...rateCardData, + jobRolesList, + }) + ); } @HandleExceptions() @@ -82,3 +154,4 @@ public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise< return res.status(200).send(new ServerResponse(true, result.rows.length > 0)); } } + diff --git a/worklenz-frontend/src/features/finance/finance-slice.ts b/worklenz-frontend/src/features/finance/finance-slice.ts index 3cc011f0..d07c7f58 100644 --- a/worklenz-frontend/src/features/finance/finance-slice.ts +++ b/worklenz-frontend/src/features/finance/finance-slice.ts @@ -161,5 +161,6 @@ export const { toggleImportRatecardsDrawer, changeCurrency, ratecardDrawerLoading, + clearDrawerRatecard, } = 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 d6fc0984..a845d697 100644 --- a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAppSelector } from '../../../hooks/useAppSelector'; import { useAppDispatch } from '../../../hooks/useAppDispatch'; -import { fetchRateCardById, fetchRateCards, toggleRatecardDrawer, updateRateCard } from '../finance-slice'; +import { clearDrawerRatecard, 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'; @@ -40,6 +40,7 @@ const RatecardDrawer = ({ (state) => state.financeReducer.isRatecardDrawerOpen ); const dispatch = useAppDispatch(); + const [isAddingRole, setIsAddingRole] = useState(false); const [selectedJobTitleId, setSelectedJobTitleId] = useState(undefined); const [searchQuery, setSearchQuery] = useState(''); @@ -90,6 +91,7 @@ const RatecardDrawer = ({ }, [type, ratecardId, dispatch]); useEffect(() => { + if (type === 'update' && drawerRatecard) { setRoles(drawerRatecard.jobRolesList || []); setName(drawerRatecard.name || ''); @@ -113,11 +115,12 @@ const RatecardDrawer = ({ const jobTitle = jobTitles.data?.find(jt => jt.id === jobTitleId); if (jobTitle) { const newRole = { - rate_card_id: jobTitleId, - jobTitle: jobTitle.name || 'New Role', - ratePerHour: 0, + jobtitle: jobTitle.name, + rate_card_id: ratecardId, + job_title_id: jobTitleId, + rate: 0, }; - // setRoles([...roles, newRole]); + setRoles([...roles, newRole]); } setIsAddingRole(false); setSelectedJobTitleId(undefined); @@ -144,8 +147,13 @@ const RatecardDrawer = ({ }) as any); if (onSaved) onSaved(); dispatch(toggleRatecardDrawer()); + } catch (error) { console.error('Failed to update rate card', error); + } finally { + setRoles([]); + setName('Untitled Rate Card'); + setCurrency('LKR'); } } }; @@ -154,7 +162,7 @@ const RatecardDrawer = ({ const columns = [ { title: t('jobTitleColumn'), - dataIndex: 'jobTitle', + dataIndex: 'jobtitle', render: (text: string, record: any, index: number) => ( { const updatedRoles = [...roles]; - updatedRoles[index].jobTitle = e.target.value; + updatedRoles[index].jobtitle = e.target.value; setRoles(updatedRoles); }} /> @@ -176,7 +184,7 @@ const RatecardDrawer = ({ }, { title: `${t('ratePerHourColumn')} (${currency})`, - dataIndex: 'ratePerHour', + dataIndex: 'rate', render: (text: number, record: any, index: number) => ( { const updatedRoles = [...roles]; - updatedRoles[index].ratePerHour = parseInt(e.target.value, 10) || 0; + updatedRoles[index].rate = parseInt(e.target.value, 10) || 0; setRoles(updatedRoles); }} /> @@ -210,6 +218,7 @@ const RatecardDrawer = ({ return ( @@ -252,7 +261,7 @@ const RatecardDrawer = ({ record.jobId} + rowKey={(record) => record.job_title_id} pagination={false} footer={() => ( isAddingRole ? ( diff --git a/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx b/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx index b5421083..4e149e84 100644 --- a/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx +++ b/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx @@ -21,7 +21,7 @@ import { useAppDispatch } from '../../../hooks/useAppDispatch'; import { useTranslation } from 'react-i18next'; import { useDocumentTitle } from '../../../hooks/useDoumentTItle'; import { durationDateFormat } from '../../../utils/durationDateFormat'; -import { createRateCard, deleteRateCard, toggleRatecardDrawer } from '../../../features/finance/finance-slice'; +import { createRateCard, deleteRateCard, fetchRateCardById, toggleRatecardDrawer } from '../../../features/finance/finance-slice'; import RatecardDrawer from '../../../features/finance/ratecard-drawer/ratecard-drawer'; import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service'; import { DEFAULT_PAGE_SIZE } from '@/shared/constants'; @@ -58,6 +58,12 @@ const RatecardSettings: React.FC = () => { size: 'small', }); + const filteredRatecardsData = useMemo(() => { + return ratecardsList.filter((item) => + item.name?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, [ratecardsList, searchQuery]); + const fetchRateCards = useCallback(async () => { setLoading(true); try { @@ -81,13 +87,9 @@ const RatecardSettings: React.FC = () => { useEffect(() => { fetchRateCards(); - }, []); + }, [toggleRatecardDrawer]); + - const filteredRatecardsData = useMemo(() => { - return ratecardsList.filter((item) => - item.name?.toLowerCase().includes(searchQuery.toLowerCase()) - ); - }, [ratecardsList, searchQuery]); const handleRatecardCreate = useCallback(async () => { @@ -107,6 +109,7 @@ const RatecardSettings: React.FC = () => { const handleRatecardUpdate = useCallback((id: string) => { setRatecardDrawerType('update'); + dispatch(fetchRateCardById(id)); setSelectedRatecardId(id); dispatch(toggleRatecardDrawer()); }, [dispatch]); @@ -161,9 +164,12 @@ const RatecardSettings: React.FC = () => { icon={} okText={t('deleteConfirmationOk')} cancelText={t('deleteConfirmationCancel')} - onConfirm={() => { + onConfirm={async () => { setLoading(true); - record.id && dispatch(deleteRateCard(record.id)); + if (record.id) { + await dispatch(deleteRateCard(record.id)); + await fetchRateCards(); + } setLoading(false); }} > diff --git a/worklenz-frontend/src/types/project/ratecard.types.ts b/worklenz-frontend/src/types/project/ratecard.types.ts index b53403fd..0b94918e 100644 --- a/worklenz-frontend/src/types/project/ratecard.types.ts +++ b/worklenz-frontend/src/types/project/ratecard.types.ts @@ -1,8 +1,11 @@ export interface IJobType { - jobId: string; - jobTitle: string; + jobId?: string; + jobtitle?: string; ratePerHour?: number; + rate_card_id?: string; + job_title_id: string; + rate?: number; }; export interface JobRoleType extends IJobType { members: string[] | null;