feat(ratecard): enhance ratecard update logic and add unsaved changes alert
This commit is contained in:
@@ -97,10 +97,26 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
|
||||
const { id } = req.params;
|
||||
const { job_title_id, rate } = req.body;
|
||||
const q = `
|
||||
WITH updated AS (
|
||||
UPDATE finance_project_rate_card_roles
|
||||
SET job_title_id = $1, rate = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING *;
|
||||
RETURNING *
|
||||
),
|
||||
jobtitles AS (
|
||||
SELECT u.*, jt.name AS jobtitle
|
||||
FROM updated u
|
||||
JOIN job_titles jt ON jt.id = u.job_title_id
|
||||
),
|
||||
members AS (
|
||||
SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id
|
||||
FROM project_members pm
|
||||
WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles)
|
||||
GROUP BY pm.project_rate_card_role_id
|
||||
)
|
||||
SELECT jt.*, m.members
|
||||
FROM jobtitles jt
|
||||
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
|
||||
`;
|
||||
const result = await db.query(q, [job_title_id, rate, id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
"actionsColumn": "Actions",
|
||||
"addAllButton": "Add All",
|
||||
"removeAllButton": "Remove All",
|
||||
"selectJobTitle": "Select job title"
|
||||
"selectJobTitle": "Select job title",
|
||||
"unsavedChangesTitle": "Unsaved changes",
|
||||
"ratecardNameRequired": "Rate card name is required"
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Drawer, Select, Typography, Flex, Button, Input, Table, Popconfirm, Tooltip } from 'antd';
|
||||
import { Drawer, Select, Typography, Flex, Button, Input, Table, Tooltip, Alert, Space, message, Popconfirm } from 'antd';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '../../../hooks/useAppSelector';
|
||||
@@ -19,6 +19,7 @@ interface PaginationType {
|
||||
pageSizeOptions: string[];
|
||||
size: 'small' | 'default';
|
||||
}
|
||||
|
||||
const RatecardDrawer = ({
|
||||
type,
|
||||
ratecardId,
|
||||
@@ -29,11 +30,12 @@ const RatecardDrawer = ({
|
||||
onSaved?: () => void;
|
||||
}) => {
|
||||
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
|
||||
// initial Job Roles List (dummy data)
|
||||
const [roles, setRoles] = useState<IJobType[]>([]);
|
||||
const [initialRoles, setInitialRoles] = useState<IJobType[]>([]);
|
||||
const [initialName, setInitialName] = useState<string>('Untitled Rate Card');
|
||||
const [initialCurrency, setInitialCurrency] = useState<string>('USD');
|
||||
const [addingRowIndex, setAddingRowIndex] = useState<number | null>(null);
|
||||
const { t } = useTranslation('settings/ratecard-settings');
|
||||
// get drawer state from client reducer
|
||||
const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading);
|
||||
const drawerRatecard = useAppSelector(state => state.financeReducer.drawerRatecard);
|
||||
const isDrawerOpen = useAppSelector(
|
||||
@@ -57,6 +59,15 @@ const RatecardDrawer = ({
|
||||
size: 'small',
|
||||
});
|
||||
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
||||
const [showUnsavedAlert, setShowUnsavedAlert] = useState(false);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
// Detect changes
|
||||
const hasChanges = useMemo(() => {
|
||||
const rolesChanged = JSON.stringify(roles) !== JSON.stringify(initialRoles);
|
||||
const nameChanged = name !== initialName;
|
||||
const currencyChanged = currency !== initialCurrency;
|
||||
return rolesChanged || nameChanged || currencyChanged;
|
||||
}, [roles, name, currency, initialRoles, initialName, initialCurrency]);
|
||||
|
||||
const getJobTitles = useMemo(() => {
|
||||
return async () => {
|
||||
@@ -74,12 +85,10 @@ const RatecardDrawer = ({
|
||||
};
|
||||
}, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery]);
|
||||
|
||||
// fetch rate cards data
|
||||
useEffect(() => {
|
||||
getJobTitles();
|
||||
}, []);
|
||||
|
||||
// get currently selected ratecard
|
||||
const selectedRatecard = ratecardsList.find(
|
||||
(ratecard) => ratecard.id === ratecardId
|
||||
);
|
||||
@@ -88,24 +97,22 @@ const RatecardDrawer = ({
|
||||
if (type === 'update' && ratecardId) {
|
||||
dispatch(fetchRateCardById(ratecardId));
|
||||
}
|
||||
// ...reset logic for create...
|
||||
}, [type, ratecardId, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (type === 'update' && drawerRatecard) {
|
||||
setRoles(drawerRatecard.jobRolesList || []);
|
||||
setInitialRoles(drawerRatecard.jobRolesList || []);
|
||||
setName(drawerRatecard.name || '');
|
||||
setInitialName(drawerRatecard.name || '');
|
||||
setCurrency(drawerRatecard.currency || 'USD');
|
||||
setInitialCurrency(drawerRatecard.currency || 'USD');
|
||||
}
|
||||
}, [drawerRatecard, type]);
|
||||
|
||||
// Add All handler
|
||||
const handleAddAllRoles = () => {
|
||||
if (!jobTitles.data) return;
|
||||
// Get current job_title_ids in roles
|
||||
const existingIds = new Set(roles.map(r => r.job_title_id));
|
||||
// Only add job titles not already present
|
||||
const newRoles = jobTitles.data
|
||||
.filter(jt => jt.id && !existingIds.has(jt.id))
|
||||
.map(jt => ({
|
||||
@@ -114,7 +121,6 @@ const RatecardDrawer = ({
|
||||
job_title_id: jt.id!,
|
||||
rate: 0,
|
||||
}));
|
||||
// Prevent any accidental duplicates by merging and filtering again
|
||||
const mergedRoles = [...roles, ...newRoles].filter(
|
||||
(role, idx, arr) =>
|
||||
arr.findIndex(r => r.job_title_id === role.job_title_id) === idx
|
||||
@@ -122,7 +128,6 @@ const RatecardDrawer = ({
|
||||
setRoles(mergedRoles);
|
||||
};
|
||||
|
||||
|
||||
const handleAddRole = () => {
|
||||
const existingIds = new Set(roles.map(r => r.job_title_id));
|
||||
const availableJobTitles = jobTitles.data?.filter(jt => !existingIds.has(jt.id!));
|
||||
@@ -138,8 +143,8 @@ const RatecardDrawer = ({
|
||||
updatedRoles.splice(index, 1);
|
||||
setRoles(updatedRoles);
|
||||
};
|
||||
|
||||
const handleSelectJobTitle = (jobTitleId: string) => {
|
||||
// Prevent duplicate job_title_id
|
||||
if (roles.some(role => role.job_title_id === jobTitleId)) {
|
||||
setIsAddingRole(false);
|
||||
setSelectedJobTitleId(undefined);
|
||||
@@ -162,7 +167,6 @@ const RatecardDrawer = ({
|
||||
const handleSave = async () => {
|
||||
if (type === 'update' && ratecardId) {
|
||||
try {
|
||||
// Filter out roles with no jobtitle or empty jobtitle
|
||||
const filteredRoles = roles.filter(role => role.jobtitle && role.jobtitle.trim() !== '');
|
||||
await dispatch(updateRateCard({
|
||||
id: ratecardId,
|
||||
@@ -172,7 +176,6 @@ const RatecardDrawer = ({
|
||||
jobRolesList: filteredRoles,
|
||||
},
|
||||
}) as any);
|
||||
// Refresh the rate cards list in Redux
|
||||
await dispatch(fetchRateCards({
|
||||
index: 1,
|
||||
size: 10,
|
||||
@@ -182,18 +185,24 @@ const RatecardDrawer = ({
|
||||
}) as any);
|
||||
if (onSaved) onSaved();
|
||||
dispatch(toggleRatecardDrawer());
|
||||
|
||||
// Reset initial states after save
|
||||
setInitialRoles(filteredRoles);
|
||||
setInitialName(name);
|
||||
setInitialCurrency(currency);
|
||||
setShowUnsavedAlert(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to update rate card', error);
|
||||
} finally {
|
||||
setRoles([]);
|
||||
setName('Untitled Rate Card');
|
||||
setCurrency('USD');
|
||||
setInitialRoles([]);
|
||||
setInitialName('Untitled Rate Card');
|
||||
setInitialCurrency('USD');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// table columns
|
||||
const columns = [
|
||||
{
|
||||
title: t('jobTitleColumn'),
|
||||
@@ -208,7 +217,6 @@ const RatecardDrawer = ({
|
||||
style={{ minWidth: 150 }}
|
||||
value={record.job_title_id || undefined}
|
||||
onChange={value => {
|
||||
// Prevent duplicate job_title_id
|
||||
if (roles.some((role, idx) => role.job_title_id === value && idx !== index)) {
|
||||
return;
|
||||
}
|
||||
@@ -241,11 +249,9 @@ const RatecardDrawer = ({
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
// Render as clickable text for existing rows
|
||||
return (
|
||||
<span
|
||||
style={{ cursor: 'pointer' }}
|
||||
// onClick={() => setEditingRowIndex(index)}
|
||||
>
|
||||
{record.jobtitle}
|
||||
</span>
|
||||
@@ -287,9 +293,7 @@ const RatecardDrawer = ({
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={async () => {
|
||||
if (index) {
|
||||
handleDeleteRole(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Delete">
|
||||
@@ -299,22 +303,43 @@ const RatecardDrawer = ({
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
|
||||
),
|
||||
},
|
||||
];
|
||||
const handleDrawerClose = async () => {
|
||||
if (
|
||||
drawerRatecard &&
|
||||
(drawerRatecard.jobRolesList?.length === 0 || !drawerRatecard.jobRolesList) &&
|
||||
(name === 'Untitled Rate Card' || name === '' || name === undefined)
|
||||
) {
|
||||
await dispatch(deleteRateCard(drawerRatecard.id as string));
|
||||
}
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
if (!name || name.trim() === '' || name === 'Untitled Rate Card') {
|
||||
messageApi.open({
|
||||
type: 'warning',
|
||||
content: t('ratecardNameRequired') || 'Rate card name is required.',
|
||||
});
|
||||
return;
|
||||
} else if (hasChanges) {
|
||||
setShowUnsavedAlert(true);
|
||||
} else {
|
||||
dispatch(toggleRatecardDrawer());
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmSave = async () => {
|
||||
await handleSave();
|
||||
setShowUnsavedAlert(false);
|
||||
};
|
||||
|
||||
const handleConfirmDiscard = () => {
|
||||
dispatch(toggleRatecardDrawer());
|
||||
setRoles([]);
|
||||
setName('Untitled Rate Card');
|
||||
setCurrency('USD');
|
||||
setInitialRoles([]);
|
||||
setInitialName('Untitled Rate Card');
|
||||
setInitialCurrency('USD');
|
||||
setShowUnsavedAlert(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Drawer
|
||||
loading={drawerLoading}
|
||||
onClose={handleDrawerClose}
|
||||
@@ -348,7 +373,6 @@ const RatecardDrawer = ({
|
||||
]}
|
||||
onChange={(value) => setCurrency(value)}
|
||||
/>
|
||||
{/* Add All Button */}
|
||||
<Button onClick={handleAddAllRoles} type="default">
|
||||
{t('addAllButton') || 'Add All'}
|
||||
</Button>
|
||||
@@ -359,11 +383,32 @@ const RatecardDrawer = ({
|
||||
width={700}
|
||||
footer={
|
||||
<Flex justify="end" gap={16} style={{ marginTop: 16 }}>
|
||||
<Button style={{ marginBottom: 24 }} onClick={handleSave} type="primary" disabled={name === '' || name === 'Untitled Rate Card' && roles.length === 0}>{t('saveButton')}</Button>
|
||||
<Button style={{ marginBottom: 24 }} onClick={handleSave} type="primary" disabled={name === '' || (name === 'Untitled Rate Card' && roles.length === 0)}>
|
||||
{t('saveButton')}
|
||||
</Button>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
{/* ratecard Table directly inside the Drawer */}
|
||||
{showUnsavedAlert && (
|
||||
<Alert
|
||||
message={t('unsavedChangesTitle') || 'Unsaved Changes'}
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setShowUnsavedAlert(false)}
|
||||
action={
|
||||
<Space direction="horizontal">
|
||||
<Button size="small" type="primary" onClick={handleConfirmSave}>
|
||||
Save
|
||||
</Button>
|
||||
<Button size="small" danger onClick={handleConfirmDiscard}>
|
||||
Discard
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
<Table
|
||||
dataSource={roles}
|
||||
columns={columns}
|
||||
@@ -381,6 +426,8 @@ const RatecardDrawer = ({
|
||||
)}
|
||||
/>
|
||||
</Drawer>
|
||||
</>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
deleteProjectRateCardRoleById,
|
||||
fetchProjectRateCardRoles,
|
||||
insertProjectRateCardRole,
|
||||
updateProjectRateCardRoleById,
|
||||
updateProjectRateCardRolesByProjectId,
|
||||
} from '@/features/finance/project-finance-slice';
|
||||
import { useParams } from 'react-router-dom';
|
||||
@@ -18,6 +19,7 @@ import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.se
|
||||
import RateCardAssigneeSelector from '@/components/project-ratecard/ratecard-assignee-selector';
|
||||
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||
import { IProjectMemberViewModel } from '@/types/projectMember.types';
|
||||
import { parse } from 'path';
|
||||
|
||||
const RatecardTable: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -226,6 +228,17 @@ const RatecardTable: React.FC = () => {
|
||||
textAlign: 'right',
|
||||
}}
|
||||
onChange={(e) => handleRateChange(e.target.value, index)}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value !== roles[index].rate) {
|
||||
dispatch(updateProjectRateCardRoleById({
|
||||
id: roles[index].id!,
|
||||
body: {
|
||||
job_title_id: roles[index].job_title_id,
|
||||
rate: e.target.value,
|
||||
}
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -42,7 +42,7 @@ const RatecardSettings: React.FC = () => {
|
||||
const { t } = useTranslation('/settings/ratecard-settings');
|
||||
const dispatch = useAppDispatch();
|
||||
useDocumentTitle('Manage Rate Cards');
|
||||
|
||||
const isDrawerOpen = useAppSelector(state => state.financeReducer.isRatecardDrawerOpen);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -87,7 +87,7 @@ const RatecardSettings: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchRateCards();
|
||||
}, [toggleRatecardDrawer, dispatch]);
|
||||
}, [toggleRatecardDrawer, isDrawerOpen]);
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user