From f22a91b690576b1bacabf5594844894a7e093283 Mon Sep 17 00:00:00 2001 From: shancds Date: Tue, 27 May 2025 12:42:34 +0530 Subject: [PATCH] feat(ratecard): enhance ratecard update logic and add unsaved changes alert --- .../project-ratecard-controller.ts | 18 ++- .../en/settings/ratecard-settings.json | 5 +- .../ratecard-drawer/ratecard-drawer.tsx | 117 ++++++++++++------ .../reatecard-table/ratecard-table.tsx | 13 ++ .../settings/ratecard/ratecard-settings.tsx | 4 +- 5 files changed, 118 insertions(+), 39 deletions(-) diff --git a/worklenz-backend/src/controllers/project-ratecard-controller.ts b/worklenz-backend/src/controllers/project-ratecard-controller.ts index 42c0edf4..8da36de6 100644 --- a/worklenz-backend/src/controllers/project-ratecard-controller.ts +++ b/worklenz-backend/src/controllers/project-ratecard-controller.ts @@ -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])); diff --git a/worklenz-frontend/public/locales/en/settings/ratecard-settings.json b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json index e42e9883..5e2c984b 100644 --- a/worklenz-frontend/public/locales/en/settings/ratecard-settings.json +++ b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json @@ -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" + } diff --git a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx index 2855cea0..f3645752 100644 --- a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx @@ -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([]); - // initial Job Roles List (dummy data) const [roles, setRoles] = useState([]); + const [initialRoles, setInitialRoles] = useState([]); + const [initialName, setInitialName] = useState('Untitled Rate Card'); + const [initialCurrency, setInitialCurrency] = useState('USD'); const [addingRowIndex, setAddingRowIndex] = useState(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(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 = ({ ); } - // Render as clickable text for existing rows return ( setEditingRowIndex(index)} > {record.jobtitle} @@ -287,9 +293,7 @@ const RatecardDrawer = ({ okText={t('deleteConfirmationOk')} cancelText={t('deleteConfirmationCancel')} onConfirm={async () => { - if (index) { - handleDeleteRole(index); - } + handleDeleteRole(index); }} > @@ -299,22 +303,43 @@ const RatecardDrawer = ({ /> - ), }, ]; - 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} setCurrency(value)} /> - {/* Add All Button */} @@ -359,11 +383,32 @@ const RatecardDrawer = ({ width={700} footer={ - + } > - {/* ratecard Table directly inside the Drawer */} + {showUnsavedAlert && ( + setShowUnsavedAlert(false)} + action={ + + + + + } + style={{ marginBottom: 16 }} + /> + )} + + ); }; -export default RatecardDrawer; +export default RatecardDrawer; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx index 410aa1ca..9413e05d 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx @@ -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, + } + })); + } + }} /> ), }, diff --git a/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx b/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx index d236e91a..19a00b6d 100644 --- a/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx +++ b/worklenz-frontend/src/pages/settings/ratecard/ratecard-settings.tsx @@ -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([]); const [searchQuery, setSearchQuery] = useState(''); @@ -87,7 +87,7 @@ const RatecardSettings: React.FC = () => { useEffect(() => { fetchRateCards(); - }, [toggleRatecardDrawer, dispatch]); + }, [toggleRatecardDrawer, isDrawerOpen]);