Merge pull request #136 from shancds/feature/project-finance

Feature/project finance rate card section
This commit is contained in:
Chamika J
2025-05-21 21:43:21 +05:30
committed by GitHub
14 changed files with 918 additions and 183 deletions

View 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));
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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"
} }

View File

@@ -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;
},
};

View File

@@ -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,
}, },
}); });

View File

@@ -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;

View 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;

View File

@@ -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,39 +118,40 @@ 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>

View File

@@ -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 }}>

View File

@@ -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 />

View File

@@ -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,22 +194,63 @@ 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>
)} )}
/> />
); );

View File

@@ -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;

View File

@@ -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[];
}