Merge pull request #140 from shancds/feature/project-finance

Feature/project finance
This commit is contained in:
Chamika J
2025-05-27 14:50:17 +05:30
committed by GitHub
5 changed files with 145 additions and 60 deletions

View File

@@ -57,14 +57,14 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
fprr.*,
jt.name as jobtitle,
(
SELECT COALESCE(json_agg(pm.id), '[]'::json)
FROM project_members pm
WHERE pm.project_rate_card_role_id = fprr.id
SELECT COALESCE(json_agg(pm.id), '[]'::json)
FROM project_members pm
WHERE pm.project_rate_card_role_id = fprr.id
) AS members
FROM finance_project_rate_card_roles fprr
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE fprr.project_id = $1
ORDER BY jt.name;
ORDER BY fprr.created_at;
`;
const result = await db.query(q, [project_id]);
return res.status(200).send(new ServerResponse(true, result.rows));
@@ -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]));

View File

@@ -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"
}

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 { 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);
}
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,7 +426,9 @@ const RatecardDrawer = ({
)}
/>
</Drawer>
</>
);
};
export default RatecardDrawer;
export default RatecardDrawer;

View File

@@ -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();
@@ -28,13 +30,14 @@ const RatecardTable: React.FC = () => {
const rolesRedux = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || [];
const isLoading = useAppSelector((state) => state.projectFinanceRateCard.isLoading);
const currency = useAppSelector((state) => state.financeReducer.currency).toUpperCase();
const rateInputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
// Local state for editing
const [roles, setRoles] = useState<JobRoleType[]>(rolesRedux);
const [addingRow, setAddingRow] = useState<boolean>(false);
const [jobTitles, setJobTitles] = useState<RatecardType[]>([]);
const [members, setMembers] = useState<IProjectMemberViewModel[]>([]);
const [isLoadingMembers, setIsLoading] = useState(false);
const [focusRateIndex, setFocusRateIndex] = useState<number | null>(null);
const pagination = {
current: 1,
@@ -89,6 +92,13 @@ const RatecardTable: React.FC = () => {
}
}, [dispatch, projectId]);
useEffect(() => {
if (focusRateIndex !== null && rateInputRefs.current[focusRateIndex]) {
rateInputRefs.current[focusRateIndex]?.focus();
setFocusRateIndex(null);
}
}, [roles, focusRateIndex]);
// Add new role row
const handleAddRole = () => {
setAddingRow(true);
@@ -108,7 +118,7 @@ const RatecardTable: React.FC = () => {
}
};
// Handle job title select for new row
// In handleSelectJobTitle, after successful insert, update the rate if needed
const handleSelectJobTitle = async (jobTitleId: string) => {
const jobTitle = jobTitles.find((jt) => jt.id === jobTitleId);
if (!jobTitle || !projectId) return;
@@ -118,27 +128,21 @@ const RatecardTable: React.FC = () => {
);
if (insertProjectRateCardRole.fulfilled.match(resultAction)) {
const newRole = resultAction.payload;
setRoles([
...roles,
{
id: newRole.id,
job_title_id: newRole.job_title_id,
jobtitle: newRole.jobtitle,
rate: newRole.rate,
members: [], // Initialize members array
},
]);
// Re-fetch roles and focus the last one (newly added)
dispatch(fetchProjectRateCardRoles(projectId)).then(() => {
setFocusRateIndex(roles.length); // The new row will be at the end
});
}
setAddingRow(false);
};
// Handle rate change
// Update handleRateChange to always update local state
const handleRateChange = (value: string | number, index: number) => {
const updatedRoles = roles.map((role, idx) =>
idx === index ? { ...role, rate: Number(value) } : role
setRoles(prev =>
prev.map((role, idx) =>
idx === index ? { ...role, rate: Number(value) } : role
)
);
setRoles(updatedRoles);
};
// Handle delete
@@ -174,6 +178,18 @@ const RatecardTable: React.FC = () => {
console.error('Error assigning member:', error);
}
};
// Separate function for updating rate if changed
const handleRateBlur = (value: string, index: number) => {
if (value !== roles[index].rate) {
dispatch(updateProjectRateCardRoleById({
id: roles[index].id!,
body: {
job_title_id: roles[index].job_title_id,
rate: value,
}
}));
}
};
// Columns
const columns: TableProps<JobRoleType>['columns'] = [
@@ -214,6 +230,7 @@ const RatecardTable: React.FC = () => {
align: 'right',
render: (value: number, record: JobRoleType, index: number) => (
<Input
ref={el => rateInputRefs.current[index] = el}
type="number"
value={roles[index]?.rate ?? 0}
min={0}
@@ -226,6 +243,8 @@ const RatecardTable: React.FC = () => {
textAlign: 'right',
}}
onChange={(e) => handleRateChange(e.target.value, index)}
onBlur={(e) => handleRateBlur(e.target.value, index)}
onPressEnter={(e) => handleRateBlur(e.target.value, index)}
/>
),
},

View File

@@ -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]);