feat(ratecard): enhance project rate card functionality with job title retrieval and bulk save feature

This commit is contained in:
shancds
2025-05-21 18:59:04 +05:30
parent c3bec74897
commit 3ce81272b2
5 changed files with 207 additions and 63 deletions

View File

@@ -22,7 +22,8 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate) 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(",")} 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 ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate
RETURNING *; RETURNING *,
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS Jobtitle;
`; `;
const flatValues = values.flat(); const flatValues = values.flat();
const result = await db.query(q, flatValues); const result = await db.query(q, flatValues);
@@ -94,7 +95,8 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
const q = ` const q = `
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate) 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(",")} VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")}
RETURNING *; RETURNING *,
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle;
`; `;
const flatValues = values.flat(); const flatValues = values.flat();
const result = await db.query(q, flatValues); const result = await db.query(q, flatValues);

View File

@@ -10,7 +10,7 @@ import { useParams } from 'react-router-dom';
const ImportRatecardsDrawer: React.FC = () => { const ImportRatecardsDrawer: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { projectId } = useParams(); const { projectId } = useParams();
const { t } = useTranslation('project-view-finance'); const { t } = useTranslation('project-view-finance');
const drawerRatecard = useAppSelector( const drawerRatecard = useAppSelector(
@@ -26,6 +26,8 @@ const { projectId } = useParams();
(state) => state.financeReducer.currency (state) => state.financeReducer.currency
).toUpperCase(); ).toUpperCase();
const rolesRedux = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || [];
// Loading states // Loading states
const isRatecardsLoading = useAppSelector( const isRatecardsLoading = useAppSelector(
(state) => state.financeReducer.isRatecardsLoading (state) => state.financeReducer.isRatecardsLoading
@@ -83,31 +85,32 @@ const { projectId } = useParams();
} }
footer={ footer={
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
<Button <Button
type="primary" type="primary"
onClick={() => { disabled={rolesRedux.length !== 0}
if (!projectId) { onClick={() => {
// Handle missing project id (show error, etc.) if (!projectId) {
return; // Handle missing project id (show error, etc.)
} return;
if (drawerRatecard?.jobRolesList?.length) { }
dispatch( if (drawerRatecard?.jobRolesList?.length) {
insertProjectRateCardRoles({ dispatch(
project_id: projectId, insertProjectRateCardRoles({
roles: drawerRatecard.jobRolesList project_id: projectId,
.filter((role) => typeof role.rate !== 'undefined') roles: drawerRatecard.jobRolesList
.map((role) => ({ .filter((role) => typeof role.rate !== 'undefined')
...role, .map((role) => ({
rate: Number(role.rate), ...role,
})), rate: Number(role.rate),
}) })),
); })
} );
dispatch(toggleImportRatecardsDrawer()); }
}} dispatch(toggleImportRatecardsDrawer());
> }}
{t('import')} >
</Button> {t('import')}
</Button>
</div> </div>
} }
open={isDrawerOpen} open={isDrawerOpen}

View File

@@ -18,7 +18,7 @@ const RatecardTab = () => {
> >
{t('ratecardImportantNotice')} {t('ratecardImportantNotice')}
</Typography.Text> </Typography.Text>
<Button {/* <Button
type="primary" type="primary"
style={{ style={{
marginTop: '10px', marginTop: '10px',
@@ -27,7 +27,7 @@ const RatecardTab = () => {
}} }}
> >
{t('saveButton')} {t('saveButton')}
</Button> </Button> */}
{/* import ratecards drawer */} {/* import ratecards drawer */}
<ImportRatecardsDrawer /> <ImportRatecardsDrawer />

View File

@@ -1,56 +1,176 @@
import { Avatar, Button, Input, Popconfirm, Table, TableProps } from 'antd'; import { Avatar, Button, Input, Popconfirm, Table, TableProps, Select, Flex } from 'antd';
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import CustomAvatar from '../../../../../../components/CustomAvatar'; 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 { useAppSelector } from '../../../../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../../../../hooks/useAppDispatch'; import { useAppDispatch } from '../../../../../../hooks/useAppDispatch';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { JobRoleType } from '@/types/project/ratecard.types'; import { JobRoleType, IJobType } from '@/types/project/ratecard.types';
import { deleteProjectRateCardRoleById, fetchProjectRateCardRoles } from '@/features/finance/project-finance-slice'; import {
deleteProjectRateCardRoleById,
fetchProjectRateCardRoles,
updateProjectRateCardRolesByProjectId,
} from '@/features/finance/project-finance-slice';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
const RatecardTable: React.FC = () => { const RatecardTable: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useTranslation('project-view-finance'); const { t } = useTranslation('project-view-finance');
const { projectId } = useParams(); const { projectId } = useParams();
// Fetch roles from Redux // Redux state
const roles = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || []; const rolesRedux = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || [];
const isLoading = useAppSelector((state) => state.projectFinanceRateCard.isLoading); const isLoading = useAppSelector((state) => state.projectFinanceRateCard.isLoading);
const currency = useAppSelector((state) => state.financeReducer.currency).toUpperCase();
// get currently using currency from finance reducer // Local state for editing
const currency = useAppSelector( const [roles, setRoles] = useState<JobRoleType[]>(rolesRedux);
(state) => state.financeReducer.currency const [addingRow, setAddingRow] = useState<boolean>(false);
).toUpperCase(); const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [jobTitles, setJobTitles] = useState<RatecardType[]>([]);
// 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(() => { useEffect(() => {
if (projectId) { if (projectId) {
dispatch(fetchProjectRateCardRoles(projectId)); dispatch(fetchProjectRateCardRoles(projectId));
} }
}, [dispatch, projectId]); }, [dispatch, projectId]);
// Add new role row
const handleAddRole = () => { 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<JobRoleType>['columns'] = [ const columns: TableProps<JobRoleType>['columns'] = [
{ {
title: t('jobTitleColumn'), title: t('jobTitleColumn'),
dataIndex: 'jobtitle', dataIndex: 'jobtitle',
render: (text: string) => ( render: (text: string, record: JobRoleType, index: number) => {
<span style={{ color: '#1890ff' }}>{text}</span> // Only show Select if addingRow and this is the last row (new row)
), if (addingRow && index === roles.length) {
return (
<Select
showSearch
autoFocus
placeholder={t('selectJobTitle')}
style={{ minWidth: 150 }}
value={record.job_title_id || undefined}
onChange={handleSelectJobTitle}
onBlur={() => setAddingRow(false)}
filterOption={(input, option) =>
(option?.children as string).toLowerCase().includes(input.toLowerCase())
}
>
{jobTitles
.filter(jt => !roles.some((role) => role.job_title_id === jt.id))
.map(jt => (
<Select.Option key={jt.id} value={jt.id!}>
{jt.name}
</Select.Option>
))}
</Select>
);
}
return (
<span
style={{ cursor: 'pointer' }}
onClick={() => setEditingIndex(index)}
>
{text || record.name}
</span>
);
},
}, },
{ {
title: `${t('ratePerHourColumn')} (${currency})`, title: `${t('ratePerHourColumn')} (${currency})`,
dataIndex: 'rate', dataIndex: 'rate',
render: (text: number) => <span>{text}</span>, render: (value: number, record: JobRoleType, index: number) => (
<Input
type="number"
value={roles[index]?.rate ?? 0}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
width: 80,
}}
onChange={(e) => handleRateChange(e.target.value, index)}
/>
),
}, },
{ {
title: t('membersColumn'), title: t('membersColumn'),
dataIndex: 'members', dataIndex: 'members',
render: (members: string[]) => render: (members: string[] | null | undefined) =>
members?.length > 0 ? ( members && members.length > 0 ? (
<Avatar.Group> <Avatar.Group>
{members.map((member, i) => ( {members.map((member, i) => (
<CustomAvatar key={i} avatarName={member} size={26} /> <CustomAvatar key={i} avatarName={member} size={26} />
@@ -77,14 +197,10 @@ const RatecardTable: React.FC = () => {
{ {
title: t('actions'), title: t('actions'),
key: 'actions', key: 'actions',
render: (_: any, record: JobRoleType) => ( render: (_: any, record: JobRoleType, index: number) => (
<Popconfirm <Popconfirm
title={t('deleteConfirm')} title={t('deleteConfirm')}
onConfirm={() => { onConfirm={() => handleDelete(record, index)}
if (record.id) {
dispatch(deleteProjectRateCardRoleById(record.id));
}
}}
okText={t('yes')} okText={t('yes')}
cancelText={t('no')} cancelText={t('no')}
> >
@@ -100,19 +216,41 @@ const RatecardTable: React.FC = () => {
return ( return (
<Table <Table
dataSource={roles} dataSource={
addingRow
? [
...roles,
{
job_title_id: '',
jobtitle: '',
rate: 0,
members: [],
},
]
: roles
}
columns={columns} columns={columns}
rowKey={(record) => record.id || record.job_title_id} rowKey={(record, idx) => record.id || record.job_title_id || idx}
pagination={false} pagination={false}
loading={isLoading} loading={isLoading}
footer={() => ( footer={() => (
<Button <Flex gap={8}>
type="dashed" <Button
onClick={handleAddRole} type="dashed"
style={{ width: 'fit-content' }} onClick={handleAddRole}
> style={{ width: 'fit-content' }}
{t('addRoleButton')} >
</Button> {t('addRoleButton')}
</Button>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveAll}
disabled={roles.length === 0}
>
{t('saveButton') || 'Save'}
</Button>
</Flex>
)} )}
/> />
); );

View File

@@ -7,6 +7,7 @@ export interface IJobType {
rate_card_id?: string; rate_card_id?: string;
job_title_id: string; job_title_id: string;
rate?: number; rate?: number;
name?: string;
}; };
export interface JobRoleType extends IJobType { export interface JobRoleType extends IJobType {
members?: string[] | null; members?: string[] | null;