feat(ratecard): update job role references and enhance rate card functionality

This commit is contained in:
shancds
2025-05-20 17:46:41 +05:30
parent afd4cbdf81
commit 7d81b7784b
6 changed files with 135 additions and 43 deletions

View File

@@ -30,7 +30,7 @@ DROP TABLE IF EXISTS finance_rate_card_roles;
CREATE TABLE finance_rate_card_roles CREATE TABLE finance_rate_card_roles
( (
rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE, 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), rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP

View File

@@ -19,10 +19,10 @@ export default class RateCardController extends WorklenzControllerBase {
} }
@HandleExceptions() @HandleExceptions()
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> { public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { searchQuery, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, "name"); const { searchQuery, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, "name");
const q = ` const q = `
SELECT ROW_TO_JSON(rec) AS rate_cards SELECT ROW_TO_JSON(rec) AS rate_cards
FROM ( FROM (
SELECT COUNT(*) AS total, SELECT COUNT(*) AS total,
@@ -40,35 +40,107 @@ public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<
WHERE team_id = $1 ${searchQuery} WHERE team_id = $1 ${searchQuery}
) rec; ) rec;
`; `;
const result = await db.query(q, [req.user?.team_id || null, size, offset]); const result = await db.query(q, [req.user?.team_id || null, size, offset]);
const [data] = result.rows; 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() @HandleExceptions()
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> { public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
// 1. Fetch the rate card
const q = ` const q = `
SELECT id, name, team_id, currency, created_at, updated_at SELECT id, name, team_id, currency, created_at, updated_at
FROM finance_rate_cards FROM finance_rate_cards
WHERE id = $1 AND team_id = $2; WHERE id = $1 AND team_id = $2;
`; `;
const result = await db.query(q, [req.params.id, req.user?.team_id || null]); const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
const [data] = result.rows; 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() @HandleExceptions()
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> { public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = ` // 1. Update the rate card
UPDATE finance_rate_cards const updateRateCardQ = `
SET name = $3, currency = $4, updated_at = NOW() UPDATE finance_rate_cards
WHERE id = $1 AND team_id = $2 SET name = $3, currency = $4, updated_at = NOW()
RETURNING id, name, team_id, currency, created_at, updated_at; 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; const result = await db.query(updateRateCardQ, [
return res.status(200).send(new ServerResponse(true, data)); 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() @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)); return res.status(200).send(new ServerResponse(true, result.rows.length > 0));
} }
} }

View File

@@ -161,5 +161,6 @@ export const {
toggleImportRatecardsDrawer, toggleImportRatecardsDrawer,
changeCurrency, changeCurrency,
ratecardDrawerLoading, ratecardDrawerLoading,
clearDrawerRatecard,
} = financeSlice.actions; } = financeSlice.actions;
export default financeSlice.reducer; export default financeSlice.reducer;

View File

@@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../../hooks/useAppSelector'; import { useAppSelector } from '../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../hooks/useAppDispatch'; import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { 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 { RatecardType, IJobType } from '@/types/project/ratecard.types';
import { IJobTitlesViewModel } from '@/types/job.types'; import { IJobTitlesViewModel } from '@/types/job.types';
import { DEFAULT_PAGE_SIZE } from '@/shared/constants'; import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
@@ -40,6 +40,7 @@ const RatecardDrawer = ({
(state) => state.financeReducer.isRatecardDrawerOpen (state) => state.financeReducer.isRatecardDrawerOpen
); );
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [isAddingRole, setIsAddingRole] = useState(false); const [isAddingRole, setIsAddingRole] = useState(false);
const [selectedJobTitleId, setSelectedJobTitleId] = useState<string | undefined>(undefined); const [selectedJobTitleId, setSelectedJobTitleId] = useState<string | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -90,6 +91,7 @@ const RatecardDrawer = ({
}, [type, ratecardId, dispatch]); }, [type, ratecardId, dispatch]);
useEffect(() => { useEffect(() => {
if (type === 'update' && drawerRatecard) { if (type === 'update' && drawerRatecard) {
setRoles(drawerRatecard.jobRolesList || []); setRoles(drawerRatecard.jobRolesList || []);
setName(drawerRatecard.name || ''); setName(drawerRatecard.name || '');
@@ -113,11 +115,12 @@ const RatecardDrawer = ({
const jobTitle = jobTitles.data?.find(jt => jt.id === jobTitleId); const jobTitle = jobTitles.data?.find(jt => jt.id === jobTitleId);
if (jobTitle) { if (jobTitle) {
const newRole = { const newRole = {
rate_card_id: jobTitleId, jobtitle: jobTitle.name,
jobTitle: jobTitle.name || 'New Role', rate_card_id: ratecardId,
ratePerHour: 0, job_title_id: jobTitleId,
rate: 0,
}; };
// setRoles([...roles, newRole]); setRoles([...roles, newRole]);
} }
setIsAddingRole(false); setIsAddingRole(false);
setSelectedJobTitleId(undefined); setSelectedJobTitleId(undefined);
@@ -144,8 +147,13 @@ const RatecardDrawer = ({
}) as any); }) as any);
if (onSaved) onSaved(); if (onSaved) onSaved();
dispatch(toggleRatecardDrawer()); dispatch(toggleRatecardDrawer());
} catch (error) { } catch (error) {
console.error('Failed to update rate card', 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 = [ const columns = [
{ {
title: t('jobTitleColumn'), title: t('jobTitleColumn'),
dataIndex: 'jobTitle', dataIndex: 'jobtitle',
render: (text: string, record: any, index: number) => ( render: (text: string, record: any, index: number) => (
<Input <Input
value={text} value={text}
@@ -168,7 +176,7 @@ const RatecardDrawer = ({
}} }}
onChange={(e) => { onChange={(e) => {
const updatedRoles = [...roles]; const updatedRoles = [...roles];
updatedRoles[index].jobTitle = e.target.value; updatedRoles[index].jobtitle = e.target.value;
setRoles(updatedRoles); setRoles(updatedRoles);
}} }}
/> />
@@ -176,7 +184,7 @@ const RatecardDrawer = ({
}, },
{ {
title: `${t('ratePerHourColumn')} (${currency})`, title: `${t('ratePerHourColumn')} (${currency})`,
dataIndex: 'ratePerHour', dataIndex: 'rate',
render: (text: number, record: any, index: number) => ( render: (text: number, record: any, index: number) => (
<Input <Input
type="number" type="number"
@@ -189,7 +197,7 @@ const RatecardDrawer = ({
}} }}
onChange={(e) => { onChange={(e) => {
const updatedRoles = [...roles]; const updatedRoles = [...roles];
updatedRoles[index].ratePerHour = parseInt(e.target.value, 10) || 0; updatedRoles[index].rate = parseInt(e.target.value, 10) || 0;
setRoles(updatedRoles); setRoles(updatedRoles);
}} }}
/> />
@@ -210,6 +218,7 @@ const RatecardDrawer = ({
return ( return (
<Drawer <Drawer
loading={drawerLoading}
title={ title={
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}> <Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
@@ -252,7 +261,7 @@ const RatecardDrawer = ({
<Table <Table
dataSource={roles} dataSource={roles}
columns={columns} columns={columns}
rowKey={(record) => record.jobId} rowKey={(record) => record.job_title_id}
pagination={false} pagination={false}
footer={() => ( footer={() => (
isAddingRole ? ( isAddingRole ? (

View File

@@ -21,7 +21,7 @@ import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '../../../hooks/useDoumentTItle'; import { useDocumentTitle } from '../../../hooks/useDoumentTItle';
import { durationDateFormat } from '../../../utils/durationDateFormat'; import { durationDateFormat } from '../../../utils/durationDateFormat';
import { 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 RatecardDrawer from '../../../features/finance/ratecard-drawer/ratecard-drawer';
import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service'; import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service';
import { DEFAULT_PAGE_SIZE } from '@/shared/constants'; import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
@@ -58,6 +58,12 @@ const RatecardSettings: React.FC = () => {
size: 'small', size: 'small',
}); });
const filteredRatecardsData = useMemo(() => {
return ratecardsList.filter((item) =>
item.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [ratecardsList, searchQuery]);
const fetchRateCards = useCallback(async () => { const fetchRateCards = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -81,13 +87,9 @@ const RatecardSettings: React.FC = () => {
useEffect(() => { useEffect(() => {
fetchRateCards(); fetchRateCards();
}, []); }, [toggleRatecardDrawer]);
const filteredRatecardsData = useMemo(() => {
return ratecardsList.filter((item) =>
item.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [ratecardsList, searchQuery]);
const handleRatecardCreate = useCallback(async () => { const handleRatecardCreate = useCallback(async () => {
@@ -107,6 +109,7 @@ const RatecardSettings: React.FC = () => {
const handleRatecardUpdate = useCallback((id: string) => { const handleRatecardUpdate = useCallback((id: string) => {
setRatecardDrawerType('update'); setRatecardDrawerType('update');
dispatch(fetchRateCardById(id));
setSelectedRatecardId(id); setSelectedRatecardId(id);
dispatch(toggleRatecardDrawer()); dispatch(toggleRatecardDrawer());
}, [dispatch]); }, [dispatch]);
@@ -161,9 +164,12 @@ const RatecardSettings: React.FC = () => {
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />} icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')} okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')} cancelText={t('deleteConfirmationCancel')}
onConfirm={() => { onConfirm={async () => {
setLoading(true); setLoading(true);
record.id && dispatch(deleteRateCard(record.id)); if (record.id) {
await dispatch(deleteRateCard(record.id));
await fetchRateCards();
}
setLoading(false); setLoading(false);
}} }}
> >

View File

@@ -1,8 +1,11 @@
export interface IJobType { export interface IJobType {
jobId: string; jobId?: string;
jobTitle: string; jobtitle?: string;
ratePerHour?: number; ratePerHour?: number;
rate_card_id?: string;
job_title_id: string;
rate?: number;
}; };
export interface JobRoleType extends IJobType { export interface JobRoleType extends IJobType {
members: string[] | null; members: string[] | null;