feat(ratecard): enhance project rate card functionality with job title retrieval and bulk save feature
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user