Merge pull request #136 from shancds/feature/project-finance
Feature/project finance rate card section
This commit is contained in:
123
worklenz-backend/src/controllers/project-ratecard-controller.ts
Normal file
123
worklenz-backend/src/controllers/project-ratecard-controller.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import db from "../config/db";
|
||||||
|
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||||
|
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||||
|
import { ServerResponse } from "../models/server-response";
|
||||||
|
import HandleExceptions from "../decorators/handle-exceptions";
|
||||||
|
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||||
|
|
||||||
|
export default class ProjectRateCardController extends WorklenzControllerBase {
|
||||||
|
// Insert multiple roles for a project
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async insertMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const { project_id, roles } = req.body;
|
||||||
|
if (!Array.isArray(roles) || !project_id) {
|
||||||
|
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
||||||
|
}
|
||||||
|
const values = roles.map((role: any) => [
|
||||||
|
project_id,
|
||||||
|
role.job_title_id,
|
||||||
|
role.rate
|
||||||
|
]);
|
||||||
|
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
|
||||||
|
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);
|
||||||
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all roles for a project
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const { project_id } = req.params;
|
||||||
|
const q = `
|
||||||
|
SELECT fprr.*, jt.name as jobtitle
|
||||||
|
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;
|
||||||
|
`;
|
||||||
|
const result = await db.query(q, [project_id]);
|
||||||
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a single role by id
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const { id } = req.params;
|
||||||
|
const q = `
|
||||||
|
SELECT fprr.*, jt.name as jobtitle
|
||||||
|
FROM finance_project_rate_card_roles fprr
|
||||||
|
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
||||||
|
WHERE fprr.id = $1;
|
||||||
|
`;
|
||||||
|
const result = await db.query(q, [id]);
|
||||||
|
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a single role by id
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async updateFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { job_title_id, rate } = req.body;
|
||||||
|
const q = `
|
||||||
|
UPDATE finance_project_rate_card_roles
|
||||||
|
SET job_title_id = $1, rate = $2, updated_at = NOW()
|
||||||
|
WHERE id = $3
|
||||||
|
RETURNING *;
|
||||||
|
`;
|
||||||
|
const result = await db.query(q, [job_title_id, rate, id]);
|
||||||
|
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all roles for a project (delete then insert)
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async updateFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const { project_id, roles } = req.body;
|
||||||
|
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) {
|
||||||
|
return res.status(200).send(new ServerResponse(true, []));
|
||||||
|
}
|
||||||
|
const values = roles.map((role: any) => [
|
||||||
|
project_id,
|
||||||
|
role.job_title_id,
|
||||||
|
role.rate
|
||||||
|
]);
|
||||||
|
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 *,
|
||||||
|
(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);
|
||||||
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a single role by id
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async deleteFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const { id } = req.params;
|
||||||
|
const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`;
|
||||||
|
const result = await db.query(q, [id]);
|
||||||
|
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all roles for a project
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async deleteFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const { project_id } = req.params;
|
||||||
|
const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`;
|
||||||
|
const result = await db.query(q, [project_id]);
|
||||||
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ import taskRecurringApiRouter from "./task-recurring-api-router";
|
|||||||
|
|
||||||
import customColumnsApiRouter from "./custom-columns-api-router";
|
import customColumnsApiRouter from "./custom-columns-api-router";
|
||||||
import ratecardApiRouter from "./ratecard-api-router";
|
import ratecardApiRouter from "./ratecard-api-router";
|
||||||
|
import projectRatecardApiRouter from "./project-ratecard-api-router";
|
||||||
|
|
||||||
const api = express.Router();
|
const api = express.Router();
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ api.use("/team-members", teamMembersApiRouter);
|
|||||||
api.use("/job-titles", jobTitlesApiRouter);
|
api.use("/job-titles", jobTitlesApiRouter);
|
||||||
api.use("/clients", clientsApiRouter);
|
api.use("/clients", clientsApiRouter);
|
||||||
api.use("/rate-cards", ratecardApiRouter);
|
api.use("/rate-cards", ratecardApiRouter);
|
||||||
|
api.use("/project-rate-cards", projectRatecardApiRouter);
|
||||||
api.use("/teams", teamsApiRouter);
|
api.use("/teams", teamsApiRouter);
|
||||||
api.use("/tasks", tasksApiRouter);
|
api.use("/tasks", tasksApiRouter);
|
||||||
api.use("/settings", settingsApiRouter);
|
api.use("/settings", settingsApiRouter);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import express from "express";
|
||||||
|
import ProjectRateCardController from "../../controllers/project-ratecard-controller";
|
||||||
|
import idParamValidator from "../../middlewares/validators/id-param-validator";
|
||||||
|
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||||
|
import projectManagerValidator from "../../middlewares/validators/project-manager-validator";
|
||||||
|
|
||||||
|
const projectRatecardApiRouter = express.Router();
|
||||||
|
|
||||||
|
// Insert multiple roles for a project
|
||||||
|
projectRatecardApiRouter.post(
|
||||||
|
"/",
|
||||||
|
projectManagerValidator,
|
||||||
|
safeControllerFunction(ProjectRateCardController.insertMany)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all roles for a project
|
||||||
|
projectRatecardApiRouter.get(
|
||||||
|
"/project/:project_id",
|
||||||
|
safeControllerFunction(ProjectRateCardController.getFromProjectId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get a single role by id
|
||||||
|
projectRatecardApiRouter.get(
|
||||||
|
"/:id",
|
||||||
|
idParamValidator,
|
||||||
|
safeControllerFunction(ProjectRateCardController.getFromId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update a single role by id
|
||||||
|
projectRatecardApiRouter.put(
|
||||||
|
"/:id",
|
||||||
|
idParamValidator,
|
||||||
|
safeControllerFunction(ProjectRateCardController.updateFromId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update all roles for a project (delete then insert)
|
||||||
|
projectRatecardApiRouter.put(
|
||||||
|
"/project/:project_id",
|
||||||
|
safeControllerFunction(ProjectRateCardController.updateFromProjectId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a single role by id
|
||||||
|
projectRatecardApiRouter.delete(
|
||||||
|
"/:id",
|
||||||
|
idParamValidator,
|
||||||
|
safeControllerFunction(ProjectRateCardController.deleteFromId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete all roles for a project
|
||||||
|
projectRatecardApiRouter.delete(
|
||||||
|
"/project/:project_id",
|
||||||
|
safeControllerFunction(ProjectRateCardController.deleteFromProjectId)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default projectRatecardApiRouter;
|
||||||
@@ -16,5 +16,9 @@
|
|||||||
"createRatecardErrorMessage": "Create Rate Card failed!",
|
"createRatecardErrorMessage": "Create Rate Card failed!",
|
||||||
"updateRatecardSuccessMessage": "Update Rate Card success!",
|
"updateRatecardSuccessMessage": "Update Rate Card success!",
|
||||||
"updateRatecardErrorMessage": "Update Rate Card failed!",
|
"updateRatecardErrorMessage": "Update Rate Card failed!",
|
||||||
"currency": "Currency"
|
"currency": "Currency",
|
||||||
|
"actionsColumn": "Actions",
|
||||||
|
"addAllButton": "Add All",
|
||||||
|
"removeAllButton": "Remove All"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import apiClient from '@api/api-client';
|
||||||
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
|
import { IServerResponse } from '@/types/common.types';
|
||||||
|
import { IJobType } from '@/types/project/ratecard.types';
|
||||||
|
|
||||||
|
const rootUrl = `${API_BASE_URL}/project-rate-cards`;
|
||||||
|
|
||||||
|
export interface IProjectRateCardRole {
|
||||||
|
id?: string;
|
||||||
|
project_id: string;
|
||||||
|
job_title_id: string;
|
||||||
|
jobtitle?: string;
|
||||||
|
rate: number;
|
||||||
|
data?: object;
|
||||||
|
roles?: IJobType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projectRateCardApiService = {
|
||||||
|
// Insert multiple roles for a project
|
||||||
|
async insertMany(project_id: string, roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[]): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||||
|
const response = await apiClient.post<IServerResponse<IProjectRateCardRole[]>>(rootUrl, { project_id, roles });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get all roles for a project
|
||||||
|
async getFromProjectId(project_id: string): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||||
|
const response = await apiClient.get<IServerResponse<IProjectRateCardRole[]>>(`${rootUrl}/project/${project_id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get a single role by id
|
||||||
|
async getFromId(id: string): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||||
|
const response = await apiClient.get<IServerResponse<IProjectRateCardRole>>(`${rootUrl}/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update a single role by id
|
||||||
|
async updateFromId(id: string, body: { job_title_id: string; rate: string }): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||||
|
const response = await apiClient.put<IServerResponse<IProjectRateCardRole>>(`${rootUrl}/${id}`, body);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update all roles for a project (delete then insert)
|
||||||
|
async updateFromProjectId(project_id: string, roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[]): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||||
|
const response = await apiClient.put<IServerResponse<IProjectRateCardRole[]>>(`${rootUrl}/project/${project_id}`, { project_id, roles });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete a single role by id
|
||||||
|
async deleteFromId(id: string): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||||
|
const response = await apiClient.delete<IServerResponse<IProjectRateCardRole>>(`${rootUrl}/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete all roles for a project
|
||||||
|
async deleteFromProjectId(project_id: string): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||||
|
const response = await apiClient.delete<IServerResponse<IProjectRateCardRole[]>>(`${rootUrl}/project/${project_id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -72,6 +72,7 @@ import timeReportsOverviewReducer from '@features/reporting/time-reports/time-re
|
|||||||
import financeReducer from '../features/finance/finance-slice';
|
import financeReducer from '../features/finance/finance-slice';
|
||||||
import roadmapReducer from '../features/roadmap/roadmap-slice';
|
import roadmapReducer from '../features/roadmap/roadmap-slice';
|
||||||
import teamMembersReducer from '@features/team-members/team-members.slice';
|
import teamMembersReducer from '@features/team-members/team-members.slice';
|
||||||
|
import projectFinanceRateCardReducer from '../features/finance/project-finance-slice';
|
||||||
import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
|
import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
|
||||||
import homePageApiService from '@/api/home-page/home-page.api.service';
|
import homePageApiService from '@/api/home-page/home-page.api.service';
|
||||||
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||||
@@ -156,6 +157,7 @@ export const store = configureStore({
|
|||||||
groupByFilterDropdownReducer: groupByFilterDropdownReducer,
|
groupByFilterDropdownReducer: groupByFilterDropdownReducer,
|
||||||
timeReportsOverviewReducer: timeReportsOverviewReducer,
|
timeReportsOverviewReducer: timeReportsOverviewReducer,
|
||||||
financeReducer: financeReducer,
|
financeReducer: financeReducer,
|
||||||
|
projectFinanceRateCard: projectFinanceRateCardReducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ type financeState = {
|
|||||||
isFinanceDrawerOpen: boolean;
|
isFinanceDrawerOpen: boolean;
|
||||||
isImportRatecardsDrawerOpen: boolean;
|
isImportRatecardsDrawerOpen: boolean;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
isRatecardsLoading?: boolean;
|
||||||
isFinanceDrawerloading?: boolean;
|
isFinanceDrawerloading?: boolean;
|
||||||
drawerRatecard?: RatecardType | null;
|
drawerRatecard?: RatecardType | null;
|
||||||
|
ratecardsList?: RatecardType[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState: financeState = {
|
const initialState: financeState = {
|
||||||
@@ -17,8 +19,10 @@ const initialState: financeState = {
|
|||||||
isFinanceDrawerOpen: false,
|
isFinanceDrawerOpen: false,
|
||||||
isImportRatecardsDrawerOpen: false,
|
isImportRatecardsDrawerOpen: false,
|
||||||
currency: 'LKR',
|
currency: 'LKR',
|
||||||
|
isRatecardsLoading: false,
|
||||||
isFinanceDrawerloading: false,
|
isFinanceDrawerloading: false,
|
||||||
drawerRatecard: null,
|
drawerRatecard: null,
|
||||||
|
ratecardsList: null,
|
||||||
};
|
};
|
||||||
interface FetchRateCardsParams {
|
interface FetchRateCardsParams {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -139,7 +143,21 @@ const financeSlice = createSlice({
|
|||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder
|
builder
|
||||||
// ...other cases...
|
.addCase(fetchRateCards.pending, (state) => {
|
||||||
|
state.isRatecardsLoading = true;
|
||||||
|
})
|
||||||
|
.addCase(fetchRateCards.fulfilled, (state, action) => {
|
||||||
|
state.isRatecardsLoading = false;
|
||||||
|
state.ratecardsList = Array.isArray(action.payload.data)
|
||||||
|
? action.payload.data
|
||||||
|
: Array.isArray(action.payload)
|
||||||
|
? action.payload
|
||||||
|
: [];
|
||||||
|
})
|
||||||
|
.addCase(fetchRateCards.rejected, (state) => {
|
||||||
|
state.isRatecardsLoading = false;
|
||||||
|
state.ratecardsList = [];
|
||||||
|
})
|
||||||
.addCase(fetchRateCardById.pending, (state) => {
|
.addCase(fetchRateCardById.pending, (state) => {
|
||||||
state.isFinanceDrawerloading = true;
|
state.isFinanceDrawerloading = true;
|
||||||
state.drawerRatecard = null;
|
state.drawerRatecard = null;
|
||||||
|
|||||||
247
worklenz-frontend/src/features/finance/project-finance-slice.ts
Normal file
247
worklenz-frontend/src/features/finance/project-finance-slice.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import { projectRateCardApiService, IProjectRateCardRole } from '@/api/project-finance-ratecard/project-finance-rate-cards.api.service';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
import { JobRoleType } from '@/types/project/ratecard.types';
|
||||||
|
|
||||||
|
type ProjectFinanceRateCardState = {
|
||||||
|
isDrawerOpen: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
rateCardRoles: JobRoleType[] | null;
|
||||||
|
drawerRole: IProjectRateCardRole | null;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: ProjectFinanceRateCardState = {
|
||||||
|
isDrawerOpen: false,
|
||||||
|
isLoading: false,
|
||||||
|
rateCardRoles: null,
|
||||||
|
drawerRole: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Async thunks
|
||||||
|
export const fetchProjectRateCardRoles = createAsyncThunk(
|
||||||
|
'projectFinance/fetchAll',
|
||||||
|
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);
|
||||||
|
if (error instanceof Error) return rejectWithValue(error.message);
|
||||||
|
return rejectWithValue('Failed to fetch project rate card roles');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchProjectRateCardRoleById = createAsyncThunk(
|
||||||
|
'projectFinance/fetchById',
|
||||||
|
async (id: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await projectRateCardApiService.getFromId(id);
|
||||||
|
return response.body;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fetch Project RateCard Role By Id', error);
|
||||||
|
if (error instanceof Error) return rejectWithValue(error.message);
|
||||||
|
return rejectWithValue('Failed to fetch project rate card role');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const insertProjectRateCardRoles = createAsyncThunk(
|
||||||
|
'projectFinance/insertMany',
|
||||||
|
async ({ project_id, roles }: { project_id: string; roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[] }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await projectRateCardApiService.insertMany(project_id, roles);
|
||||||
|
return response.body;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Insert Project RateCard Roles', error);
|
||||||
|
if (error instanceof Error) return rejectWithValue(error.message);
|
||||||
|
return rejectWithValue('Failed to insert project rate card roles');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateProjectRateCardRoleById = createAsyncThunk(
|
||||||
|
'projectFinance/updateById',
|
||||||
|
async ({ id, body }: { id: string; body: { job_title_id: string; rate: string } }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await projectRateCardApiService.updateFromId(id, body);
|
||||||
|
return response.body;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Update Project RateCard Role By Id', error);
|
||||||
|
if (error instanceof Error) return rejectWithValue(error.message);
|
||||||
|
return rejectWithValue('Failed to update project rate card role');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateProjectRateCardRolesByProjectId = createAsyncThunk(
|
||||||
|
'projectFinance/updateByProjectId',
|
||||||
|
async ({ project_id, roles }: { project_id: string; roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[] }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await projectRateCardApiService.updateFromProjectId(project_id, roles);
|
||||||
|
return response.body;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Update Project RateCard Roles By ProjectId', error);
|
||||||
|
if (error instanceof Error) return rejectWithValue(error.message);
|
||||||
|
return rejectWithValue('Failed to update project rate card roles');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteProjectRateCardRoleById = createAsyncThunk(
|
||||||
|
'projectFinance/deleteById',
|
||||||
|
async (id: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await projectRateCardApiService.deleteFromId(id);
|
||||||
|
return response.body;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Delete Project RateCard Role By Id', error);
|
||||||
|
if (error instanceof Error) return rejectWithValue(error.message);
|
||||||
|
return rejectWithValue('Failed to delete project rate card role');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteProjectRateCardRolesByProjectId = createAsyncThunk(
|
||||||
|
'projectFinance/deleteByProjectId',
|
||||||
|
async (project_id: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await projectRateCardApiService.deleteFromProjectId(project_id);
|
||||||
|
return response.body;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Delete Project RateCard Roles By ProjectId', error);
|
||||||
|
if (error instanceof Error) return rejectWithValue(error.message);
|
||||||
|
return rejectWithValue('Failed to delete project rate card roles');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectFinanceSlice = createSlice({
|
||||||
|
name: 'projectFinanceRateCard',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
toggleDrawer: (state) => {
|
||||||
|
state.isDrawerOpen = !state.isDrawerOpen;
|
||||||
|
},
|
||||||
|
clearDrawerRole: (state) => {
|
||||||
|
state.drawerRole = null;
|
||||||
|
},
|
||||||
|
clearError: (state) => {
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
// Fetch all
|
||||||
|
.addCase(fetchProjectRateCardRoles.pending, (state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchProjectRateCardRoles.fulfilled, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.rateCardRoles = action.payload || [];
|
||||||
|
})
|
||||||
|
.addCase(fetchProjectRateCardRoles.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
state.rateCardRoles = [];
|
||||||
|
})
|
||||||
|
// Fetch by id
|
||||||
|
.addCase(fetchProjectRateCardRoleById.pending, (state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.drawerRole = null;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.drawerRole = action.payload || null;
|
||||||
|
})
|
||||||
|
.addCase(fetchProjectRateCardRoleById.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
state.drawerRole = null;
|
||||||
|
})
|
||||||
|
// Insert many
|
||||||
|
.addCase(insertProjectRateCardRoles.pending, (state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(insertProjectRateCardRoles.fulfilled, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.rateCardRoles = action.payload || [];
|
||||||
|
})
|
||||||
|
.addCase(insertProjectRateCardRoles.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
// Update by id
|
||||||
|
.addCase(updateProjectRateCardRoleById.pending, (state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(updateProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
if (state.rateCardRoles && action.payload) {
|
||||||
|
state.rateCardRoles = state.rateCardRoles.map((role) =>
|
||||||
|
role.id === action.payload.id ? action.payload : role
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(updateProjectRateCardRoleById.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
// Update by project id
|
||||||
|
.addCase(updateProjectRateCardRolesByProjectId.pending, (state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(updateProjectRateCardRolesByProjectId.fulfilled, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.rateCardRoles = action.payload || [];
|
||||||
|
})
|
||||||
|
.addCase(updateProjectRateCardRolesByProjectId.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
// Delete by id
|
||||||
|
.addCase(deleteProjectRateCardRoleById.pending, (state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(deleteProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
if (state.rateCardRoles && action.payload) {
|
||||||
|
state.rateCardRoles = state.rateCardRoles.filter((role) => role.id !== action.payload.id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(deleteProjectRateCardRoleById.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
// Delete by project id
|
||||||
|
.addCase(deleteProjectRateCardRolesByProjectId.pending, (state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(deleteProjectRateCardRolesByProjectId.fulfilled, (state) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.rateCardRoles = [];
|
||||||
|
})
|
||||||
|
.addCase(deleteProjectRateCardRolesByProjectId.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
toggleDrawer,
|
||||||
|
clearDrawerRole,
|
||||||
|
clearError,
|
||||||
|
} = projectFinanceSlice.actions;
|
||||||
|
|
||||||
|
export default projectFinanceSlice.reducer;
|
||||||
@@ -1,47 +1,68 @@
|
|||||||
import { Drawer, Typography, Button, Table, Menu, Flex } from 'antd';
|
import { Drawer, Typography, Button, Table, Menu, Flex, Spin } from 'antd';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppSelector } from '../../../hooks/useAppSelector';
|
import { useAppSelector } from '../../../hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
||||||
import { fetchData } from '../../../utils/fetchData';
|
import { fetchRateCards, toggleImportRatecardsDrawer } from '../finance-slice';
|
||||||
import { toggleImportRatecardsDrawer } from '../finance-slice';
|
import { fetchRateCardById } from '../finance-slice';
|
||||||
import { RatecardType } from '@/types/project/ratecard.types';
|
import { insertProjectRateCardRoles } from '../project-finance-slice';
|
||||||
const ImportRatecardsDrawer: React.FC = () => {
|
import { useParams } from 'react-router-dom';
|
||||||
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
|
|
||||||
const [selectedRatecardId, setSelectedRatecardId] = useState<string | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
// localization
|
const ImportRatecardsDrawer: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { projectId } = useParams();
|
||||||
const { t } = useTranslation('project-view-finance');
|
const { t } = useTranslation('project-view-finance');
|
||||||
|
|
||||||
// get drawer state from client reducer
|
const drawerRatecard = useAppSelector(
|
||||||
|
(state) => state.financeReducer.drawerRatecard
|
||||||
|
);
|
||||||
|
const ratecardsList = useAppSelector(
|
||||||
|
(state) => state.financeReducer.ratecardsList || []
|
||||||
|
);
|
||||||
const isDrawerOpen = useAppSelector(
|
const isDrawerOpen = useAppSelector(
|
||||||
(state) => state.financeReducer.isImportRatecardsDrawerOpen
|
(state) => state.financeReducer.isImportRatecardsDrawerOpen
|
||||||
);
|
);
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
// fetch rate cards data
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData('/finance-mock-data/ratecards-data.json', setRatecardsList);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// get currently using currency from finance reducer
|
|
||||||
const currency = useAppSelector(
|
const currency = useAppSelector(
|
||||||
(state) => state.financeReducer.currency
|
(state) => state.financeReducer.currency
|
||||||
).toUpperCase();
|
).toUpperCase();
|
||||||
|
|
||||||
// find the selected rate card's job roles
|
const rolesRedux = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || [];
|
||||||
const selectedRatecard =
|
|
||||||
ratecardsList.find(
|
// Loading states
|
||||||
(ratecard) => ratecard.ratecardId === selectedRatecardId
|
const isRatecardsLoading = useAppSelector(
|
||||||
) || null;
|
(state) => state.financeReducer.isRatecardsLoading
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedRatecardId, setSelectedRatecardId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedRatecardId) {
|
||||||
|
dispatch(fetchRateCardById(selectedRatecardId));
|
||||||
|
}
|
||||||
|
}, [selectedRatecardId, dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDrawerOpen) {
|
||||||
|
dispatch(fetchRateCards({
|
||||||
|
index: 1,
|
||||||
|
size: 1000,
|
||||||
|
field: 'name',
|
||||||
|
order: 'asc',
|
||||||
|
search: '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [isDrawerOpen, dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ratecardsList.length > 0 && !selectedRatecardId) {
|
||||||
|
setSelectedRatecardId(ratecardsList[0].id || null);
|
||||||
|
}
|
||||||
|
}, [ratecardsList, selectedRatecardId]);
|
||||||
|
|
||||||
// table columns
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: t('jobTitleColumn'),
|
title: t('jobTitleColumn'),
|
||||||
dataIndex: 'jobTitle',
|
dataIndex: 'jobtitle',
|
||||||
render: (text: string) => (
|
render: (text: string) => (
|
||||||
<Typography.Text className="group-hover:text-[#1890ff]">
|
<Typography.Text className="group-hover:text-[#1890ff]">
|
||||||
{text}
|
{text}
|
||||||
@@ -50,7 +71,7 @@ const ImportRatecardsDrawer: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: `${t('ratePerHourColumn')} (${currency})`,
|
title: `${t('ratePerHourColumn')} (${currency})`,
|
||||||
dataIndex: 'ratePerHour',
|
dataIndex: 'rate',
|
||||||
render: (text: number) => <Typography.Text>{text}</Typography.Text>,
|
render: (text: number) => <Typography.Text>{text}</Typography.Text>,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -64,7 +85,32 @@ const ImportRatecardsDrawer: React.FC = () => {
|
|||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<Button type="primary">Import</Button>
|
<Button
|
||||||
|
type="primary"
|
||||||
|
disabled={rolesRedux.length !== 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (!projectId) {
|
||||||
|
// Handle missing project id (show error, etc.)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (drawerRatecard?.jobRolesList?.length) {
|
||||||
|
dispatch(
|
||||||
|
insertProjectRateCardRoles({
|
||||||
|
project_id: projectId,
|
||||||
|
roles: drawerRatecard.jobRolesList
|
||||||
|
.filter((role) => typeof role.rate !== 'undefined')
|
||||||
|
.map((role) => ({
|
||||||
|
...role,
|
||||||
|
rate: Number(role.rate),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dispatch(toggleImportRatecardsDrawer());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('import')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
open={isDrawerOpen}
|
open={isDrawerOpen}
|
||||||
@@ -72,43 +118,44 @@ const ImportRatecardsDrawer: React.FC = () => {
|
|||||||
width={1000}
|
width={1000}
|
||||||
>
|
>
|
||||||
<Flex gap={12}>
|
<Flex gap={12}>
|
||||||
{/* sidebar menu */}
|
{/* Sidebar menu with loading */}
|
||||||
<Menu
|
<Spin spinning={isRatecardsLoading} style={{ width: '20%' }}>
|
||||||
mode="vertical"
|
<Menu
|
||||||
style={{ width: '20%' }}
|
mode="vertical"
|
||||||
selectedKeys={
|
style={{ width: '100%' }}
|
||||||
selectedRatecardId
|
selectedKeys={
|
||||||
? [selectedRatecardId]
|
selectedRatecardId
|
||||||
: [ratecardsList[0]?.ratecardId]
|
? [selectedRatecardId]
|
||||||
}
|
: ratecardsList[0]?.id
|
||||||
onClick={({ key }) => setSelectedRatecardId(key)}
|
? [ratecardsList[0].id]
|
||||||
>
|
: []
|
||||||
{ratecardsList.map((ratecard) => (
|
}
|
||||||
<Menu.Item key={ratecard.ratecardId}>
|
onClick={({ key }) => setSelectedRatecardId(key)}
|
||||||
{ratecard.ratecardName}
|
>
|
||||||
</Menu.Item>
|
{ratecardsList.map((ratecard) => (
|
||||||
))}
|
<Menu.Item key={ratecard.id}>
|
||||||
</Menu>
|
{ratecard.name}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</Spin>
|
||||||
|
|
||||||
{/* table for job roles */}
|
{/* Table for job roles with loading */}
|
||||||
<Table
|
<Table
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
dataSource={selectedRatecard?.jobRolesList || []}
|
dataSource={drawerRatecard?.jobRolesList || []}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(record) => record.jobId}
|
rowKey={(record) => record.job_title_id}
|
||||||
onRow={() => {
|
onRow={() => ({
|
||||||
return {
|
className: 'group',
|
||||||
className: 'group',
|
style: { cursor: 'pointer' },
|
||||||
style: {
|
})}
|
||||||
cursor: 'pointer',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
loading={isRatecardsLoading}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ImportRatecardsDrawer;
|
export default ImportRatecardsDrawer;
|
||||||
@@ -3,10 +3,9 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppSelector } from '../../../hooks/useAppSelector';
|
import { useAppSelector } from '../../../hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
||||||
import { clearDrawerRatecard, fetchRateCardById, fetchRateCards, toggleRatecardDrawer, updateRateCard } from '../finance-slice';
|
import { deleteRateCard, fetchRateCardById, fetchRateCards, toggleRatecardDrawer, updateRateCard } from '../finance-slice';
|
||||||
import { RatecardType, IJobType } from '@/types/project/ratecard.types';
|
import { RatecardType, IJobType } from '@/types/project/ratecard.types';
|
||||||
import { IJobTitlesViewModel } from '@/types/job.types';
|
import { IJobTitlesViewModel } from '@/types/job.types';
|
||||||
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
|
|
||||||
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
|
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
|
||||||
import { DeleteOutlined } from '@ant-design/icons';
|
import { DeleteOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ const RatecardDrawer = ({
|
|||||||
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
|
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
|
||||||
// initial Job Roles List (dummy data)
|
// initial Job Roles List (dummy data)
|
||||||
const [roles, setRoles] = useState<IJobType[]>([]);
|
const [roles, setRoles] = useState<IJobType[]>([]);
|
||||||
|
const [addingRowIndex, setAddingRowIndex] = useState<number | null>(null);
|
||||||
const { t } = useTranslation('settings/ratecard-settings');
|
const { t } = useTranslation('settings/ratecard-settings');
|
||||||
// get drawer state from client reducer
|
// get drawer state from client reducer
|
||||||
const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading);
|
const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading);
|
||||||
@@ -49,13 +48,14 @@ const RatecardDrawer = ({
|
|||||||
const [jobTitles, setJobTitles] = useState<IJobTitlesViewModel>({});
|
const [jobTitles, setJobTitles] = useState<IJobTitlesViewModel>({});
|
||||||
const [pagination, setPagination] = useState<PaginationType>({
|
const [pagination, setPagination] = useState<PaginationType>({
|
||||||
current: 1,
|
current: 1,
|
||||||
pageSize: DEFAULT_PAGE_SIZE,
|
pageSize: 10000,
|
||||||
field: 'name',
|
field: 'name',
|
||||||
order: 'desc',
|
order: 'desc',
|
||||||
total: 0,
|
total: 0,
|
||||||
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
|
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
|
||||||
size: 'small',
|
size: 'small',
|
||||||
});
|
});
|
||||||
|
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const getJobTitles = useMemo(() => {
|
const getJobTitles = useMemo(() => {
|
||||||
return async () => {
|
return async () => {
|
||||||
@@ -102,23 +102,33 @@ const RatecardDrawer = ({
|
|||||||
// Add All handler
|
// Add All handler
|
||||||
const handleAddAllRoles = () => {
|
const handleAddAllRoles = () => {
|
||||||
if (!jobTitles.data) return;
|
if (!jobTitles.data) return;
|
||||||
// Filter out job titles already in roles
|
// Get current job_title_ids in roles
|
||||||
const existingIds = new Set(roles.map(r => r.job_title_id));
|
const existingIds = new Set(roles.map(r => r.job_title_id));
|
||||||
|
// Only add job titles not already present
|
||||||
const newRoles = jobTitles.data
|
const newRoles = jobTitles.data
|
||||||
.filter(jt => !existingIds.has(jt.id!))
|
.filter(jt => jt.id && !existingIds.has(jt.id))
|
||||||
.map(jt => ({
|
.map(jt => ({
|
||||||
jobtitle: jt.name,
|
jobtitle: jt.name,
|
||||||
rate_card_id: ratecardId,
|
rate_card_id: ratecardId,
|
||||||
job_title_id: jt.id!,
|
job_title_id: jt.id!,
|
||||||
rate: 0,
|
rate: 0,
|
||||||
}));
|
}));
|
||||||
setRoles([...roles, ...newRoles]);
|
// 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
|
||||||
|
);
|
||||||
|
setRoles(mergedRoles);
|
||||||
};
|
};
|
||||||
|
|
||||||
// add new job role handler
|
|
||||||
const handleAddRole = () => {
|
const handleAddRole = () => {
|
||||||
setIsAddingRole(true);
|
// Only allow adding if there are job titles not already in roles
|
||||||
setSelectedJobTitleId(undefined);
|
const existingIds = new Set(roles.map(r => r.job_title_id));
|
||||||
|
const availableJobTitles = jobTitles.data?.filter(jt => !existingIds.has(jt.id!));
|
||||||
|
if (availableJobTitles && availableJobTitles.length > 0) {
|
||||||
|
setRoles([...roles, { job_title_id: '', rate: 0 }]);
|
||||||
|
setAddingRowIndex(roles.length); // index of the new row
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const handleDeleteRole = (index: number) => {
|
const handleDeleteRole = (index: number) => {
|
||||||
const updatedRoles = [...roles];
|
const updatedRoles = [...roles];
|
||||||
@@ -126,6 +136,12 @@ const RatecardDrawer = ({
|
|||||||
setRoles(updatedRoles);
|
setRoles(updatedRoles);
|
||||||
};
|
};
|
||||||
const handleSelectJobTitle = (jobTitleId: string) => {
|
const handleSelectJobTitle = (jobTitleId: string) => {
|
||||||
|
// Prevent duplicate job_title_id
|
||||||
|
if (roles.some(role => role.job_title_id === jobTitleId)) {
|
||||||
|
setIsAddingRole(false);
|
||||||
|
setSelectedJobTitleId(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const jobTitle = jobTitles.data?.find(jt => jt.id === jobTitleId);
|
const jobTitle = jobTitles.data?.find(jt => jt.id === jobTitleId);
|
||||||
if (jobTitle) {
|
if (jobTitle) {
|
||||||
const newRole = {
|
const newRole = {
|
||||||
@@ -143,12 +159,14 @@ const RatecardDrawer = ({
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (type === 'update' && ratecardId) {
|
if (type === 'update' && ratecardId) {
|
||||||
try {
|
try {
|
||||||
|
// Filter out roles with no jobtitle or empty jobtitle
|
||||||
|
const filteredRoles = roles.filter(role => role.jobtitle && role.jobtitle.trim() !== '');
|
||||||
await dispatch(updateRateCard({
|
await dispatch(updateRateCard({
|
||||||
id: ratecardId,
|
id: ratecardId,
|
||||||
body: {
|
body: {
|
||||||
name,
|
name,
|
||||||
currency,
|
currency,
|
||||||
jobRolesList: roles,
|
jobRolesList: filteredRoles,
|
||||||
},
|
},
|
||||||
}) as any);
|
}) as any);
|
||||||
// Refresh the rate cards list in Redux
|
// Refresh the rate cards list in Redux
|
||||||
@@ -177,24 +195,56 @@ const RatecardDrawer = ({
|
|||||||
{
|
{
|
||||||
title: t('jobTitleColumn'),
|
title: t('jobTitleColumn'),
|
||||||
dataIndex: 'jobtitle',
|
dataIndex: 'jobtitle',
|
||||||
render: (text: string, record: any, index: number) => (
|
render: (text: string, record: any, index: number) => {
|
||||||
<Input
|
if (index === addingRowIndex || index === editingRowIndex) {
|
||||||
value={text}
|
return (
|
||||||
placeholder="Enter job title"
|
<Select
|
||||||
style={{
|
showSearch
|
||||||
background: 'transparent',
|
autoFocus
|
||||||
border: 'none',
|
placeholder={t('selectJobTitle')}
|
||||||
boxShadow: 'none',
|
style={{ minWidth: 150 }}
|
||||||
padding: 0,
|
value={record.job_title_id || undefined}
|
||||||
color: '#1890ff',
|
onChange={value => {
|
||||||
}}
|
// Prevent duplicate job_title_id
|
||||||
onChange={(e) => {
|
if (roles.some((role, idx) => role.job_title_id === value && idx !== index)) {
|
||||||
const updatedRoles = [...roles];
|
return;
|
||||||
updatedRoles[index].jobtitle = e.target.value;
|
}
|
||||||
setRoles(updatedRoles);
|
const updatedRoles = [...roles];
|
||||||
}}
|
const selectedJob = jobTitles.data?.find(jt => jt.id === value);
|
||||||
/>
|
updatedRoles[index].job_title_id = value;
|
||||||
),
|
updatedRoles[index].jobtitle = selectedJob?.name || '';
|
||||||
|
setRoles(updatedRoles);
|
||||||
|
setEditingRowIndex(null);
|
||||||
|
setAddingRowIndex(null);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setEditingRowIndex(null);
|
||||||
|
setAddingRowIndex(null);
|
||||||
|
}}
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.children as string).toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{jobTitles.data
|
||||||
|
?.filter(jt => !roles.some((role, idx) => role.job_title_id === jt.id && idx !== index))
|
||||||
|
.map(jt => (
|
||||||
|
<Select.Option key={jt.id} value={jt.id}>
|
||||||
|
{jt.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Render as clickable text for existing rows
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => setEditingRowIndex(index)}
|
||||||
|
>
|
||||||
|
{record.jobtitle}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: `${t('ratePerHourColumn')} (${currency})`,
|
title: `${t('ratePerHourColumn')} (${currency})`,
|
||||||
@@ -230,10 +280,21 @@ const RatecardDrawer = ({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const handleDrawerClose = async () => {
|
||||||
|
if (
|
||||||
|
drawerRatecard &&
|
||||||
|
(drawerRatecard.jobRolesList?.length === 0 || !drawerRatecard.jobRolesList) &&
|
||||||
|
name === 'Untitled Rate Card'
|
||||||
|
) {
|
||||||
|
await dispatch(deleteRateCard(drawerRatecard.id as string));
|
||||||
|
}
|
||||||
|
dispatch(toggleRatecardDrawer());
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
loading={drawerLoading}
|
loading={drawerLoading}
|
||||||
|
onClose={handleDrawerClose}
|
||||||
title={
|
title={
|
||||||
<Flex align="center" justify="space-between">
|
<Flex align="center" justify="space-between">
|
||||||
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||||
@@ -272,7 +333,6 @@ const RatecardDrawer = ({
|
|||||||
</Flex>
|
</Flex>
|
||||||
}
|
}
|
||||||
open={isDrawerOpen}
|
open={isDrawerOpen}
|
||||||
onClose={() => dispatch(toggleRatecardDrawer())}
|
|
||||||
width={700}
|
width={700}
|
||||||
footer={
|
footer={
|
||||||
<Flex justify="end" gap={16} style={{ marginTop: 16 }}>
|
<Flex justify="end" gap={16} style={{ marginTop: 16 }}>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const RatecardTab = () => {
|
|||||||
>
|
>
|
||||||
{t('ratecardImportantNotice')}
|
{t('ratecardImportantNotice')}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Button
|
{/* <Button
|
||||||
type="primary"
|
type="primary"
|
||||||
style={{
|
style={{
|
||||||
marginTop: '10px',
|
marginTop: '10px',
|
||||||
@@ -27,7 +27,7 @@ const RatecardTab = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('saveButton')}
|
{t('saveButton')}
|
||||||
</Button>
|
</Button> */}
|
||||||
|
|
||||||
{/* import ratecards drawer */}
|
{/* import ratecards drawer */}
|
||||||
<ImportRatecardsDrawer />
|
<ImportRatecardsDrawer />
|
||||||
|
|||||||
@@ -1,108 +1,176 @@
|
|||||||
import { Avatar, Button, Input, Table, TableProps } from 'antd';
|
import { Avatar, Button, Input, Popconfirm, Table, TableProps, Select, Flex } from 'antd';
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import CustomAvatar from '../../../../../../components/CustomAvatar';
|
import CustomAvatar from '../../../../../../components/CustomAvatar';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons';
|
||||||
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
|
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '../../../../../../hooks/useAppDispatch';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { JobRoleType } from '@/types/project/ratecard.types';
|
import { JobRoleType, IJobType, RatecardType } from '@/types/project/ratecard.types';
|
||||||
|
import {
|
||||||
const initialJobRolesList: JobRoleType[] = [
|
deleteProjectRateCardRoleById,
|
||||||
{
|
fetchProjectRateCardRoles,
|
||||||
jobId: 'J001',
|
updateProjectRateCardRolesByProjectId,
|
||||||
jobTitle: 'Project Manager',
|
} from '@/features/finance/project-finance-slice';
|
||||||
ratePerHour: 50,
|
import { useParams } from 'react-router-dom';
|
||||||
members: ['Alice Johnson', 'Bob Smith'],
|
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
|
||||||
},
|
|
||||||
{
|
|
||||||
jobId: 'J002',
|
|
||||||
jobTitle: 'Senior Software Engineer',
|
|
||||||
ratePerHour: 40,
|
|
||||||
members: ['Charlie Brown', 'Diana Prince'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
jobId: 'J003',
|
|
||||||
jobTitle: 'Junior Software Engineer',
|
|
||||||
ratePerHour: 25,
|
|
||||||
members: ['Eve Davis', 'Frank Castle'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
jobId: 'J004',
|
|
||||||
jobTitle: 'UI/UX Designer',
|
|
||||||
ratePerHour: 30,
|
|
||||||
members: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const RatecardTable: React.FC = () => {
|
const RatecardTable: React.FC = () => {
|
||||||
const [roles, setRoles] = useState<JobRoleType[]>(initialJobRolesList);
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
// localization
|
|
||||||
const { t } = useTranslation('project-view-finance');
|
const { t } = useTranslation('project-view-finance');
|
||||||
|
const { projectId } = useParams();
|
||||||
|
|
||||||
// get currently using currency from finance reducer
|
// Redux state
|
||||||
const currency = useAppSelector(
|
const rolesRedux = useAppSelector((state) => state.projectFinanceRateCard.rateCardRoles) || [];
|
||||||
(state) => state.financeReducer.currency
|
const isLoading = useAppSelector((state) => state.projectFinanceRateCard.isLoading);
|
||||||
).toUpperCase();
|
const currency = useAppSelector((state) => state.financeReducer.currency).toUpperCase();
|
||||||
|
|
||||||
|
// Local state for editing
|
||||||
|
const [roles, setRoles] = useState<JobRoleType[]>(rolesRedux);
|
||||||
|
const [addingRow, setAddingRow] = useState<boolean>(false);
|
||||||
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
|
const [jobTitles, setJobTitles] = useState<RatecardType[]>([]);
|
||||||
|
|
||||||
|
// 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 = () => {
|
const handleAddRole = () => {
|
||||||
const newRole: JobRoleType = {
|
setAddingRow(true);
|
||||||
jobId: `J00${roles.length + 1}`,
|
|
||||||
jobTitle: 'New Role',
|
|
||||||
ratePerHour: 0,
|
|
||||||
members: [],
|
|
||||||
};
|
|
||||||
setRoles([...roles, newRole]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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<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) => {
|
||||||
<Input
|
// Only show Select if addingRow and this is the last row (new row)
|
||||||
value={text}
|
if (addingRow && index === roles.length) {
|
||||||
placeholder="Enter job title"
|
return (
|
||||||
style={{
|
<Select
|
||||||
background: 'transparent',
|
showSearch
|
||||||
border: 'none',
|
autoFocus
|
||||||
boxShadow: 'none',
|
placeholder={t('selectJobTitle')}
|
||||||
padding: 0,
|
style={{ minWidth: 150 }}
|
||||||
color: '#1890ff',
|
value={record.job_title_id || undefined}
|
||||||
}}
|
onChange={handleSelectJobTitle}
|
||||||
onChange={(e) => {
|
onBlur={() => setAddingRow(false)}
|
||||||
const updatedRoles = [...roles];
|
filterOption={(input, option) =>
|
||||||
updatedRoles[index].jobTitle = e.target.value;
|
(option?.children as string).toLowerCase().includes(input.toLowerCase())
|
||||||
setRoles(updatedRoles);
|
}
|
||||||
}}
|
>
|
||||||
/>
|
{jobTitles
|
||||||
),
|
.filter(jt => !roles.some((role) => role.job_title_id === jt.id))
|
||||||
|
.map(jt => (
|
||||||
|
<Select.Option key={jt.id} value={jt.id!}>
|
||||||
|
{jt.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => setEditingIndex(index)}
|
||||||
|
>
|
||||||
|
{text || record.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: `${t('ratePerHourColumn')} (${currency})`,
|
title: `${t('ratePerHourColumn')} (${currency})`,
|
||||||
dataIndex: 'ratePerHour',
|
dataIndex: 'rate',
|
||||||
render: (text: number, record: JobRoleType, index: number) => (
|
render: (value: number, record: JobRoleType, index: number) => (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={text}
|
value={roles[index]?.rate ?? 0}
|
||||||
style={{
|
style={{
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
width: 80,
|
||||||
}}
|
}}
|
||||||
onChange={(e) => {
|
onChange={(e) => handleRateChange(e.target.value, index)}
|
||||||
const updatedRoles = [...roles];
|
|
||||||
updatedRoles[index].ratePerHour = parseInt(e.target.value, 10) || 0;
|
|
||||||
setRoles(updatedRoles);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('membersColumn'),
|
title: t('membersColumn'),
|
||||||
dataIndex: 'members',
|
dataIndex: 'members',
|
||||||
render: (members: string[]) =>
|
render: (members: string[] | null | undefined) =>
|
||||||
members?.length > 0 ? (
|
members && members.length > 0 ? (
|
||||||
<Avatar.Group>
|
<Avatar.Group>
|
||||||
{members.map((member, i) => (
|
{members.map((member, i) => (
|
||||||
<CustomAvatar key={i} avatarName={member} size={26} />
|
<CustomAvatar key={i} avatarName={member} size={26} />
|
||||||
@@ -126,25 +194,66 @@ const RatecardTable: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('actions'),
|
||||||
|
key: 'actions',
|
||||||
|
render: (_: any, record: JobRoleType, index: number) => (
|
||||||
|
<Popconfirm
|
||||||
|
title={t('deleteConfirm')}
|
||||||
|
onConfirm={() => handleDelete(record, index)}
|
||||||
|
okText={t('yes')}
|
||||||
|
cancelText={t('no')}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
dataSource={roles}
|
dataSource={
|
||||||
|
addingRow
|
||||||
|
? [
|
||||||
|
...roles,
|
||||||
|
{
|
||||||
|
job_title_id: '',
|
||||||
|
jobtitle: '',
|
||||||
|
rate: 0,
|
||||||
|
members: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: roles
|
||||||
|
}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(record) => record.jobId}
|
rowKey={(record, idx) => record.id || record.job_title_id || idx}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
loading={isLoading}
|
||||||
footer={() => (
|
footer={() => (
|
||||||
<Button
|
<Flex gap={8}>
|
||||||
type="dashed"
|
<Button
|
||||||
onClick={handleAddRole}
|
type="dashed"
|
||||||
style={{ width: 'fit-content' }}
|
onClick={handleAddRole}
|
||||||
>
|
style={{ width: 'fit-content' }}
|
||||||
{t('addRoleButton')}
|
>
|
||||||
</Button>
|
{t('addRoleButton')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
disabled={roles.length === 0}
|
||||||
|
>
|
||||||
|
{t('saveButton') || 'Save'}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RatecardTable;
|
export default RatecardTable;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export interface IServerResponse<T> {
|
export interface IServerResponse<T> {
|
||||||
|
data: any;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
|
|
||||||
export interface IJobType {
|
export interface IJobType {
|
||||||
jobId?: string;
|
id?: string;
|
||||||
jobtitle?: string;
|
jobId?: string;
|
||||||
ratePerHour?: number;
|
jobtitle?: string;
|
||||||
rate_card_id?: string;
|
ratePerHour?: number;
|
||||||
job_title_id: string;
|
rate_card_id?: string;
|
||||||
rate?: number;
|
job_title_id: string;
|
||||||
};
|
rate?: number;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
export interface JobRoleType extends IJobType {
|
export interface JobRoleType extends IJobType {
|
||||||
members: string[] | null;
|
members?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RatecardType {
|
export interface RatecardType {
|
||||||
@@ -23,3 +25,8 @@ export interface IRatecardViewModel {
|
|||||||
total?: number;
|
total?: number;
|
||||||
data?: RatecardType[];
|
data?: RatecardType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IProjectRateCardRole {
|
||||||
|
project_id: string;
|
||||||
|
roles: IJobType[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user