feat(ratecard): enhance ratecard update logic and add unsaved changes alert

This commit is contained in:
shancds
2025-05-27 12:42:34 +05:30
parent dcb4ff1eb0
commit f22a91b690
5 changed files with 118 additions and 39 deletions

View File

@@ -97,10 +97,26 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
const { id } = req.params; const { id } = req.params;
const { job_title_id, rate } = req.body; const { job_title_id, rate } = req.body;
const q = ` const q = `
WITH updated AS (
UPDATE finance_project_rate_card_roles UPDATE finance_project_rate_card_roles
SET job_title_id = $1, rate = $2, updated_at = NOW() SET job_title_id = $1, rate = $2, updated_at = NOW()
WHERE id = $3 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]); const result = await db.query(q, [job_title_id, rate, id]);
return res.status(200).send(new ServerResponse(true, result.rows[0])); return res.status(200).send(new ServerResponse(true, result.rows[0]));

View File

@@ -20,6 +20,9 @@
"actionsColumn": "Actions", "actionsColumn": "Actions",
"addAllButton": "Add All", "addAllButton": "Add All",
"removeAllButton": "Remove All", "removeAllButton": "Remove All",
"selectJobTitle": "Select job title" "selectJobTitle": "Select job title",
"unsavedChangesTitle": "Unsaved changes",
"ratecardNameRequired": "Rate card name is required"
} }

View File

@@ -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 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';
@@ -19,6 +19,7 @@ interface PaginationType {
pageSizeOptions: string[]; pageSizeOptions: string[];
size: 'small' | 'default'; size: 'small' | 'default';
} }
const RatecardDrawer = ({ const RatecardDrawer = ({
type, type,
ratecardId, ratecardId,
@@ -29,11 +30,12 @@ const RatecardDrawer = ({
onSaved?: () => void; onSaved?: () => void;
}) => { }) => {
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]); const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
// initial Job Roles List (dummy data)
const [roles, setRoles] = useState<IJobType[]>([]); 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 [addingRowIndex, setAddingRowIndex] = useState<number | null>(null);
const { t } = useTranslation('settings/ratecard-settings'); const { t } = useTranslation('settings/ratecard-settings');
// get drawer state from client reducer
const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading); const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading);
const drawerRatecard = useAppSelector(state => state.financeReducer.drawerRatecard); const drawerRatecard = useAppSelector(state => state.financeReducer.drawerRatecard);
const isDrawerOpen = useAppSelector( const isDrawerOpen = useAppSelector(
@@ -57,6 +59,15 @@ const RatecardDrawer = ({
size: 'small', size: 'small',
}); });
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null); 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(() => { const getJobTitles = useMemo(() => {
return async () => { return async () => {
@@ -74,12 +85,10 @@ const RatecardDrawer = ({
}; };
}, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery]); }, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery]);
// fetch rate cards data
useEffect(() => { useEffect(() => {
getJobTitles(); getJobTitles();
}, []); }, []);
// get currently selected ratecard
const selectedRatecard = ratecardsList.find( const selectedRatecard = ratecardsList.find(
(ratecard) => ratecard.id === ratecardId (ratecard) => ratecard.id === ratecardId
); );
@@ -88,24 +97,22 @@ const RatecardDrawer = ({
if (type === 'update' && ratecardId) { if (type === 'update' && ratecardId) {
dispatch(fetchRateCardById(ratecardId)); dispatch(fetchRateCardById(ratecardId));
} }
// ...reset logic for create...
}, [type, ratecardId, dispatch]); }, [type, ratecardId, dispatch]);
useEffect(() => { useEffect(() => {
if (type === 'update' && drawerRatecard) { if (type === 'update' && drawerRatecard) {
setRoles(drawerRatecard.jobRolesList || []); setRoles(drawerRatecard.jobRolesList || []);
setInitialRoles(drawerRatecard.jobRolesList || []);
setName(drawerRatecard.name || ''); setName(drawerRatecard.name || '');
setInitialName(drawerRatecard.name || '');
setCurrency(drawerRatecard.currency || 'USD'); setCurrency(drawerRatecard.currency || 'USD');
setInitialCurrency(drawerRatecard.currency || 'USD');
} }
}, [drawerRatecard, type]); }, [drawerRatecard, type]);
// Add All handler
const handleAddAllRoles = () => { const handleAddAllRoles = () => {
if (!jobTitles.data) return; if (!jobTitles.data) return;
// Get current job_title_ids in roles
const existingIds = new Set(roles.map(r => r.job_title_id)); const existingIds = new Set(roles.map(r => r.job_title_id));
// Only add job titles not already present
const newRoles = jobTitles.data const newRoles = jobTitles.data
.filter(jt => jt.id && !existingIds.has(jt.id)) .filter(jt => jt.id && !existingIds.has(jt.id))
.map(jt => ({ .map(jt => ({
@@ -114,7 +121,6 @@ const RatecardDrawer = ({
job_title_id: jt.id!, job_title_id: jt.id!,
rate: 0, rate: 0,
})); }));
// Prevent any accidental duplicates by merging and filtering again
const mergedRoles = [...roles, ...newRoles].filter( const mergedRoles = [...roles, ...newRoles].filter(
(role, idx, arr) => (role, idx, arr) =>
arr.findIndex(r => r.job_title_id === role.job_title_id) === idx arr.findIndex(r => r.job_title_id === role.job_title_id) === idx
@@ -122,7 +128,6 @@ const RatecardDrawer = ({
setRoles(mergedRoles); setRoles(mergedRoles);
}; };
const handleAddRole = () => { const handleAddRole = () => {
const existingIds = new Set(roles.map(r => r.job_title_id)); const existingIds = new Set(roles.map(r => r.job_title_id));
const availableJobTitles = jobTitles.data?.filter(jt => !existingIds.has(jt.id!)); const availableJobTitles = jobTitles.data?.filter(jt => !existingIds.has(jt.id!));
@@ -138,8 +143,8 @@ const RatecardDrawer = ({
updatedRoles.splice(index, 1); updatedRoles.splice(index, 1);
setRoles(updatedRoles); setRoles(updatedRoles);
}; };
const handleSelectJobTitle = (jobTitleId: string) => { const handleSelectJobTitle = (jobTitleId: string) => {
// Prevent duplicate job_title_id
if (roles.some(role => role.job_title_id === jobTitleId)) { if (roles.some(role => role.job_title_id === jobTitleId)) {
setIsAddingRole(false); setIsAddingRole(false);
setSelectedJobTitleId(undefined); setSelectedJobTitleId(undefined);
@@ -162,7 +167,6 @@ const RatecardDrawer = ({
const handleSave = async () => { const handleSave = async () => {
if (type === 'update' && ratecardId) { if (type === 'update' && ratecardId) {
try { try {
// Filter out roles with no jobtitle or empty jobtitle
const filteredRoles = roles.filter(role => role.jobtitle && role.jobtitle.trim() !== ''); const filteredRoles = roles.filter(role => role.jobtitle && role.jobtitle.trim() !== '');
await dispatch(updateRateCard({ await dispatch(updateRateCard({
id: ratecardId, id: ratecardId,
@@ -172,7 +176,6 @@ const RatecardDrawer = ({
jobRolesList: filteredRoles, jobRolesList: filteredRoles,
}, },
}) as any); }) as any);
// Refresh the rate cards list in Redux
await dispatch(fetchRateCards({ await dispatch(fetchRateCards({
index: 1, index: 1,
size: 10, size: 10,
@@ -182,18 +185,24 @@ const RatecardDrawer = ({
}) as any); }) as any);
if (onSaved) onSaved(); if (onSaved) onSaved();
dispatch(toggleRatecardDrawer()); dispatch(toggleRatecardDrawer());
// Reset initial states after save
setInitialRoles(filteredRoles);
setInitialName(name);
setInitialCurrency(currency);
setShowUnsavedAlert(false);
} catch (error) { } catch (error) {
console.error('Failed to update rate card', error); console.error('Failed to update rate card', error);
} finally { } finally {
setRoles([]); setRoles([]);
setName('Untitled Rate Card'); setName('Untitled Rate Card');
setCurrency('USD'); setCurrency('USD');
setInitialRoles([]);
setInitialName('Untitled Rate Card');
setInitialCurrency('USD');
} }
} }
}; };
// table columns
const columns = [ const columns = [
{ {
title: t('jobTitleColumn'), title: t('jobTitleColumn'),
@@ -208,7 +217,6 @@ const RatecardDrawer = ({
style={{ minWidth: 150 }} style={{ minWidth: 150 }}
value={record.job_title_id || undefined} value={record.job_title_id || undefined}
onChange={value => { onChange={value => {
// Prevent duplicate job_title_id
if (roles.some((role, idx) => role.job_title_id === value && idx !== index)) { if (roles.some((role, idx) => role.job_title_id === value && idx !== index)) {
return; return;
} }
@@ -241,11 +249,9 @@ const RatecardDrawer = ({
</Select> </Select>
); );
} }
// Render as clickable text for existing rows
return ( return (
<span <span
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
// onClick={() => setEditingRowIndex(index)}
> >
{record.jobtitle} {record.jobtitle}
</span> </span>
@@ -287,9 +293,7 @@ const RatecardDrawer = ({
okText={t('deleteConfirmationOk')} okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')} cancelText={t('deleteConfirmationCancel')}
onConfirm={async () => { onConfirm={async () => {
if (index) { handleDeleteRole(index);
handleDeleteRole(index);
}
}} }}
> >
<Tooltip title="Delete"> <Tooltip title="Delete">
@@ -299,22 +303,43 @@ const RatecardDrawer = ({
/> />
</Tooltip> </Tooltip>
</Popconfirm> </Popconfirm>
), ),
}, },
]; ];
const handleDrawerClose = async () => {
if ( const handleDrawerClose = () => {
drawerRatecard && if (!name || name.trim() === '' || name === 'Untitled Rate Card') {
(drawerRatecard.jobRolesList?.length === 0 || !drawerRatecard.jobRolesList) && messageApi.open({
(name === 'Untitled Rate Card' || name === '' || name === undefined) type: 'warning',
) { content: t('ratecardNameRequired') || 'Rate card name is required.',
await dispatch(deleteRateCard(drawerRatecard.id as string)); });
return;
} else if (hasChanges) {
setShowUnsavedAlert(true);
} else {
dispatch(toggleRatecardDrawer());
} }
};
const handleConfirmSave = async () => {
await handleSave();
setShowUnsavedAlert(false);
};
const handleConfirmDiscard = () => {
dispatch(toggleRatecardDrawer()); dispatch(toggleRatecardDrawer());
setRoles([]);
setName('Untitled Rate Card');
setCurrency('USD');
setInitialRoles([]);
setInitialName('Untitled Rate Card');
setInitialCurrency('USD');
setShowUnsavedAlert(false);
}; };
return ( return (
<>
{contextHolder}
<Drawer <Drawer
loading={drawerLoading} loading={drawerLoading}
onClose={handleDrawerClose} onClose={handleDrawerClose}
@@ -348,7 +373,6 @@ const RatecardDrawer = ({
]} ]}
onChange={(value) => setCurrency(value)} onChange={(value) => setCurrency(value)}
/> />
{/* Add All Button */}
<Button onClick={handleAddAllRoles} type="default"> <Button onClick={handleAddAllRoles} type="default">
{t('addAllButton') || 'Add All'} {t('addAllButton') || 'Add All'}
</Button> </Button>
@@ -359,11 +383,32 @@ const RatecardDrawer = ({
width={700} width={700}
footer={ footer={
<Flex justify="end" gap={16} style={{ marginTop: 16 }}> <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> </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 <Table
dataSource={roles} dataSource={roles}
columns={columns} columns={columns}
@@ -381,7 +426,9 @@ const RatecardDrawer = ({
)} )}
/> />
</Drawer> </Drawer>
</>
); );
}; };
export default RatecardDrawer; export default RatecardDrawer;

View File

@@ -11,6 +11,7 @@ import {
deleteProjectRateCardRoleById, deleteProjectRateCardRoleById,
fetchProjectRateCardRoles, fetchProjectRateCardRoles,
insertProjectRateCardRole, insertProjectRateCardRole,
updateProjectRateCardRoleById,
updateProjectRateCardRolesByProjectId, updateProjectRateCardRolesByProjectId,
} from '@/features/finance/project-finance-slice'; } from '@/features/finance/project-finance-slice';
import { useParams } from 'react-router-dom'; 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 RateCardAssigneeSelector from '@/components/project-ratecard/ratecard-assignee-selector';
import { projectsApiService } from '@/api/projects/projects.api.service'; import { projectsApiService } from '@/api/projects/projects.api.service';
import { IProjectMemberViewModel } from '@/types/projectMember.types'; import { IProjectMemberViewModel } from '@/types/projectMember.types';
import { parse } from 'path';
const RatecardTable: React.FC = () => { const RatecardTable: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -226,6 +228,17 @@ const RatecardTable: React.FC = () => {
textAlign: 'right', textAlign: 'right',
}} }}
onChange={(e) => handleRateChange(e.target.value, index)} 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,
}
}));
}
}}
/> />
), ),
}, },

View File

@@ -42,7 +42,7 @@ const RatecardSettings: React.FC = () => {
const { t } = useTranslation('/settings/ratecard-settings'); const { t } = useTranslation('/settings/ratecard-settings');
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useDocumentTitle('Manage Rate Cards'); useDocumentTitle('Manage Rate Cards');
const isDrawerOpen = useAppSelector(state => state.financeReducer.isRatecardDrawerOpen);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]); const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -87,7 +87,7 @@ const RatecardSettings: React.FC = () => {
useEffect(() => { useEffect(() => {
fetchRateCards(); fetchRateCards();
}, [toggleRatecardDrawer, dispatch]); }, [toggleRatecardDrawer, isDrawerOpen]);