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={() => ( - + + + + )} /> ); 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;