feat(ratecard): update job role references and enhance rate card functionality
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user