Merge pull request #137 from shancds/feature/project-finance
Feature/project finance (project-ratecard-member-add)
This commit is contained in:
@@ -45,4 +45,4 @@ ALTER TABLE projects
|
|||||||
ADD COLUMN rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL;
|
ADD COLUMN rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
ALTER TABLE finance_rate_cards
|
ALTER TABLE finance_rate_cards
|
||||||
ADD COLUMN currency TEXT NOT NULL DEFAULT 'LKR';
|
ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD';
|
||||||
|
|||||||
@@ -6,9 +6,27 @@ import HandleExceptions from "../decorators/handle-exceptions";
|
|||||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||||
|
|
||||||
export default class ProjectRateCardController extends WorklenzControllerBase {
|
export default class ProjectRateCardController extends WorklenzControllerBase {
|
||||||
|
|
||||||
|
// Insert a single role for a project
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async createOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
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
|
// Insert multiple roles for a project
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async insertMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async createMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const { project_id, roles } = req.body;
|
const { project_id, roles } = req.body;
|
||||||
if (!Array.isArray(roles) || !project_id) {
|
if (!Array.isArray(roles) || !project_id) {
|
||||||
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
||||||
@@ -32,10 +50,17 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
// Get all roles for a project
|
// Get all roles for a project
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async getFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async getByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const { project_id } = req.params;
|
const { project_id } = req.params;
|
||||||
const q = `
|
const q = `
|
||||||
SELECT fprr.*, jt.name as jobtitle
|
SELECT
|
||||||
|
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
|
||||||
|
) AS members
|
||||||
FROM finance_project_rate_card_roles fprr
|
FROM finance_project_rate_card_roles fprr
|
||||||
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
||||||
WHERE fprr.project_id = $1
|
WHERE fprr.project_id = $1
|
||||||
@@ -47,10 +72,17 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
// Get a single role by id
|
// Get a single role by id
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async getFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const q = `
|
const q = `
|
||||||
SELECT fprr.*, jt.name as jobtitle
|
SELECT
|
||||||
|
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
|
||||||
|
) AS members
|
||||||
FROM finance_project_rate_card_roles fprr
|
FROM finance_project_rate_card_roles fprr
|
||||||
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
||||||
WHERE fprr.id = $1;
|
WHERE fprr.id = $1;
|
||||||
@@ -61,7 +93,7 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
// Update a single role by id
|
// Update a single role by id
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async updateFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async updateById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { job_title_id, rate } = req.body;
|
const { job_title_id, rate } = req.body;
|
||||||
const q = `
|
const q = `
|
||||||
@@ -74,29 +106,120 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
|
|||||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update project member rate for a project with members
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async updateProjectMemberByProjectIdAndMemberId(
|
||||||
|
req: IWorkLenzRequest,
|
||||||
|
res: IWorkLenzResponse
|
||||||
|
): Promise<IWorkLenzResponse> {
|
||||||
|
const { project_id, id } = req.params;
|
||||||
|
const { project_rate_card_role_id } = req.body;
|
||||||
|
|
||||||
|
if (!project_id || !id || !project_rate_card_role_id) {
|
||||||
|
return res.status(400).send(new ServerResponse(false, null, "Missing values"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Check current role assignment
|
||||||
|
const checkQuery = `
|
||||||
|
SELECT project_rate_card_role_id
|
||||||
|
FROM project_members
|
||||||
|
WHERE id = $1 AND project_id = $2;
|
||||||
|
`;
|
||||||
|
const { rows: checkRows } = await db.query(checkQuery, [id, project_id]);
|
||||||
|
|
||||||
|
const currentRoleId = checkRows[0]?.project_rate_card_role_id;
|
||||||
|
|
||||||
|
if (currentRoleId !== null && currentRoleId !== project_rate_card_role_id) {
|
||||||
|
// Step 2: Fetch members with the requested role
|
||||||
|
const membersQuery = `
|
||||||
|
SELECT COALESCE(json_agg(id), '[]'::json) AS members
|
||||||
|
FROM project_members
|
||||||
|
WHERE project_id = $1 AND project_rate_card_role_id = $2;
|
||||||
|
`;
|
||||||
|
const { rows: memberRows } = await db.query(membersQuery, [project_id, project_rate_card_role_id]);
|
||||||
|
|
||||||
|
return res.status(200).send(
|
||||||
|
new ServerResponse(false, memberRows[0], "Already Assigned !")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Perform the update
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE project_members
|
||||||
|
SET project_rate_card_role_id = CASE
|
||||||
|
WHEN project_rate_card_role_id = $1 THEN NULL
|
||||||
|
ELSE $1
|
||||||
|
END
|
||||||
|
WHERE id = $2
|
||||||
|
AND project_id = $3
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM finance_project_rate_card_roles
|
||||||
|
WHERE id = $1 AND project_id = $3
|
||||||
|
)
|
||||||
|
RETURNING project_rate_card_role_id;
|
||||||
|
`;
|
||||||
|
const { rows: updateRows } = await db.query(updateQuery, [project_rate_card_role_id, id, project_id]);
|
||||||
|
|
||||||
|
if (updateRows.length === 0) {
|
||||||
|
return res.status(200).send(new ServerResponse(true, [], "Project member not found or invalid project_rate_card_role_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRoleId = updateRows[0].project_rate_card_role_id || project_rate_card_role_id;
|
||||||
|
|
||||||
|
// Step 4: Fetch updated members list
|
||||||
|
const membersQuery = `
|
||||||
|
SELECT COALESCE(json_agg(id), '[]'::json) AS members
|
||||||
|
FROM project_members
|
||||||
|
WHERE project_id = $1 AND project_rate_card_role_id = $2;
|
||||||
|
`;
|
||||||
|
const { rows: finalMembers } = await db.query(membersQuery, [project_id, updatedRoleId]);
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, finalMembers[0]));
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).send(new ServerResponse(false, null, "Internal server error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
// Update all roles for a project (delete then insert)
|
// Update all roles for a project (delete then insert)
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async updateFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async updateByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const { project_id, roles } = req.body;
|
const { project_id, roles } = req.body;
|
||||||
if (!Array.isArray(roles) || !project_id) {
|
if (!Array.isArray(roles) || !project_id) {
|
||||||
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
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 (roles.length === 0) {
|
||||||
|
// If no roles provided, do nothing and return empty array
|
||||||
return res.status(200).send(new ServerResponse(true, []));
|
return res.status(200).send(new ServerResponse(true, []));
|
||||||
}
|
}
|
||||||
|
// Build upsert query for all roles
|
||||||
const values = roles.map((role: any) => [
|
const values = roles.map((role: any) => [
|
||||||
project_id,
|
project_id,
|
||||||
role.job_title_id,
|
role.job_title_id,
|
||||||
role.rate
|
role.rate
|
||||||
]);
|
]);
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
|
WITH upserted AS (
|
||||||
VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")}
|
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
|
||||||
RETURNING *,
|
VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")}
|
||||||
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle;
|
ON CONFLICT (project_id, job_title_id)
|
||||||
|
DO UPDATE SET rate = EXCLUDED.rate, updated_at = NOW()
|
||||||
|
RETURNING *
|
||||||
|
),
|
||||||
|
jobtitles AS (
|
||||||
|
SELECT upr.*, jt.name AS jobtitle
|
||||||
|
FROM upserted upr
|
||||||
|
JOIN job_titles jt ON jt.id = upr.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 flatValues = values.flat();
|
const flatValues = values.flat();
|
||||||
const result = await db.query(q, flatValues);
|
const result = await db.query(q, flatValues);
|
||||||
@@ -105,7 +228,7 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
// Delete a single role by id
|
// Delete a single role by id
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async deleteFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`;
|
const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`;
|
||||||
const result = await db.query(q, [id]);
|
const result = await db.query(q, [id]);
|
||||||
@@ -114,7 +237,7 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
// Delete all roles for a project
|
// Delete all roles for a project
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async deleteFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async deleteByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const { project_id } = req.params;
|
const { project_id } = req.params;
|
||||||
const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`;
|
const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`;
|
||||||
const result = await db.query(q, [project_id]);
|
const result = await db.query(q, [project_id]);
|
||||||
|
|||||||
@@ -10,46 +10,60 @@ const projectRatecardApiRouter = express.Router();
|
|||||||
projectRatecardApiRouter.post(
|
projectRatecardApiRouter.post(
|
||||||
"/",
|
"/",
|
||||||
projectManagerValidator,
|
projectManagerValidator,
|
||||||
safeControllerFunction(ProjectRateCardController.insertMany)
|
safeControllerFunction(ProjectRateCardController.createMany)
|
||||||
|
);
|
||||||
|
// Insert a single role for a project
|
||||||
|
projectRatecardApiRouter.post(
|
||||||
|
"/create-project-rate-card-role",
|
||||||
|
projectManagerValidator,
|
||||||
|
safeControllerFunction(ProjectRateCardController.createOne)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get all roles for a project
|
// Get all roles for a project
|
||||||
projectRatecardApiRouter.get(
|
projectRatecardApiRouter.get(
|
||||||
"/project/:project_id",
|
"/project/:project_id",
|
||||||
safeControllerFunction(ProjectRateCardController.getFromProjectId)
|
safeControllerFunction(ProjectRateCardController.getByProjectId)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get a single role by id
|
// Get a single role by id
|
||||||
projectRatecardApiRouter.get(
|
projectRatecardApiRouter.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
idParamValidator,
|
idParamValidator,
|
||||||
safeControllerFunction(ProjectRateCardController.getFromId)
|
safeControllerFunction(ProjectRateCardController.getById)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update a single role by id
|
// Update a single role by id
|
||||||
projectRatecardApiRouter.put(
|
projectRatecardApiRouter.put(
|
||||||
"/:id",
|
"/:id",
|
||||||
idParamValidator,
|
idParamValidator,
|
||||||
safeControllerFunction(ProjectRateCardController.updateFromId)
|
safeControllerFunction(ProjectRateCardController.updateById)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update all roles for a project (delete then insert)
|
// Update all roles for a project (delete then insert)
|
||||||
projectRatecardApiRouter.put(
|
projectRatecardApiRouter.put(
|
||||||
"/project/:project_id",
|
"/project/:project_id",
|
||||||
safeControllerFunction(ProjectRateCardController.updateFromProjectId)
|
safeControllerFunction(ProjectRateCardController.updateByProjectId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update project member rate card role
|
||||||
|
projectRatecardApiRouter.put(
|
||||||
|
"/project/:project_id/members/:id/rate-card-role",
|
||||||
|
idParamValidator,
|
||||||
|
projectManagerValidator,
|
||||||
|
safeControllerFunction(ProjectRateCardController.updateProjectMemberByProjectIdAndMemberId)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete a single role by id
|
// Delete a single role by id
|
||||||
projectRatecardApiRouter.delete(
|
projectRatecardApiRouter.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
idParamValidator,
|
idParamValidator,
|
||||||
safeControllerFunction(ProjectRateCardController.deleteFromId)
|
safeControllerFunction(ProjectRateCardController.deleteById)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete all roles for a project
|
// Delete all roles for a project
|
||||||
projectRatecardApiRouter.delete(
|
projectRatecardApiRouter.delete(
|
||||||
"/project/:project_id",
|
"/project/:project_id",
|
||||||
safeControllerFunction(ProjectRateCardController.deleteFromProjectId)
|
safeControllerFunction(ProjectRateCardController.deleteByProjectId)
|
||||||
);
|
);
|
||||||
|
|
||||||
export default projectRatecardApiRouter;
|
export default projectRatecardApiRouter;
|
||||||
|
|||||||
@@ -28,6 +28,10 @@
|
|||||||
"jobTitleColumn": "Job Title",
|
"jobTitleColumn": "Job Title",
|
||||||
"ratePerHourColumn": "Rate per hour",
|
"ratePerHourColumn": "Rate per hour",
|
||||||
"ratecardPluralText": "Rate Cards",
|
"ratecardPluralText": "Rate Cards",
|
||||||
"labourHoursColumn": "Labour Hours"
|
"labourHoursColumn": "Labour Hours",
|
||||||
|
"actions": "Actions",
|
||||||
|
"selectJobTitle": "Select Job Title",
|
||||||
|
"ratecardsPluralText": "Rate Card Templates"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"currency": "Currency",
|
"currency": "Currency",
|
||||||
"actionsColumn": "Actions",
|
"actionsColumn": "Actions",
|
||||||
"addAllButton": "Add All",
|
"addAllButton": "Add All",
|
||||||
"removeAllButton": "Remove All"
|
"removeAllButton": "Remove All",
|
||||||
|
"selectJobTitle": "Select job title"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import apiClient from '@api/api-client';
|
import apiClient from '@api/api-client';
|
||||||
import { API_BASE_URL } from '@/shared/constants';
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
import { IServerResponse } from '@/types/common.types';
|
import { IServerResponse } from '@/types/common.types';
|
||||||
import { IJobType } from '@/types/project/ratecard.types';
|
import { IJobType, JobRoleType } from '@/types/project/ratecard.types';
|
||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/project-rate-cards`;
|
const rootUrl = `${API_BASE_URL}/project-rate-cards`;
|
||||||
|
|
||||||
@@ -21,6 +21,14 @@ export const projectRateCardApiService = {
|
|||||||
const response = await apiClient.post<IServerResponse<IProjectRateCardRole[]>>(rootUrl, { project_id, roles });
|
const response = await apiClient.post<IServerResponse<IProjectRateCardRole[]>>(rootUrl, { project_id, roles });
|
||||||
return response.data;
|
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<IServerResponse<IProjectRateCardRole>> {
|
||||||
|
const response = await apiClient.post<IServerResponse<IProjectRateCardRole>>(
|
||||||
|
`${rootUrl}/create-project-rate-card-role`,
|
||||||
|
{ project_id, job_title_id, rate }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Get all roles for a project
|
// Get all roles for a project
|
||||||
async getFromProjectId(project_id: string): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
async getFromProjectId(project_id: string): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||||
@@ -46,6 +54,19 @@ export const projectRateCardApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Update project member rate card role
|
||||||
|
async updateMemberRateCardRole(
|
||||||
|
project_id: string,
|
||||||
|
member_id: string,
|
||||||
|
project_rate_card_role_id: string
|
||||||
|
): Promise<IServerResponse<JobRoleType>> {
|
||||||
|
const response = await apiClient.put<IServerResponse<JobRoleType>>(
|
||||||
|
`${rootUrl}/project/${project_id}/members/${member_id}/rate-card-role`,
|
||||||
|
{ project_rate_card_role_id }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Delete a single role by id
|
// Delete a single role by id
|
||||||
async deleteFromId(id: string): Promise<IServerResponse<IProjectRateCardRole>> {
|
async deleteFromId(id: string): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||||
const response = await apiClient.delete<IServerResponse<IProjectRateCardRole>>(`${rootUrl}/${id}`);
|
const response = await apiClient.delete<IServerResponse<IProjectRateCardRole>>(`${rootUrl}/${id}`);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { InputRef } from 'antd/es/input';
|
||||||
|
import Dropdown from 'antd/es/dropdown';
|
||||||
|
import Card from 'antd/es/card';
|
||||||
|
import List from 'antd/es/list';
|
||||||
|
import Input from 'antd/es/input';
|
||||||
|
import Checkbox from 'antd/es/checkbox';
|
||||||
|
import Button from 'antd/es/button';
|
||||||
|
import Empty from 'antd/es/empty';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import SingleAvatar from '../common/single-avatar/single-avatar';
|
||||||
|
import { IProjectMemberViewModel } from '@/types/projectMember.types';
|
||||||
|
|
||||||
|
interface RateCardAssigneeSelectorProps {
|
||||||
|
projectId: string;
|
||||||
|
onChange?: (memberId: string) => void;
|
||||||
|
selectedMemberIds?: string[];
|
||||||
|
memberlist?: IProjectMemberViewModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const RateCardAssigneeSelector = ({
|
||||||
|
projectId,
|
||||||
|
onChange,
|
||||||
|
selectedMemberIds = [],
|
||||||
|
memberlist = [],
|
||||||
|
}: RateCardAssigneeSelectorProps) => {
|
||||||
|
const membersInputRef = useRef<InputRef>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [members, setMembers] = useState<IProjectMemberViewModel[]>(memberlist);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMembers(memberlist);
|
||||||
|
}, [memberlist]);
|
||||||
|
|
||||||
|
const filteredMembers = members.filter((member) =>
|
||||||
|
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const dropdownContent = (
|
||||||
|
<Card styles={{ body: { padding: 8 } }}>
|
||||||
|
<Input
|
||||||
|
ref={membersInputRef}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||||
|
placeholder="Search members"
|
||||||
|
/>
|
||||||
|
<List style={{ padding: 0, maxHeight: 200, overflow: 'auto' }}>
|
||||||
|
{filteredMembers.length ? (
|
||||||
|
filteredMembers.map((member) => (
|
||||||
|
<List.Item
|
||||||
|
key={member.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '4px 8px',
|
||||||
|
border: 'none',
|
||||||
|
opacity: member.pending_invitation ? 0.5 : 1,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedMemberIds.includes(member.id || '')}
|
||||||
|
disabled={member.pending_invitation}
|
||||||
|
onChange={() => onChange?.(member.id || '')}
|
||||||
|
/>
|
||||||
|
<SingleAvatar
|
||||||
|
avatarUrl={member.avatar_url}
|
||||||
|
name={member.name}
|
||||||
|
email={member.email}
|
||||||
|
/>
|
||||||
|
<span>{member.name}</span>
|
||||||
|
</List.Item>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Empty description="No members found" />
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
overlayClassName="custom-dropdown"
|
||||||
|
trigger={['click']}
|
||||||
|
dropdownRender={() => dropdownContent}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) setTimeout(() => membersInputRef.current?.focus(), 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined style={{ fontSize: 12 }} />}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RateCardAssigneeSelector;
|
||||||
@@ -18,7 +18,7 @@ const initialState: financeState = {
|
|||||||
isRatecardDrawerOpen: false,
|
isRatecardDrawerOpen: false,
|
||||||
isFinanceDrawerOpen: false,
|
isFinanceDrawerOpen: false,
|
||||||
isImportRatecardsDrawerOpen: false,
|
isImportRatecardsDrawerOpen: false,
|
||||||
currency: 'LKR',
|
currency: 'USD',
|
||||||
isRatecardsLoading: false,
|
isRatecardsLoading: false,
|
||||||
isFinanceDrawerloading: false,
|
isFinanceDrawerloading: false,
|
||||||
drawerRatecard: null,
|
drawerRatecard: null,
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export const fetchProjectRateCardRoles = createAsyncThunk(
|
|||||||
async (project_id: string, { rejectWithValue }) => {
|
async (project_id: string, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await projectRateCardApiService.getFromProjectId(project_id);
|
const response = await projectRateCardApiService.getFromProjectId(project_id);
|
||||||
console.log('Project RateCard Roles:', response);
|
|
||||||
return response.body;
|
return response.body;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Fetch Project RateCard Roles', 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(
|
export const updateProjectRateCardRoleById = createAsyncThunk(
|
||||||
'projectFinance/updateById',
|
'projectFinance/updateById',
|
||||||
async ({ id, body }: { id: string; body: { job_title_id: string; rate: string } }, { rejectWithValue }) => {
|
async ({ id, body }: { id: string; body: { job_title_id: string; rate: string } }, { rejectWithValue }) => {
|
||||||
@@ -105,6 +121,14 @@ export const deleteProjectRateCardRoleById = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const assignMemberToRateCardRole = createAsyncThunk(
|
||||||
|
'projectFinance/assignMemberToRateCardRole',
|
||||||
|
async ({ project_id, member_id, project_rate_card_role_id }: { project_id: string; member_id: string; project_rate_card_role_id: string }) => {
|
||||||
|
const response = await projectRateCardApiService.updateMemberRateCardRole(project_id, member_id, project_rate_card_role_id);
|
||||||
|
return response.body;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const deleteProjectRateCardRolesByProjectId = createAsyncThunk(
|
export const deleteProjectRateCardRolesByProjectId = createAsyncThunk(
|
||||||
'projectFinance/deleteByProjectId',
|
'projectFinance/deleteByProjectId',
|
||||||
async (project_id: string, { rejectWithValue }) => {
|
async (project_id: string, { rejectWithValue }) => {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const RatecardDrawer = ({
|
|||||||
const [isAddingRole, setIsAddingRole] = useState(false);
|
const [isAddingRole, setIsAddingRole] = useState(false);
|
||||||
const [selectedJobTitleId, setSelectedJobTitleId] = useState<string | undefined>(undefined);
|
const [selectedJobTitleId, setSelectedJobTitleId] = useState<string | undefined>(undefined);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [currency, setCurrency] = useState('LKR');
|
const [currency, setCurrency] = useState('USD');
|
||||||
const [name, setName] = useState<string>('Untitled Rate Card');
|
const [name, setName] = useState<string>('Untitled Rate Card');
|
||||||
const [jobTitles, setJobTitles] = useState<IJobTitlesViewModel>({});
|
const [jobTitles, setJobTitles] = useState<IJobTitlesViewModel>({});
|
||||||
const [pagination, setPagination] = useState<PaginationType>({
|
const [pagination, setPagination] = useState<PaginationType>({
|
||||||
@@ -95,7 +95,7 @@ const RatecardDrawer = ({
|
|||||||
if (type === 'update' && drawerRatecard) {
|
if (type === 'update' && drawerRatecard) {
|
||||||
setRoles(drawerRatecard.jobRolesList || []);
|
setRoles(drawerRatecard.jobRolesList || []);
|
||||||
setName(drawerRatecard.name || '');
|
setName(drawerRatecard.name || '');
|
||||||
setCurrency(drawerRatecard.currency || 'LKR');
|
setCurrency(drawerRatecard.currency || 'USD');
|
||||||
}
|
}
|
||||||
}, [drawerRatecard, type]);
|
}, [drawerRatecard, type]);
|
||||||
|
|
||||||
@@ -121,15 +121,17 @@ const RatecardDrawer = ({
|
|||||||
setRoles(mergedRoles);
|
setRoles(mergedRoles);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleAddRole = () => {
|
const handleAddRole = () => {
|
||||||
// Only allow adding if there are job titles not already in roles
|
|
||||||
const existingIds = new Set(roles.map(r => r.job_title_id));
|
const existingIds = new Set(roles.map(r => r.job_title_id));
|
||||||
const availableJobTitles = jobTitles.data?.filter(jt => !existingIds.has(jt.id!));
|
const availableJobTitles = jobTitles.data?.filter(jt => !existingIds.has(jt.id!));
|
||||||
if (availableJobTitles && availableJobTitles.length > 0) {
|
if (availableJobTitles && availableJobTitles.length > 0) {
|
||||||
setRoles([...roles, { job_title_id: '', rate: 0 }]);
|
setRoles([...roles, { job_title_id: '', rate: 0 }]);
|
||||||
setAddingRowIndex(roles.length); // index of the new row
|
setAddingRowIndex(roles.length);
|
||||||
|
setIsAddingRole(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteRole = (index: number) => {
|
const handleDeleteRole = (index: number) => {
|
||||||
const updatedRoles = [...roles];
|
const updatedRoles = [...roles];
|
||||||
updatedRoles.splice(index, 1);
|
updatedRoles.splice(index, 1);
|
||||||
@@ -185,7 +187,7 @@ const RatecardDrawer = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
setName('Untitled Rate Card');
|
setName('Untitled Rate Card');
|
||||||
setCurrency('LKR');
|
setCurrency('USD');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -218,6 +220,9 @@ const RatecardDrawer = ({
|
|||||||
setAddingRowIndex(null);
|
setAddingRowIndex(null);
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
|
if (roles[index].job_title_id === ""){
|
||||||
|
handleDeleteRole(index);
|
||||||
|
}
|
||||||
setEditingRowIndex(null);
|
setEditingRowIndex(null);
|
||||||
setAddingRowIndex(null);
|
setAddingRowIndex(null);
|
||||||
}}
|
}}
|
||||||
@@ -239,7 +244,7 @@ const RatecardDrawer = ({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => setEditingRowIndex(index)}
|
// onClick={() => setEditingRowIndex(index)}
|
||||||
>
|
>
|
||||||
{record.jobtitle}
|
{record.jobtitle}
|
||||||
</span>
|
</span>
|
||||||
@@ -249,6 +254,7 @@ const RatecardDrawer = ({
|
|||||||
{
|
{
|
||||||
title: `${t('ratePerHourColumn')} (${currency})`,
|
title: `${t('ratePerHourColumn')} (${currency})`,
|
||||||
dataIndex: 'rate',
|
dataIndex: 'rate',
|
||||||
|
align: 'right',
|
||||||
render: (text: number, record: any, index: number) => (
|
render: (text: number, record: any, index: number) => (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -257,6 +263,7 @@ const RatecardDrawer = ({
|
|||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
|
textAlign: 'right',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
}}
|
}}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -319,8 +326,8 @@ const RatecardDrawer = ({
|
|||||||
<Select
|
<Select
|
||||||
value={currency}
|
value={currency}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'LKR', label: 'LKR' },
|
|
||||||
{ value: 'USD', label: 'USD' },
|
{ value: 'USD', label: 'USD' },
|
||||||
|
{ value: 'LKR', label: 'LKR' },
|
||||||
{ value: 'INR', label: 'INR' },
|
{ value: 'INR', label: 'INR' },
|
||||||
]}
|
]}
|
||||||
onChange={(value) => setCurrency(value)}
|
onChange={(value) => setCurrency(value)}
|
||||||
@@ -347,27 +354,7 @@ const RatecardDrawer = ({
|
|||||||
rowKey={(record) => record.job_title_id}
|
rowKey={(record) => record.job_title_id}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
footer={() => (
|
footer={() => (
|
||||||
isAddingRole ? (
|
<Button
|
||||||
<Select
|
|
||||||
showSearch
|
|
||||||
style={{ minWidth: 200 }}
|
|
||||||
placeholder={t('selectJobTitle')}
|
|
||||||
optionFilterProp="children"
|
|
||||||
value={selectedJobTitleId}
|
|
||||||
onChange={handleSelectJobTitle}
|
|
||||||
onBlur={() => setIsAddingRole(false)}
|
|
||||||
filterOption={(input, option) =>
|
|
||||||
(option?.children as string).toLowerCase().includes(input.toLowerCase())
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{jobTitles.data?.map((jt) => (
|
|
||||||
<Select.Option key={jt.id} value={jt.id}>
|
|
||||||
{jt.name}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
type="dashed"
|
||||||
onClick={handleAddRole}
|
onClick={handleAddRole}
|
||||||
block
|
block
|
||||||
@@ -375,7 +362,6 @@ const RatecardDrawer = ({
|
|||||||
>
|
>
|
||||||
{t('addRoleButton')}
|
{t('addRoleButton')}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -7,12 +7,17 @@ import { useAppDispatch } from '../../../../../../hooks/useAppDispatch';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { JobRoleType, IJobType, RatecardType } from '@/types/project/ratecard.types';
|
import { JobRoleType, IJobType, RatecardType } from '@/types/project/ratecard.types';
|
||||||
import {
|
import {
|
||||||
|
assignMemberToRateCardRole,
|
||||||
deleteProjectRateCardRoleById,
|
deleteProjectRateCardRoleById,
|
||||||
fetchProjectRateCardRoles,
|
fetchProjectRateCardRoles,
|
||||||
|
insertProjectRateCardRole,
|
||||||
updateProjectRateCardRolesByProjectId,
|
updateProjectRateCardRolesByProjectId,
|
||||||
} from '@/features/finance/project-finance-slice';
|
} from '@/features/finance/project-finance-slice';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
|
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
|
||||||
|
import RateCardAssigneeSelector from '@/components/project-ratecard/ratecard-assignee-selector';
|
||||||
|
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||||
|
import { IProjectMemberViewModel } from '@/types/projectMember.types';
|
||||||
|
|
||||||
const RatecardTable: React.FC = () => {
|
const RatecardTable: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -27,8 +32,42 @@ const RatecardTable: React.FC = () => {
|
|||||||
// Local state for editing
|
// Local state for editing
|
||||||
const [roles, setRoles] = useState<JobRoleType[]>(rolesRedux);
|
const [roles, setRoles] = useState<JobRoleType[]>(rolesRedux);
|
||||||
const [addingRow, setAddingRow] = useState<boolean>(false);
|
const [addingRow, setAddingRow] = useState<boolean>(false);
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
|
||||||
const [jobTitles, setJobTitles] = useState<RatecardType[]>([]);
|
const [jobTitles, setJobTitles] = useState<RatecardType[]>([]);
|
||||||
|
const [members, setMembers] = useState<IProjectMemberViewModel[]>([]);
|
||||||
|
const [isLoadingMembers, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const pagination = {
|
||||||
|
current: 1,
|
||||||
|
pageSize: 1000,
|
||||||
|
field: 'name',
|
||||||
|
order: 'asc',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProjectMembers = async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await projectsApiService.getMembers(
|
||||||
|
projectId,
|
||||||
|
pagination.current,
|
||||||
|
pagination.pageSize,
|
||||||
|
pagination.field,
|
||||||
|
pagination.order,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
if (res.done) {
|
||||||
|
setMembers(res.body?.data || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching members:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getProjectMembers();
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
// Fetch job titles for selection
|
// Fetch job titles for selection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,7 +79,6 @@ const RatecardTable: React.FC = () => {
|
|||||||
|
|
||||||
// Sync local roles with redux roles
|
// Sync local roles with redux roles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('Roles Redux:', rolesRedux);
|
|
||||||
setRoles(rolesRedux);
|
setRoles(rolesRedux);
|
||||||
}, [rolesRedux]);
|
}, [rolesRedux]);
|
||||||
|
|
||||||
@@ -59,7 +97,6 @@ const RatecardTable: React.FC = () => {
|
|||||||
// Save all roles (bulk update)
|
// Save all roles (bulk update)
|
||||||
const handleSaveAll = () => {
|
const handleSaveAll = () => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
// Only send roles with job_title_id and rate
|
|
||||||
const filteredRoles = roles
|
const filteredRoles = roles
|
||||||
.filter((r) => r.job_title_id && typeof r.rate !== 'undefined')
|
.filter((r) => r.job_title_id && typeof r.rate !== 'undefined')
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
@@ -72,20 +109,27 @@ const RatecardTable: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle job title select for new row
|
// Handle job title select for new row
|
||||||
const handleSelectJobTitle = (jobTitleId: string) => {
|
const handleSelectJobTitle = async (jobTitleId: string) => {
|
||||||
const jobTitle = jobTitles.find((jt) => jt.id === jobTitleId);
|
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;
|
if (roles.some((r) => r.job_title_id === jobTitleId)) return;
|
||||||
setRoles([
|
const resultAction = await dispatch(
|
||||||
...roles,
|
insertProjectRateCardRole({ project_id: projectId, job_title_id: jobTitleId, rate: 0 })
|
||||||
{
|
);
|
||||||
job_title_id: jobTitleId,
|
|
||||||
jobtitle: jobTitle.name || '',
|
if (insertProjectRateCardRole.fulfilled.match(resultAction)) {
|
||||||
rate: 0,
|
const newRole = resultAction.payload;
|
||||||
members: [],
|
setRoles([
|
||||||
},
|
...roles,
|
||||||
]);
|
{
|
||||||
|
id: newRole.id,
|
||||||
|
job_title_id: newRole.job_title_id,
|
||||||
|
jobtitle: newRole.jobtitle,
|
||||||
|
rate: newRole.rate,
|
||||||
|
members: [], // Initialize members array
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
setAddingRow(false);
|
setAddingRow(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,18 +146,41 @@ const RatecardTable: React.FC = () => {
|
|||||||
if (record.id) {
|
if (record.id) {
|
||||||
dispatch(deleteProjectRateCardRoleById(record.id));
|
dispatch(deleteProjectRateCardRoleById(record.id));
|
||||||
} else {
|
} else {
|
||||||
// Remove unsaved row
|
|
||||||
setRoles(roles.filter((_, idx) => idx !== index));
|
setRoles(roles.filter((_, idx) => idx !== index));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle member change
|
||||||
|
const handleMemberChange = async (memberId: string, rowIndex: number, record: JobRoleType) => {
|
||||||
|
if (!projectId || !record.id) return; // Ensure required IDs are present
|
||||||
|
try {
|
||||||
|
const resultAction = await dispatch(
|
||||||
|
assignMemberToRateCardRole({
|
||||||
|
project_id: projectId,
|
||||||
|
member_id: memberId,
|
||||||
|
project_rate_card_role_id: record.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (assignMemberToRateCardRole.fulfilled.match(resultAction)) {
|
||||||
|
const updatedMembers = resultAction.payload; // Array of member IDs
|
||||||
|
setRoles((prev) =>
|
||||||
|
prev.map((role, idx) => {
|
||||||
|
if (idx !== rowIndex) return role;
|
||||||
|
return { ...role, members: updatedMembers?.members || [] };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error assigning member:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Columns
|
// Columns
|
||||||
const columns: TableProps<JobRoleType>['columns'] = [
|
const columns: TableProps<JobRoleType>['columns'] = [
|
||||||
{
|
{
|
||||||
title: t('jobTitleColumn'),
|
title: t('jobTitleColumn'),
|
||||||
dataIndex: 'jobtitle',
|
dataIndex: 'jobtitle',
|
||||||
render: (text: string, record: JobRoleType, index: number) => {
|
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) {
|
if (addingRow && index === roles.length) {
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
@@ -125,12 +192,12 @@ const RatecardTable: React.FC = () => {
|
|||||||
onChange={handleSelectJobTitle}
|
onChange={handleSelectJobTitle}
|
||||||
onBlur={() => setAddingRow(false)}
|
onBlur={() => setAddingRow(false)}
|
||||||
filterOption={(input, option) =>
|
filterOption={(input, option) =>
|
||||||
(option?.children as string).toLowerCase().includes(input.toLowerCase())
|
(option?.children as string)?.toLowerCase().includes(input.toLowerCase())
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{jobTitles
|
{jobTitles
|
||||||
.filter(jt => !roles.some((role) => role.job_title_id === jt.id))
|
.filter((jt) => !roles.some((role) => role.job_title_id === jt.id))
|
||||||
.map(jt => (
|
.map((jt) => (
|
||||||
<Select.Option key={jt.id} value={jt.id!}>
|
<Select.Option key={jt.id} value={jt.id!}>
|
||||||
{jt.name}
|
{jt.name}
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
@@ -138,19 +205,13 @@ const RatecardTable: React.FC = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return <span>{text || record.name}</span>;
|
||||||
<span
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={() => setEditingIndex(index)}
|
|
||||||
>
|
|
||||||
{text || record.name}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: `${t('ratePerHourColumn')} (${currency})`,
|
title: `${t('ratePerHourColumn')} (${currency})`,
|
||||||
dataIndex: 'rate',
|
dataIndex: 'rate',
|
||||||
|
align: 'right',
|
||||||
render: (value: number, record: JobRoleType, index: number) => (
|
render: (value: number, record: JobRoleType, index: number) => (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -161,6 +222,7 @@ const RatecardTable: React.FC = () => {
|
|||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
width: 80,
|
width: 80,
|
||||||
|
textAlign: 'right',
|
||||||
}}
|
}}
|
||||||
onChange={(e) => handleRateChange(e.target.value, index)}
|
onChange={(e) => handleRateChange(e.target.value, index)}
|
||||||
/>
|
/>
|
||||||
@@ -169,34 +231,31 @@ const RatecardTable: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: t('membersColumn'),
|
title: t('membersColumn'),
|
||||||
dataIndex: 'members',
|
dataIndex: 'members',
|
||||||
render: (members: string[] | null | undefined) =>
|
render: (memberscol: string[] | null | undefined, record: JobRoleType, index: number) => (
|
||||||
members && members.length > 0 ? (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, position: 'relative' }}>
|
||||||
<Avatar.Group>
|
<Avatar.Group>
|
||||||
{members.map((member, i) => (
|
{memberscol?.map((memberId, i) => {
|
||||||
<CustomAvatar key={i} avatarName={member} size={26} />
|
const member = members.find((m) => m.id === memberId);
|
||||||
))}
|
return member ? (
|
||||||
|
<CustomAvatar key={i} avatarName={member.name} size={26} />
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
</Avatar.Group>
|
</Avatar.Group>
|
||||||
) : (
|
<div>
|
||||||
<Button
|
<RateCardAssigneeSelector
|
||||||
shape="circle"
|
projectId={projectId as string}
|
||||||
icon={
|
selectedMemberIds={memberscol || []}
|
||||||
<PlusOutlined
|
onChange={(memberId) => handleMemberChange(memberId, index, record)}
|
||||||
style={{
|
memberlist={members}
|
||||||
fontSize: 12,
|
/>
|
||||||
width: 22,
|
</div>
|
||||||
height: 22,
|
</div>
|
||||||
display: 'flex',
|
),
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('actions'),
|
title: t('actions'),
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
|
align: 'center',
|
||||||
render: (_: any, record: JobRoleType, index: number) => (
|
render: (_: any, record: JobRoleType, index: number) => (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t('deleteConfirm')}
|
title={t('deleteConfirm')}
|
||||||
@@ -204,11 +263,7 @@ const RatecardTable: React.FC = () => {
|
|||||||
okText={t('yes')}
|
okText={t('yes')}
|
||||||
cancelText={t('no')}
|
cancelText={t('no')}
|
||||||
>
|
>
|
||||||
<Button
|
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||||
type="text"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
/>
|
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -219,37 +274,33 @@ const RatecardTable: React.FC = () => {
|
|||||||
dataSource={
|
dataSource={
|
||||||
addingRow
|
addingRow
|
||||||
? [
|
? [
|
||||||
...roles,
|
...roles,
|
||||||
{
|
{
|
||||||
job_title_id: '',
|
job_title_id: '',
|
||||||
jobtitle: '',
|
jobtitle: '',
|
||||||
rate: 0,
|
rate: 0,
|
||||||
members: [],
|
members: [],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: roles
|
: roles
|
||||||
}
|
}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(record, idx) => record.id || record.job_title_id || idx}
|
rowKey={(record, idx) => record.id || record.job_title_id || String(idx)}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
loading={isLoading}
|
loading={isLoading || isLoadingMembers}
|
||||||
footer={() => (
|
footer={() => (
|
||||||
<Flex gap={8}>
|
<Flex gap={0}>
|
||||||
<Button
|
<Button type="dashed" onClick={handleAddRole} style={{ width: 'fit-content' }}>
|
||||||
type="dashed"
|
|
||||||
onClick={handleAddRole}
|
|
||||||
style={{ width: 'fit-content' }}
|
|
||||||
>
|
|
||||||
{t('addRoleButton')}
|
{t('addRoleButton')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{/* <Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<SaveOutlined />}
|
icon={<SaveOutlined />}
|
||||||
onClick={handleSaveAll}
|
onClick={handleSaveAll}
|
||||||
disabled={roles.length === 0}
|
disabled={roles.length === 0}
|
||||||
>
|
>
|
||||||
{t('saveButton') || 'Save'}
|
{t('saveButton') || 'Save'}
|
||||||
</Button>
|
</Button> */}
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user