diff --git a/worklenz-backend/src/controllers/project-ratecard-controller.ts b/worklenz-backend/src/controllers/project-ratecard-controller.ts
index c27ca765..82d5b5ff 100644
--- a/worklenz-backend/src/controllers/project-ratecard-controller.ts
+++ b/worklenz-backend/src/controllers/project-ratecard-controller.ts
@@ -22,7 +22,8 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
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(",")}
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 result = await db.query(q, flatValues);
@@ -94,7 +95,8 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
const q = `
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(",")}
- 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 result = await db.query(q, flatValues);
diff --git a/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx b/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx
index 4a3ebd08..ff44f9f0 100644
--- a/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx
+++ b/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx
@@ -10,7 +10,7 @@ import { useParams } from 'react-router-dom';
const ImportRatecardsDrawer: React.FC = () => {
const dispatch = useAppDispatch();
-const { projectId } = useParams();
+ const { projectId } = useParams();
const { t } = useTranslation('project-view-finance');
const drawerRatecard = useAppSelector(
@@ -26,6 +26,8 @@ const { projectId } = useParams();
(state) => state.financeReducer.currency
).toUpperCase();
+ const rolesRedux = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || [];
+
// Loading states
const isRatecardsLoading = useAppSelector(
(state) => state.financeReducer.isRatecardsLoading
@@ -83,31 +85,32 @@ const { projectId } = useParams();
}
footer={
-
+
}
open={isDrawerOpen}
diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx
index 6119c8b9..4ec0ee07 100644
--- a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx
@@ -18,7 +18,7 @@ const RatecardTab = () => {
>
{t('ratecardImportantNotice')}
-
+ */}
{/* import ratecards drawer */}
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 141208c0..90517fbe 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
@@ -1,56 +1,176 @@
-import { Avatar, Button, Input, Popconfirm, Table, TableProps } from 'antd';
-import React, { useEffect } from 'react';
+import { Avatar, Button, Input, Popconfirm, Table, TableProps, Select, Flex } from 'antd';
+import React, { useEffect, useState } from 'react';
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 { useAppDispatch } from '../../../../../../hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
-import { JobRoleType } from '@/types/project/ratecard.types';
-import { deleteProjectRateCardRoleById, fetchProjectRateCardRoles } from '@/features/finance/project-finance-slice';
+import { JobRoleType, IJobType } from '@/types/project/ratecard.types';
+import {
+ deleteProjectRateCardRoleById,
+ fetchProjectRateCardRoles,
+ updateProjectRateCardRolesByProjectId,
+} from '@/features/finance/project-finance-slice';
import { useParams } from 'react-router-dom';
+import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
const RatecardTable: React.FC = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('project-view-finance');
const { projectId } = useParams();
- // Fetch roles from Redux
- const roles = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || [];
+ // Redux state
+ const rolesRedux = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || [];
const isLoading = useAppSelector((state) => state.projectFinanceRateCard.isLoading);
+ const currency = useAppSelector((state) => state.financeReducer.currency).toUpperCase();
- // get currently using currency from finance reducer
- const currency = useAppSelector(
- (state) => state.financeReducer.currency
- ).toUpperCase();
+ // Local state for editing
+ const [roles, setRoles] = useState(rolesRedux);
+ const [addingRow, setAddingRow] = useState(false);
+ const [editingIndex, setEditingIndex] = useState(null);
+ const [jobTitles, setJobTitles] = useState([]);
+ // 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(() => {
if (projectId) {
dispatch(fetchProjectRateCardRoles(projectId));
}
}, [dispatch, projectId]);
+ // Add new role row
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['columns'] = [
{
title: t('jobTitleColumn'),
dataIndex: 'jobtitle',
- render: (text: string) => (
- {text}
- ),
+ render: (text: string, record: JobRoleType, index: number) => {
+ // Only show Select if addingRow and this is the last row (new row)
+ if (addingRow && index === roles.length) {
+ return (
+
+ );
+ }
+ return (
+ setEditingIndex(index)}
+ >
+ {text || record.name}
+
+ );
+ },
},
{
title: `${t('ratePerHourColumn')} (${currency})`,
dataIndex: 'rate',
- render: (text: number) => {text},
+ render: (value: number, record: JobRoleType, index: number) => (
+ handleRateChange(e.target.value, index)}
+ />
+ ),
},
{
title: t('membersColumn'),
dataIndex: 'members',
- render: (members: string[]) =>
- members?.length > 0 ? (
+ render: (members: string[] | null | undefined) =>
+ members && members.length > 0 ? (
{members.map((member, i) => (
@@ -77,14 +197,10 @@ const RatecardTable: React.FC = () => {
{
title: t('actions'),
key: 'actions',
- render: (_: any, record: JobRoleType) => (
+ render: (_: any, record: JobRoleType, index: number) => (
{
- if (record.id) {
- dispatch(deleteProjectRateCardRoleById(record.id));
- }
- }}
+ onConfirm={() => handleDelete(record, index)}
okText={t('yes')}
cancelText={t('no')}
>
@@ -100,19 +216,41 @@ const RatecardTable: React.FC = () => {
return (
record.id || record.job_title_id}
+ rowKey={(record, idx) => record.id || record.job_title_id || idx}
pagination={false}
loading={isLoading}
footer={() => (
-
+
+
+ }
+ onClick={handleSaveAll}
+ disabled={roles.length === 0}
+ >
+ {t('saveButton') || 'Save'}
+
+
)}
/>
);
diff --git a/worklenz-frontend/src/types/project/ratecard.types.ts b/worklenz-frontend/src/types/project/ratecard.types.ts
index 5d29681c..67eb7ee8 100644
--- a/worklenz-frontend/src/types/project/ratecard.types.ts
+++ b/worklenz-frontend/src/types/project/ratecard.types.ts
@@ -7,6 +7,7 @@ export interface IJobType {
rate_card_id?: string;
job_title_id: string;
rate?: number;
+ name?: string;
};
export interface JobRoleType extends IJobType {
members?: string[] | null;