diff --git a/worklenz-backend/src/controllers/project-ratecard-controller.ts b/worklenz-backend/src/controllers/project-ratecard-controller.ts index 82d5b5ff..99543712 100644 --- a/worklenz-backend/src/controllers/project-ratecard-controller.ts +++ b/worklenz-backend/src/controllers/project-ratecard-controller.ts @@ -6,6 +6,24 @@ import HandleExceptions from "../decorators/handle-exceptions"; import WorklenzControllerBase from "./worklenz-controller-base"; export default class ProjectRateCardController extends WorklenzControllerBase { + + // Insert a single role for a project +@HandleExceptions() +public static async insertOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { project_id, job_title_id, rate } = req.body; + if (!project_id || !job_title_id || typeof rate !== "number") { + return res.status(400).send(new ServerResponse(false, null, "Invalid input")); + } + const q = ` + INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate) + VALUES ($1, $2, $3) + ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate + RETURNING *, + (SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle; + `; + const result = await db.query(q, [project_id, job_title_id, rate]); + return res.status(200).send(new ServerResponse(true, result.rows[0])); +} // Insert multiple roles for a project @HandleExceptions() public static async insertMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { @@ -81,12 +99,11 @@ export default class ProjectRateCardController extends WorklenzControllerBase { if (!Array.isArray(roles) || !project_id) { return res.status(400).send(new ServerResponse(false, null, "Invalid input")); } - // Delete existing - await db.query(`DELETE FROM finance_project_rate_card_roles WHERE project_id = $1`, [project_id]); - // Insert new if (roles.length === 0) { + // If no roles provided, do nothing and return empty array return res.status(200).send(new ServerResponse(true, [])); } + // Build upsert query for all roles const values = roles.map((role: any) => [ project_id, role.job_title_id, @@ -95,8 +112,9 @@ 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(",")} + ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate, updated_at = NOW() RETURNING *, - (SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle; + (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-backend/src/routes/apis/project-ratecard-api-router.ts b/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts index d056e368..2ed31118 100644 --- a/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts +++ b/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts @@ -12,6 +12,12 @@ projectRatecardApiRouter.post( projectManagerValidator, safeControllerFunction(ProjectRateCardController.insertMany) ); +// Insert a single role for a project +projectRatecardApiRouter.post( + "/create-project-rate-card-role", + projectManagerValidator, + safeControllerFunction(ProjectRateCardController.insertOne) +); // Get all roles for a project projectRatecardApiRouter.get( diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts index a4ba6b5e..df2072cb 100644 --- a/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts @@ -21,6 +21,14 @@ export const projectRateCardApiService = { const response = await apiClient.post>(rootUrl, { project_id, roles }); return response.data; }, + // Insert a single role for a project + async insertOne({ project_id, job_title_id, rate }: { project_id: string; job_title_id: string; rate: number }): Promise> { + const response = await apiClient.post>( + `${rootUrl}/create-project-rate-card-role`, + { project_id, job_title_id, rate } + ); + return response.data; + }, // Get all roles for a project async getFromProjectId(project_id: string): Promise> { diff --git a/worklenz-frontend/src/features/finance/project-finance-slice.ts b/worklenz-frontend/src/features/finance/project-finance-slice.ts index a2986872..d0f897dd 100644 --- a/worklenz-frontend/src/features/finance/project-finance-slice.ts +++ b/worklenz-frontend/src/features/finance/project-finance-slice.ts @@ -25,7 +25,6 @@ export const fetchProjectRateCardRoles = createAsyncThunk( async (project_id: string, { rejectWithValue }) => { try { const response = await projectRateCardApiService.getFromProjectId(project_id); - console.log('Project RateCard Roles:', response); return response.body; } catch (error) { logger.error('Fetch Project RateCard Roles', error); @@ -63,6 +62,23 @@ export const insertProjectRateCardRoles = createAsyncThunk( } ); +export const insertProjectRateCardRole = createAsyncThunk( + 'projectFinance/insertOne', + async ( + { project_id, job_title_id, rate }: { project_id: string; job_title_id: string; rate: number }, + { rejectWithValue } + ) => { + try { + const response = await projectRateCardApiService.insertOne({ project_id, job_title_id, rate }); + return response.body; + } catch (error) { + logger.error('Insert Project RateCard Role', error); + if (error instanceof Error) return rejectWithValue(error.message); + return rejectWithValue('Failed to insert project rate card role'); + } + } +); + export const updateProjectRateCardRoleById = createAsyncThunk( 'projectFinance/updateById', async ({ id, body }: { id: string; body: { job_title_id: string; rate: string } }, { rejectWithValue }) => { 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 e6505ea5..b38545d6 100644 --- a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx @@ -253,6 +253,7 @@ const RatecardDrawer = ({ render: (text: number, record: any, index: number) => ( { // Sync local roles with redux roles useEffect(() => { - console.log('Roles Redux:', rolesRedux); setRoles(rolesRedux); - }, [rolesRedux]); + }, [rolesRedux, dispatch]); // Fetch roles on mount useEffect(() => { @@ -72,20 +72,29 @@ const RatecardTable: React.FC = () => { }; // Handle job title select for new row - const handleSelectJobTitle = (jobTitleId: string) => { + const handleSelectJobTitle = async(jobTitleId: string) => { const jobTitle = jobTitles.find((jt) => jt.id === jobTitleId); - if (!jobTitle) return; + if (!jobTitle || !projectId) 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: [], - }, - ]); + // Dispatch and wait for result + const resultAction = await dispatch( + insertProjectRateCardRole({ project_id: projectId, job_title_id: jobTitleId, rate: 0 }) + ); + + // If fulfilled, update local state with returned id + 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, + }, + ]); + } setAddingRow(false); }; @@ -221,14 +230,14 @@ const RatecardTable: React.FC = () => { dataSource={ addingRow ? [ - ...roles, - { - job_title_id: '', - jobtitle: '', - rate: 0, - members: [], - }, - ] + ...roles, + { + job_title_id: '', + jobtitle: '', + rate: 0, + members: [], + }, + ] : roles } columns={columns}