diff --git a/worklenz-backend/database/migrations/20250519000000-project-finance-module-tables.sql b/worklenz-backend/database/migrations/20250519000000-project-finance-module-tables.sql new file mode 100644 index 00000000..f48dc750 --- /dev/null +++ b/worklenz-backend/database/migrations/20250519000000-project-finance-module-tables.sql @@ -0,0 +1,48 @@ +-- Dropping existing finance_rate_cards table +DROP TABLE IF EXISTS finance_rate_cards; +-- Creating table to store rate card details +CREATE TABLE finance_rate_cards +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE, + name VARCHAR NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Dropping existing finance_project_rate_card_roles table +DROP TABLE IF EXISTS finance_project_rate_card_roles; +-- Creating table with single id primary key +CREATE TABLE finance_project_rate_card_roles +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles (id) ON DELETE CASCADE, + rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_project_role UNIQUE (project_id, role_id) +); + +-- Dropping existing finance_rate_card_roles table +DROP TABLE IF EXISTS finance_rate_card_roles; +-- Creating table to store role-specific rates for rate cards +CREATE TABLE finance_rate_card_roles +( + rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE, + role_id UUID REFERENCES roles (id) ON DELETE SET NULL, + rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Adding project_rate_card_role_id column to project_members +ALTER TABLE project_members + ADD COLUMN project_rate_card_role_id UUID REFERENCES finance_project_rate_card_roles(id) ON DELETE SET NULL; + +-- Adding rate_card column to projects +ALTER TABLE projects + ADD COLUMN rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL; + +ALTER TABLE finance_rate_cards + ADD COLUMN currency TEXT NOT NULL DEFAULT 'LKR'; diff --git a/worklenz-backend/src/controllers/ratecard-controller.ts b/worklenz-backend/src/controllers/ratecard-controller.ts new file mode 100644 index 00000000..497a6869 --- /dev/null +++ b/worklenz-backend/src/controllers/ratecard-controller.ts @@ -0,0 +1,84 @@ +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; +import db from "../config/db"; +import { ServerResponse } from "../models/server-response"; +import WorklenzControllerBase from "./worklenz-controller-base"; +import HandleExceptions from "../decorators/handle-exceptions"; + +export default class RateCardController extends WorklenzControllerBase { + @HandleExceptions() + public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const q = ` + INSERT INTO finance_rate_cards (team_id, name) + VALUES ($1, $2) + RETURNING id, name, team_id, created_at, updated_at; + `; + const result = await db.query(q, [req.user?.team_id || null, req.body.name]); + const [data] = result.rows; + return res.status(200).send(new ServerResponse(true, data)); + } + + @HandleExceptions() +public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { searchQuery, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, "name"); + + const q = ` + SELECT ROW_TO_JSON(rec) AS rate_cards + FROM ( + SELECT COUNT(*) AS total, + ( + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) + FROM ( + SELECT id, name, team_id, created_at, updated_at + FROM finance_rate_cards + WHERE team_id = $1 ${searchQuery} + ORDER BY ${sortField} ${sortOrder} + LIMIT $2 OFFSET $3 + ) t + ) AS data + FROM finance_rate_cards + WHERE team_id = $1 ${searchQuery} + ) rec; + `; + const result = await db.query(q, [req.user?.team_id || null, size, offset]); + const [data] = result.rows; + + return res.status(200).send(new ServerResponse(true, data.rate_cards || this.paginatedDatasetDefaultStruct)); +} + + @HandleExceptions() + public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const q = ` + SELECT id, name, team_id, created_at, updated_at + FROM finance_rate_cards + WHERE id = $1 AND team_id = $2; + `; + const result = await db.query(q, [req.params.id, req.user?.team_id || null]); + const [data] = result.rows; + return res.status(200).send(new ServerResponse(true, data)); + } + + @HandleExceptions() + public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const q = ` + UPDATE finance_rate_cards + SET name = $3, updated_at = NOW() + WHERE id = $1 AND team_id = $2 + RETURNING id, name, team_id, created_at, updated_at; + `; + const result = await db.query(q, [req.params.id, req.user?.team_id || null, req.body.name]); + const [data] = result.rows; + return res.status(200).send(new ServerResponse(true, data)); + } + + @HandleExceptions() + public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const q = ` + DELETE FROM finance_rate_cards + WHERE id = $1 AND team_id = $2 + RETURNING id; + `; + const result = await db.query(q, [req.params.id, req.user?.team_id || null]); + return res.status(200).send(new ServerResponse(true, result.rows.length > 0)); + } +} diff --git a/worklenz-backend/src/middlewares/validators/ratecard-body-validator.ts b/worklenz-backend/src/middlewares/validators/ratecard-body-validator.ts new file mode 100644 index 00000000..04c4e499 --- /dev/null +++ b/worklenz-backend/src/middlewares/validators/ratecard-body-validator.ts @@ -0,0 +1,15 @@ +import {NextFunction} from "express"; + +import {IWorkLenzRequest} from "../../interfaces/worklenz-request"; +import {IWorkLenzResponse} from "../../interfaces/worklenz-response"; +import {ServerResponse} from "../../models/server-response"; + +export default function (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void { + const {name} = req.body; + if (!name || name.trim() === "") + return res.status(200).send(new ServerResponse(false, null, "Name is required")); + + req.body.name = req.body.name.trim(); + + return next(); +} diff --git a/worklenz-backend/src/routes/apis/index.ts b/worklenz-backend/src/routes/apis/index.ts index 5a2019c8..3a1df537 100644 --- a/worklenz-backend/src/routes/apis/index.ts +++ b/worklenz-backend/src/routes/apis/index.ts @@ -1,120 +1,123 @@ -import express from "express"; - -import AccessControlsController from "../../controllers/access-controls-controller"; -import AuthController from "../../controllers/auth-controller"; -import LogsController from "../../controllers/logs-controller"; -import OverviewController from "../../controllers/overview-controller"; -import TaskPrioritiesController from "../../controllers/task-priorities-controller"; - -import attachmentsApiRouter from "./attachments-api-router"; -import clientsApiRouter from "./clients-api-router"; -import jobTitlesApiRouter from "./job-titles-api-router"; -import notificationsApiRouter from "./notifications-api-router"; -import personalOverviewApiRouter from "./personal-overview-api-router"; -import projectMembersApiRouter from "./project-members-api-router"; -import projectsApiRouter from "./projects-api-router"; -import settingsApiRouter from "./settings-api-router"; -import statusesApiRouter from "./statuses-api-router"; -import subTasksApiRouter from "./sub-tasks-api-router"; -import taskCommentsApiRouter from "./task-comments-api-router"; -import taskWorkLogApiRouter from "./task-work-log-api-router"; -import tasksApiRouter from "./tasks-api-router"; -import teamMembersApiRouter from "./team-members-api-router"; -import teamsApiRouter from "./teams-api-router"; -import timezonesApiRouter from "./timezones-api-router"; -import todoListApiRouter from "./todo-list-api-router"; -import projectStatusesApiRouter from "./project-statuses-api-router"; -import labelsApiRouter from "./labels-api-router"; -import sharedProjectsApiRouter from "./shared-projects-api-router"; -import resourceAllocationApiRouter from "./resource-allocation-api-router"; -import taskTemplatesApiRouter from "./task-templates-api-router"; -import projectInsightsApiRouter from "./project-insights-api-router"; -import passwordValidator from "../../middlewares/validators/password-validator"; -import adminCenterApiRouter from "./admin-center-api-router"; -import reportingApiRouter from "./reporting-api-router"; -import activityLogsApiRouter from "./activity-logs-api-router"; -import safeControllerFunction from "../../shared/safe-controller-function"; -import projectFoldersApiRouter from "./project-folders-api-router"; -import taskPhasesApiRouter from "./task-phases-api-router"; -import projectCategoriesApiRouter from "./project-categories-api-router"; -import homePageApiRouter from "./home-page-api-router"; -import ganttApiRouter from "./gantt-api-router"; -import projectCommentsApiRouter from "./project-comments-api-router"; -import reportingExportApiRouter from "./reporting-export-api-router"; -import projectHealthsApiRouter from "./project-healths-api-router"; -import ptTasksApiRouter from "./pt-tasks-api-router"; -import projectTemplatesApiRouter from "./project-templates-api"; -import ptTaskPhasesApiRouter from "./pt_task-phases-api-router"; -import ptStatusesApiRouter from "./pt-statuses-api-router"; -import workloadApiRouter from "./gannt-apis/workload-api-router"; -import roadmapApiRouter from "./gannt-apis/roadmap-api-router"; -import scheduleApiRouter from "./gannt-apis/schedule-api-router"; -import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router"; -import projectManagerApiRouter from "./project-managers-api-router"; - -import billingApiRouter from "./billing-api-router"; -import taskDependenciesApiRouter from "./task-dependencies-api-router"; - -import taskRecurringApiRouter from "./task-recurring-api-router"; - import customColumnsApiRouter from "./custom-columns-api-router"; +import express from "express"; + +import AccessControlsController from "../../controllers/access-controls-controller"; +import AuthController from "../../controllers/auth-controller"; +import LogsController from "../../controllers/logs-controller"; +import OverviewController from "../../controllers/overview-controller"; +import TaskPrioritiesController from "../../controllers/task-priorities-controller"; + +import attachmentsApiRouter from "./attachments-api-router"; +import clientsApiRouter from "./clients-api-router"; +import jobTitlesApiRouter from "./job-titles-api-router"; +import notificationsApiRouter from "./notifications-api-router"; +import personalOverviewApiRouter from "./personal-overview-api-router"; +import projectMembersApiRouter from "./project-members-api-router"; +import projectsApiRouter from "./projects-api-router"; +import settingsApiRouter from "./settings-api-router"; +import statusesApiRouter from "./statuses-api-router"; +import subTasksApiRouter from "./sub-tasks-api-router"; +import taskCommentsApiRouter from "./task-comments-api-router"; +import taskWorkLogApiRouter from "./task-work-log-api-router"; +import tasksApiRouter from "./tasks-api-router"; +import teamMembersApiRouter from "./team-members-api-router"; +import teamsApiRouter from "./teams-api-router"; +import timezonesApiRouter from "./timezones-api-router"; +import todoListApiRouter from "./todo-list-api-router"; +import projectStatusesApiRouter from "./project-statuses-api-router"; +import labelsApiRouter from "./labels-api-router"; +import sharedProjectsApiRouter from "./shared-projects-api-router"; +import resourceAllocationApiRouter from "./resource-allocation-api-router"; +import taskTemplatesApiRouter from "./task-templates-api-router"; +import projectInsightsApiRouter from "./project-insights-api-router"; +import passwordValidator from "../../middlewares/validators/password-validator"; +import adminCenterApiRouter from "./admin-center-api-router"; +import reportingApiRouter from "./reporting-api-router"; +import activityLogsApiRouter from "./activity-logs-api-router"; +import safeControllerFunction from "../../shared/safe-controller-function"; +import projectFoldersApiRouter from "./project-folders-api-router"; +import taskPhasesApiRouter from "./task-phases-api-router"; +import projectCategoriesApiRouter from "./project-categories-api-router"; +import homePageApiRouter from "./home-page-api-router"; +import ganttApiRouter from "./gantt-api-router"; +import projectCommentsApiRouter from "./project-comments-api-router"; +import reportingExportApiRouter from "./reporting-export-api-router"; +import projectHealthsApiRouter from "./project-healths-api-router"; +import ptTasksApiRouter from "./pt-tasks-api-router"; +import projectTemplatesApiRouter from "./project-templates-api"; +import ptTaskPhasesApiRouter from "./pt_task-phases-api-router"; +import ptStatusesApiRouter from "./pt-statuses-api-router"; +import workloadApiRouter from "./gannt-apis/workload-api-router"; +import roadmapApiRouter from "./gannt-apis/roadmap-api-router"; +import scheduleApiRouter from "./gannt-apis/schedule-api-router"; +import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router"; +import projectManagerApiRouter from "./project-managers-api-router"; + +import billingApiRouter from "./billing-api-router"; +import taskDependenciesApiRouter from "./task-dependencies-api-router"; + +import taskRecurringApiRouter from "./task-recurring-api-router"; + +import customColumnsApiRouter from "./custom-columns-api-router"; +import ratecardApiRouter from "./ratecard-api-router"; + +const api = express.Router(); + +api.use("/projects", projectsApiRouter); +api.use("/team-members", teamMembersApiRouter); +api.use("/job-titles", jobTitlesApiRouter); +api.use("/clients", clientsApiRouter); +api.use("/rate-cards", ratecardApiRouter); +api.use("/teams", teamsApiRouter); +api.use("/tasks", tasksApiRouter); +api.use("/settings", settingsApiRouter); +api.use("/personal-overview", personalOverviewApiRouter); +api.use("/statuses", statusesApiRouter); +api.use("/todo-list", todoListApiRouter); +api.use("/notifications", notificationsApiRouter); +api.use("/attachments", attachmentsApiRouter); +api.use("/sub-tasks", subTasksApiRouter); +api.use("/project-members", projectMembersApiRouter); +api.use("/task-time-log", taskWorkLogApiRouter); +api.use("/task-comments", taskCommentsApiRouter); +api.use("/timezones", timezonesApiRouter); +api.use("/project-statuses", projectStatusesApiRouter); +api.use("/labels", labelsApiRouter); +api.use("/resource-allocation", resourceAllocationApiRouter); +api.use("/shared/projects", sharedProjectsApiRouter); +api.use("/task-templates", taskTemplatesApiRouter); +api.use("/project-insights", projectInsightsApiRouter); +api.use("/admin-center", adminCenterApiRouter); +api.use("/reporting", reportingApiRouter); +api.use("/activity-logs", activityLogsApiRouter); +api.use("/projects-folders", projectFoldersApiRouter); +api.use("/task-phases", taskPhasesApiRouter); +api.use("/project-categories", projectCategoriesApiRouter); +api.use("/home", homePageApiRouter); +api.use("/gantt", ganttApiRouter); +api.use("/project-comments", projectCommentsApiRouter); +api.use("/reporting-export", reportingExportApiRouter); +api.use("/project-healths", projectHealthsApiRouter); +api.use("/project-templates", projectTemplatesApiRouter); +api.use("/pt-tasks", ptTasksApiRouter); +api.use("/pt-task-phases", ptTaskPhasesApiRouter); +api.use("/pt-statuses", ptStatusesApiRouter); +api.use("/workload-gannt", workloadApiRouter); +api.use("/roadmap-gannt", roadmapApiRouter); +api.use("/schedule-gannt", scheduleApiRouter); +api.use("/schedule-gannt-v2", scheduleApiV2Router); +api.use("/project-managers", projectManagerApiRouter); + +api.get("/overview/:id", safeControllerFunction(OverviewController.getById)); +api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get)); +api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword)); +api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles)); +api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog)); + +api.use("/billing", billingApiRouter); +api.use("/task-dependencies", taskDependenciesApiRouter); + +api.use("/task-recurring", taskRecurringApiRouter); -const api = express.Router(); - -api.use("/projects", projectsApiRouter); -api.use("/team-members", teamMembersApiRouter); -api.use("/job-titles", jobTitlesApiRouter); -api.use("/clients", clientsApiRouter); -api.use("/teams", teamsApiRouter); -api.use("/tasks", tasksApiRouter); -api.use("/settings", settingsApiRouter); -api.use("/personal-overview", personalOverviewApiRouter); -api.use("/statuses", statusesApiRouter); -api.use("/todo-list", todoListApiRouter); -api.use("/notifications", notificationsApiRouter); -api.use("/attachments", attachmentsApiRouter); -api.use("/sub-tasks", subTasksApiRouter); -api.use("/project-members", projectMembersApiRouter); -api.use("/task-time-log", taskWorkLogApiRouter); -api.use("/task-comments", taskCommentsApiRouter); -api.use("/timezones", timezonesApiRouter); -api.use("/project-statuses", projectStatusesApiRouter); -api.use("/labels", labelsApiRouter); -api.use("/resource-allocation", resourceAllocationApiRouter); -api.use("/shared/projects", sharedProjectsApiRouter); -api.use("/task-templates", taskTemplatesApiRouter); -api.use("/project-insights", projectInsightsApiRouter); -api.use("/admin-center", adminCenterApiRouter); -api.use("/reporting", reportingApiRouter); -api.use("/activity-logs", activityLogsApiRouter); -api.use("/projects-folders", projectFoldersApiRouter); -api.use("/task-phases", taskPhasesApiRouter); -api.use("/project-categories", projectCategoriesApiRouter); -api.use("/home", homePageApiRouter); -api.use("/gantt", ganttApiRouter); -api.use("/project-comments", projectCommentsApiRouter); -api.use("/reporting-export", reportingExportApiRouter); -api.use("/project-healths", projectHealthsApiRouter); -api.use("/project-templates", projectTemplatesApiRouter); -api.use("/pt-tasks", ptTasksApiRouter); -api.use("/pt-task-phases", ptTaskPhasesApiRouter); -api.use("/pt-statuses", ptStatusesApiRouter); -api.use("/workload-gannt", workloadApiRouter); -api.use("/roadmap-gannt", roadmapApiRouter); -api.use("/schedule-gannt", scheduleApiRouter); -api.use("/schedule-gannt-v2", scheduleApiV2Router); -api.use("/project-managers", projectManagerApiRouter); - -api.get("/overview/:id", safeControllerFunction(OverviewController.getById)); -api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get)); -api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword)); -api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles)); -api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog)); - -api.use("/billing", billingApiRouter); -api.use("/task-dependencies", taskDependenciesApiRouter); - -api.use("/task-recurring", taskRecurringApiRouter); - api.use("/custom-columns", customColumnsApiRouter); -export default api; +export default api; diff --git a/worklenz-backend/src/routes/apis/ratecard-api-router.ts b/worklenz-backend/src/routes/apis/ratecard-api-router.ts new file mode 100644 index 00000000..8ddbf2dd --- /dev/null +++ b/worklenz-backend/src/routes/apis/ratecard-api-router.ts @@ -0,0 +1,48 @@ +import express from "express"; + +import RateCardController from "../../controllers/ratecard-controller"; + + +import idParamValidator from "../../middlewares/validators/id-param-validator"; +import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-or-admin-validator"; +import safeControllerFunction from "../../shared/safe-controller-function"; +import projectManagerValidator from "../../middlewares/validators/project-manager-validator"; +import ratecardBodyValidator from "../../middlewares/validators/ratecard-body-validator"; + +const ratecardApiRouter = express.Router(); + +ratecardApiRouter.post( + "/", + projectManagerValidator, + ratecardBodyValidator, + safeControllerFunction(RateCardController.create) +); + +ratecardApiRouter.get( + "/", + safeControllerFunction(RateCardController.get) +); + +ratecardApiRouter.get( + "/:id", + teamOwnerOrAdminValidator, + idParamValidator, + safeControllerFunction(RateCardController.getById) +); + +ratecardApiRouter.put( + "/:id", + teamOwnerOrAdminValidator, + ratecardBodyValidator, + idParamValidator, + safeControllerFunction(RateCardController.update) +); + +ratecardApiRouter.delete( + "/:id", + teamOwnerOrAdminValidator, + idParamValidator, + safeControllerFunction(RateCardController.deleteById) +); + +export default ratecardApiRouter; diff --git a/worklenz-frontend/src/api/settings/rate-cards/rate-cards.api.service.ts b/worklenz-frontend/src/api/settings/rate-cards/rate-cards.api.service.ts new file mode 100644 index 00000000..dc313f29 --- /dev/null +++ b/worklenz-frontend/src/api/settings/rate-cards/rate-cards.api.service.ts @@ -0,0 +1,49 @@ +import apiClient from '@api/api-client'; +import { API_BASE_URL } from '@/shared/constants'; +import { IServerResponse } from '@/types/common.types'; +import { IJobTitle, IJobTitlesViewModel } from '@/types/job.types'; +import { toQueryString } from '@/utils/toQueryString'; + +type IRatecard = { + id: string;} +type IRatecardViewModel = { + id: string;} + +const rootUrl = `${API_BASE_URL}/rate-cards`; + +export const rateCardApiService = { + async getRateCards( + index: number, + size: number, + field: string | null, + order: string | null, + search?: string | null + ): Promise> { + const s = encodeURIComponent(search || ''); + const queryString = toQueryString({ index, size, field, order, search: s }); + const response = await apiClient.get>( + `${rootUrl}${queryString}` + ); + return response.data; + }, + async getRateCardById(id: string): Promise> { + const response = await apiClient.get>(`${rootUrl}/${id}`); + return response.data; + }, + + async createRateCard(body: IRatecard): Promise> { + const response = await apiClient.post>(rootUrl, body); + return response.data; + }, + + async updateRateCard(id: string, body: IRatecard): Promise> { + const response = await apiClient.put>(`${rootUrl}/${id}`, body); + return response.data; + }, + + async deleteRateCard(id: string): Promise> { + const response = await apiClient.delete>(`${rootUrl}/${id}`); + return response.data; + }, + +}; \ No newline at end of file diff --git a/worklenz-frontend/src/features/navbar/navRoutes.ts b/worklenz-frontend/src/features/navbar/navRoutes.ts index 5e7a9e8f..466ffd28 100644 --- a/worklenz-frontend/src/features/navbar/navRoutes.ts +++ b/worklenz-frontend/src/features/navbar/navRoutes.ts @@ -1,7 +1,7 @@ export type NavRoutesType = { name: string; path: string; - adminOnly: boolean; + adminOnly?: boolean; freePlanFeature?: boolean; };