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..a4c17fe5 --- /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 CASCADE; +-- 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, + job_title_id UUID NOT NULL REFERENCES job_titles (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, job_title_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, + job_title_id UUID REFERENCES job_titles(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..8b1fcd75 --- /dev/null +++ b/worklenz-backend/src/controllers/ratecard-controller.ts @@ -0,0 +1,157 @@ +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, currency, 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 { + // 1. Fetch the rate card + const q = ` + SELECT id, name, team_id, currency, 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; + + if (!data) { + return res.status(404).send(new ServerResponse(false, null, "Rate card not found")); + } + + // 2. Fetch job roles with job title names + const jobRolesQ = ` + SELECT + rcr.job_title_id, + jt.name AS jobTitle, + rcr.rate, + rcr.rate_card_id + FROM finance_rate_card_roles rcr + LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id + WHERE rcr.rate_card_id = $1 + `; + const jobRolesResult = await db.query(jobRolesQ, [req.params.id]); + const jobRolesList = jobRolesResult.rows; + + // 3. Return the rate card with jobRolesList + return res.status(200).send( + new ServerResponse(true, { + ...data, + jobRolesList, + }) + ); + } + + @HandleExceptions() + public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + // 1. Update the rate card + const updateRateCardQ = ` + UPDATE finance_rate_cards + SET name = $3, currency = $4, updated_at = NOW() + WHERE id = $1 AND team_id = $2 + RETURNING id, name, team_id, currency, created_at, updated_at; + `; + const result = await db.query(updateRateCardQ, [ + req.params.id, + req.user?.team_id || null, + req.body.name, + req.body.currency, + ]); + const [rateCardData] = result.rows; + + // 2. Update job roles (delete old, insert new) + if (Array.isArray(req.body.jobRolesList)) { + // Delete existing roles for this rate card + await db.query( + `DELETE FROM finance_rate_card_roles WHERE rate_card_id = $1;`, + [req.params.id] + ); + + // Insert new roles + for (const role of req.body.jobRolesList) { + if (role.job_title_id) { + await db.query( + `INSERT INTO finance_rate_card_roles (rate_card_id, job_title_id, rate) + VALUES ($1, $2, $3);`, + [req.params.id, role.job_title_id, role.rate ?? 0] + ); + } + } + } + + // 3. Get jobRolesList with job title names + const jobRolesQ = ` + SELECT + rcr.job_title_id, + jt.name AS jobTitle, + rcr.rate + FROM finance_rate_card_roles rcr + LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id + WHERE rcr.rate_card_id = $1 + `; + const jobRolesResult = await db.query(jobRolesQ, [req.params.id]); + const jobRolesList = jobRolesResult.rows; + + // 4. Return the updated rate card with jobRolesList + return res.status(200).send( + new ServerResponse(true, { + ...rateCardData, + jobRolesList, + }) + ); + } + + @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/public/finance-mock-data/finance-task-priority.json b/worklenz-frontend/public/finance-mock-data/finance-task-priority.json new file mode 100644 index 00000000..556c06a8 --- /dev/null +++ b/worklenz-frontend/public/finance-mock-data/finance-task-priority.json @@ -0,0 +1,163 @@ +[ + { + "id": "c2669c5f-a019-445b-b703-b941bbefdab7", + "type": "low", + "name": "Low", + "color_code": "#c2e4d0", + "color_code_dark": "#46d980", + "tasks": [ + { + "id": "4be5ef5c-1234-4247-b159-6d8df2b37d04", + "task": "Testing and QA", + "isBillable": false, + "hours": 180, + "cost": 18000, + "fixedCost": 2500, + "totalBudget": 20000, + "totalActual": 21000, + "variance": -1000, + "members": [ + { + "memberId": "6", + "name": "Eve Adams", + "jobId": "J006", + "jobRole": "QA Engineer", + "hourlyRate": 100 + } + ] + }, + { + "id": "6be5ef5c-1234-4247-b159-6d8df2b37d06", + "task": "Project Documentation", + "isBillable": false, + "hours": 100, + "cost": 10000, + "fixedCost": 1000, + "totalBudget": 12000, + "totalActual": 12500, + "variance": -500, + "members": [ + { + "memberId": "8", + "name": "Grace Lee", + "jobId": "J008", + "jobRole": "Technical Writer", + "hourlyRate": 100 + } + ] + } + ] + }, + { + "id": "d3f9c5f1-b019-445b-b703-b941bbefdab8", + "type": "medium", + "name": "Medium", + "color_code": "#f9e3b1", + "color_code_dark": "#ffc227", + "tasks": [ + { + "id": "1be5ef5c-1234-4247-b159-6d8df2b37d01", + "task": "UI Design", + "isBillable": true, + "hours": 120, + "cost": 12000, + "fixedCost": 1500, + "totalBudget": 14000, + "totalActual": 13500, + "variance": 500, + "members": [ + { + "memberId": "1", + "name": "John Doe", + "jobId": "J001", + "jobRole": "UI/UX Designer", + "hourlyRate": 100 + }, + { + "memberId": "2", + "name": "Jane Smith", + "jobId": "J002", + "jobRole": "Frontend Developer", + "hourlyRate": 120 + } + ] + }, + { + "id": "2be5ef5c-1234-4247-b159-6d8df2b37d02", + "task": "API Integration", + "isBillable": true, + "hours": 200, + "cost": 20000, + "fixedCost": 3000, + "totalBudget": 25000, + "totalActual": 26000, + "variance": -1000, + "members": [ + { + "memberId": "3", + "name": "Alice Johnson", + "jobId": "J003", + "jobRole": "Backend Developer", + "hourlyRate": 100 + } + ] + } + ] + }, + { + "id": "e3f9c5f1-b019-445b-b703-b941bbefdab9", + "type": "high", + "name": "High", + "color_code": "#f6bfc0", + "color_code_dark": "#ff4141", + "tasks": [ + { + "id": "5be5ef5c-1234-4247-b159-6d8df2b37d05", + "task": "Database Migration", + "isBillable": true, + "hours": 250, + "cost": 37500, + "fixedCost": 4000, + "totalBudget": 42000, + "totalActual": 41000, + "variance": 1000, + "members": [ + { + "memberId": "7", + "name": "Frank Harris", + "jobId": "J007", + "jobRole": "Database Administrator", + "hourlyRate": 150 + } + ] + }, + { + "id": "3be5ef5c-1234-4247-b159-6d8df2b37d03", + "task": "Performance Optimization", + "isBillable": true, + "hours": 300, + "cost": 45000, + "fixedCost": 5000, + "totalBudget": 50000, + "totalActual": 47000, + "variance": 3000, + "members": [ + { + "memberId": "4", + "name": "Bob Brown", + "jobId": "J004", + "jobRole": "Performance Engineer", + "hourlyRate": 150 + }, + { + "memberId": "5", + "name": "Charlie Davis", + "jobId": "J005", + "jobRole": "Full Stack Developer", + "hourlyRate": 130 + } + ] + } + ] + } +] diff --git a/worklenz-frontend/public/finance-mock-data/finance-task-status.json b/worklenz-frontend/public/finance-mock-data/finance-task-status.json new file mode 100644 index 00000000..888a66c0 --- /dev/null +++ b/worklenz-frontend/public/finance-mock-data/finance-task-status.json @@ -0,0 +1,163 @@ +[ + { + "id": "c2669c5f-a019-445b-b703-b941bbefdab7", + "type": "todo", + "name": "To Do", + "color_code": "#d8d7d8", + "color_code_dark": "#989898", + "tasks": [ + { + "id": "1be5ef5c-1234-4247-b159-6d8df2b37d01", + "task": "UI Design", + "isBillable": true, + "hours": 120, + "cost": 12000, + "fixedCost": 1500, + "totalBudget": 14000, + "totalActual": 13500, + "variance": 500, + "members": [ + { + "memberId": "1", + "name": "John Doe", + "jobId": "J001", + "jobRole": "UI/UX Designer", + "hourlyRate": 100 + }, + { + "memberId": "2", + "name": "Jane Smith", + "jobId": "J002", + "jobRole": "Frontend Developer", + "hourlyRate": 120 + } + ] + }, + { + "id": "2be5ef5c-1234-4247-b159-6d8df2b37d02", + "task": "API Integration", + "isBillable": true, + "hours": 200, + "cost": 20000, + "fixedCost": 3000, + "totalBudget": 25000, + "totalActual": 26000, + "variance": -1000, + "members": [ + { + "memberId": "3", + "name": "Alice Johnson", + "jobId": "J003", + "jobRole": "Backend Developer", + "hourlyRate": 100 + } + ] + } + ] + }, + { + "id": "d3f9c5f1-b019-445b-b703-b941bbefdab8", + "type": "doing", + "name": "In Progress", + "color_code": "#c0d5f6", + "color_code_dark": "#4190ff", + "tasks": [ + { + "id": "3be5ef5c-1234-4247-b159-6d8df2b37d03", + "task": "Performance Optimization", + "isBillable": true, + "hours": 300, + "cost": 45000, + "fixedCost": 5000, + "totalBudget": 50000, + "totalActual": 47000, + "variance": 3000, + "members": [ + { + "memberId": "4", + "name": "Bob Brown", + "jobId": "J004", + "jobRole": "Performance Engineer", + "hourlyRate": 150 + }, + { + "memberId": "5", + "name": "Charlie Davis", + "jobId": "J005", + "jobRole": "Full Stack Developer", + "hourlyRate": 130 + } + ] + }, + { + "id": "4be5ef5c-1234-4247-b159-6d8df2b37d04", + "task": "Testing and QA", + "isBillable": false, + "hours": 180, + "cost": 18000, + "fixedCost": 2500, + "totalBudget": 20000, + "totalActual": 21000, + "variance": -1000, + "members": [ + { + "memberId": "6", + "name": "Eve Adams", + "jobId": "J006", + "jobRole": "QA Engineer", + "hourlyRate": 100 + } + ] + } + ] + }, + { + "id": "e3f9c5f1-b019-445b-b703-b941bbefdab9", + "type": "done", + "name": "Done", + "color_code": "#c2e4d0", + "color_code_dark": "#46d980", + "tasks": [ + { + "id": "5be5ef5c-1234-4247-b159-6d8df2b37d05", + "task": "Database Migration", + "isBillable": true, + "hours": 250, + "cost": 37500, + "fixedCost": 4000, + "totalBudget": 42000, + "totalActual": 41000, + "variance": 1000, + "members": [ + { + "memberId": "7", + "name": "Frank Harris", + "jobId": "J007", + "jobRole": "Database Administrator", + "hourlyRate": 150 + } + ] + }, + { + "id": "6be5ef5c-1234-4247-b159-6d8df2b37d06", + "task": "Project Documentation", + "isBillable": false, + "hours": 100, + "cost": 10000, + "fixedCost": 1000, + "totalBudget": 12000, + "totalActual": 12500, + "variance": -500, + "members": [ + { + "memberId": "8", + "name": "Grace Lee", + "jobId": "J008", + "jobRole": "Technical Writer", + "hourlyRate": 100 + } + ] + } + ] + } +] diff --git a/worklenz-frontend/public/finance-mock-data/ratecards-data.json b/worklenz-frontend/public/finance-mock-data/ratecards-data.json new file mode 100644 index 00000000..bd2c9624 --- /dev/null +++ b/worklenz-frontend/public/finance-mock-data/ratecards-data.json @@ -0,0 +1,51 @@ +[ + { + "ratecardId": "RC001", + "ratecardName": "Rate Card 1", + "jobRolesList": [ + { + "jobId": "J001", + "jobTitle": "Project Manager", + "ratePerHour": 100 + }, + { + "jobId": "J002", + "jobTitle": "Senior Software Engineer", + "ratePerHour": 120 + }, + { + "jobId": "J003", + "jobTitle": "Junior Software Engineer", + "ratePerHour": 80 + }, + { + "jobId": "J004", + "jobTitle": "UI/UX Designer", + "ratePerHour": 50 + } + ], + "createdDate": "2024-12-01T00:00:00.000Z" + }, + { + "ratecardId": "RC002", + "ratecardName": "Rate Card 2", + "jobRolesList": [ + { + "jobId": "J001", + "jobTitle": "Project Manager", + "ratePerHour": 80 + }, + { + "jobId": "J002", + "jobTitle": "Senior Software Engineer", + "ratePerHour": 100 + }, + { + "jobId": "J003", + "jobTitle": "Junior Software Engineer", + "ratePerHour": 60 + } + ], + "createdDate": "2024-12-15T00:00:00.000Z" + } +] diff --git a/worklenz-frontend/public/locales/en/project-view-finance.json b/worklenz-frontend/public/locales/en/project-view-finance.json new file mode 100644 index 00000000..ed43b4bf --- /dev/null +++ b/worklenz-frontend/public/locales/en/project-view-finance.json @@ -0,0 +1,31 @@ +{ + "financeText": "Finance", + "ratecardSingularText": "Rate Card", + "groupByText": "Group by", + "statusText": "Status", + "phaseText": "Phase", + "priorityText": "Priority", + "exportButton": "Export", + "currencyText": "Currency", + "importButton": "Import", + + "taskColumn": "Task", + "membersColumn": "Members", + "hoursColumn": "Hours", + "costColumn": "Cost", + "fixedCostColumn": "Fixed Cost", + "totalBudgetedCostColumn": "Total Budgeted Cost", + "totalActualCostColumn": "Total Actual Cost", + "varianceColumn": "Variance", + "totalText": "Total", + + "addRoleButton": "+ Add Role", + "ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.", + "saveButton": "Save", + + "jobTitleColumn": "Job Title", + "ratePerHourColumn": "Rate per hour", + "ratecardPluralText": "Rate Cards", + "labourHoursColumn": "Labour Hours" + } + \ No newline at end of file diff --git a/worklenz-frontend/public/locales/en/settings/ratecard-settings.json b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json new file mode 100644 index 00000000..8dae2b36 --- /dev/null +++ b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json @@ -0,0 +1,20 @@ +{ + "nameColumn": "Name", + "createdColumn": "Created", + "noProjectsAvailable": "No projects available", + "deleteConfirmationTitle": "Are you sure?", + "deleteConfirmationOk": "Yes", + "deleteConfirmationCancel": "Cancel", + "searchPlaceholder": "Search by name", + "createRatecard": "Create Rate Card", + + "jobTitleColumn": "Job title", + "ratePerHourColumn": "Rate per hour", + "saveButton": "Save", + "addRoleButton": "+ Add Role", + "createRatecardSuccessMessage": "Create Rate Card success!", + "createRatecardErrorMessage": "Create Rate Card failed!", + "updateRatecardSuccessMessage": "Update Rate Card success!", + "updateRatecardErrorMessage": "Update Rate Card failed!", + "currency": "Currency" +} diff --git a/worklenz-frontend/public/locales/es/project-view-finance.json b/worklenz-frontend/public/locales/es/project-view-finance.json new file mode 100644 index 00000000..fdf9849d --- /dev/null +++ b/worklenz-frontend/public/locales/es/project-view-finance.json @@ -0,0 +1,31 @@ +{ + "financeText": "Finanzas", + "ratecardSingularText": "Tarifa", + "groupByText": "Agrupar por", + "statusText": "Estado", + "phaseText": "Fase", + "priorityText": "Prioridad", + "exportButton": "Exportar", + "currencyText": "Moneda", + "importButton": "Importar", + + "taskColumn": "Tarea", + "membersColumn": "Miembros", + "hoursColumn": "Horas", + "costColumn": "Costo", + "fixedCostColumn": "Costo Fijo", + "totalBudgetedCostColumn": "Costo Total Presupuestado", + "totalActualCostColumn": "Costo Total Real", + "varianceColumn": "Diferencia", + "totalText": "Total", + + "addRoleButton": "+ Agregar Rol", + "ratecardImportantNotice": "* Esta tarifa se genera en función de los títulos de trabajo y tarifas estándar de la empresa. Sin embargo, tienes la flexibilidad de modificarla según el proyecto. Estos cambios no afectarán los títulos de trabajo y tarifas estándar de la organización.", + "saveButton": "Guardar", + + "jobTitleColumn": "Título del Trabajo", + "ratePerHourColumn": "Tarifa por hora", + "ratecardPluralText": "Tarifas", + "labourHoursColumn": "Horas de Trabajo" +} + \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/settings/ratecard-settings.json b/worklenz-frontend/public/locales/es/settings/ratecard-settings.json new file mode 100644 index 00000000..825eabd5 --- /dev/null +++ b/worklenz-frontend/public/locales/es/settings/ratecard-settings.json @@ -0,0 +1,20 @@ +{ + "nameColumn": "Nombre", + "createdColumn": "Creado", + "noProjectsAvailable": "No hay proyectos disponibles", + "deleteConfirmationTitle": "¿Estás seguro?", + "deleteConfirmationOk": "Sí", + "deleteConfirmationCancel": "Cancelar", + "searchPlaceholder": "Buscar por nombre", + "createRatecard": "Crear Tarifa", + + "jobTitleColumn": "Puesto de trabajo", + "ratePerHourColumn": "Tarifa por hora", + "saveButton": "Guardar", + "addRoleButton": "+ Agregar Rol", + "createRatecardSuccessMessage": "¡Tarifa creada con éxito!", + "createRatecardErrorMessage": "¡Error al crear la tarifa!", + "updateRatecardSuccessMessage": "¡Tarifa actualizada con éxito!", + "updateRatecardErrorMessage": "¡Error al actualizar la tarifa!", + "currency": "Moneda" +} diff --git a/worklenz-frontend/public/locales/pt/project-view-finance.json b/worklenz-frontend/public/locales/pt/project-view-finance.json new file mode 100644 index 00000000..db5c67c6 --- /dev/null +++ b/worklenz-frontend/public/locales/pt/project-view-finance.json @@ -0,0 +1,31 @@ +{ + "financeText": "Finanças", + "ratecardSingularText": "Tabela de Taxas", + "groupByText": "Agrupar por", + "statusText": "Status", + "phaseText": "Fase", + "priorityText": "Prioridade", + "exportButton": "Exportar", + "currencyText": "Moeda", + "importButton": "Importar", + + "taskColumn": "Tarefa", + "membersColumn": "Membros", + "hoursColumn": "Horas", + "costColumn": "Custo", + "fixedCostColumn": "Custo Fixo", + "totalBudgetedCostColumn": "Custo Total Orçado", + "totalActualCostColumn": "Custo Total Real", + "varianceColumn": "Variação", + "totalText": "Total", + + "addRoleButton": "+ Adicionar Função", + "ratecardImportantNotice": "* Esta tabela de taxas é gerada com base nos cargos e taxas padrão da empresa. No entanto, você tem a flexibilidade de modificá-la de acordo com o projeto. Essas alterações não impactarão os cargos e taxas padrão da organização.", + "saveButton": "Salvar", + + "jobTitleColumn": "Título do Cargo", + "ratePerHourColumn": "Taxa por Hora", + "ratecardPluralText": "Tabelas de Taxas", + "labourHoursColumn": "Horas de Trabalho" +} + \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json b/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json new file mode 100644 index 00000000..c7d1e809 --- /dev/null +++ b/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json @@ -0,0 +1,20 @@ +{ + "nameColumn": "Nome", + "createdColumn": "Criado", + "noProjectsAvailable": "Nenhum projeto disponível", + "deleteConfirmationTitle": "Tem certeza?", + "deleteConfirmationOk": "Sim", + "deleteConfirmationCancel": "Cancelar", + "searchPlaceholder": "Pesquisar por nome", + "createRatecard": "Criar Tabela de Preços", + + "jobTitleColumn": "Cargo", + "ratePerHourColumn": "Taxa por hora", + "saveButton": "Salvar", + "addRoleButton": "+ Adicionar Função", + "createRatecardSuccessMessage": "Tabela de Preços criada com sucesso!", + "createRatecardErrorMessage": "Falha ao criar Tabela de Preços!", + "updateRatecardSuccessMessage": "Tabela de Preços atualizada com sucesso!", + "updateRatecardErrorMessage": "Falha ao atualizar Tabela de Preços!", + "currency": "Moeda" +} 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..6007474f --- /dev/null +++ b/worklenz-frontend/src/api/settings/rate-cards/rate-cards.api.service.ts @@ -0,0 +1,48 @@ +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'; +import { RatecardType, IRatecardViewModel } from '@/types/project/ratecard.types'; + +type IRatecard = { + 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: RatecardType): Promise> { + const response = await apiClient.post>(rootUrl, body); + return response.data; + }, + + async updateRateCard(id: string, body: RatecardType): 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/app/store.ts b/worklenz-frontend/src/app/store.ts index 6bf7adcf..2a34813a 100644 --- a/worklenz-frontend/src/app/store.ts +++ b/worklenz-frontend/src/app/store.ts @@ -69,7 +69,7 @@ import projectReportsTableColumnsReducer from '../features/reporting/projectRepo import projectReportsReducer from '../features/reporting/projectReports/project-reports-slice'; import membersReportsReducer from '../features/reporting/membersReports/membersReportsSlice'; import timeReportsOverviewReducer from '@features/reporting/time-reports/time-reports-overview.slice'; - +import financeReducer from '../features/finance/finance-slice'; import roadmapReducer from '../features/roadmap/roadmap-slice'; import teamMembersReducer from '@features/team-members/team-members.slice'; import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice'; @@ -155,6 +155,7 @@ export const store = configureStore({ roadmapReducer: roadmapReducer, groupByFilterDropdownReducer: groupByFilterDropdownReducer, timeReportsOverviewReducer: timeReportsOverviewReducer, + financeReducer: financeReducer, }, }); diff --git a/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx b/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx new file mode 100644 index 00000000..851f6d76 --- /dev/null +++ b/worklenz-frontend/src/features/finance/finance-drawer/finance-drawer.tsx @@ -0,0 +1,195 @@ +import React, { useEffect, useState } from 'react'; +import { Drawer, Typography } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { useAppSelector } from '../../../hooks/useAppSelector'; +import { useAppDispatch } from '../../../hooks/useAppDispatch'; +import { themeWiseColor } from '../../../utils/themeWiseColor'; +import { toggleFinanceDrawer } from '../finance-slice'; + +const FinanceDrawer = ({ task }: { task: any }) => { + const [selectedTask, setSelectedTask] = useState(task); + + useEffect(() => { + setSelectedTask(task); + }, [task]); + + // localization + const { t } = useTranslation('project-view-finance'); + + // get theme data from theme reducer + const themeMode = useAppSelector((state) => state.themeReducer.mode); + + const isDrawerOpen = useAppSelector( + (state) => state.financeReducer.isFinanceDrawerOpen + ); + const dispatch = useAppDispatch(); + const currency = useAppSelector( + (state) => state.financeReducer.currency + ).toUpperCase(); + + // function handle drawer close + const handleClose = () => { + setSelectedTask(null); + dispatch(toggleFinanceDrawer()); + }; + + // group members by job roles and calculate labor hours and costs + const groupedMembers = + selectedTask?.members?.reduce((acc: any, member: any) => { + const memberHours = selectedTask.hours / selectedTask.members.length; + const memberCost = memberHours * member.hourlyRate; + + if (!acc[member.jobRole]) { + acc[member.jobRole] = { + jobRole: member.jobRole, + laborHours: 0, + cost: 0, + members: [], + }; + } + + acc[member.jobRole].laborHours += memberHours; + acc[member.jobRole].cost += memberCost; + acc[member.jobRole].members.push({ + name: member.name, + laborHours: memberHours, + cost: memberCost, + }); + + return acc; + }, {}) || {}; + + return ( + + {selectedTask?.task || t('noTaskSelected')} + + } + open={isDrawerOpen} + onClose={handleClose} + destroyOnClose={true} + width={480} + > +
+ + + + + + + + + +
+ + + {Object.values(groupedMembers).map((group: any) => ( + + {/* Group Header */} + + + + + + {/* Member Rows */} + {group.members.map((member: any, index: number) => ( + + + + + + ))} + + ))} + +
+ {t('labourHoursColumn')} + + {t('costColumn')} ({currency}) +
{group.jobRole} + {group.laborHours} + + {group.cost} +
+ {member.name} + + {member.laborHours} + + {member.cost} +
+
+
+ ); +}; + +export default FinanceDrawer; diff --git a/worklenz-frontend/src/features/finance/finance-slice.ts b/worklenz-frontend/src/features/finance/finance-slice.ts new file mode 100644 index 00000000..d07c7f58 --- /dev/null +++ b/worklenz-frontend/src/features/finance/finance-slice.ts @@ -0,0 +1,166 @@ +import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service'; +import { RatecardType } from '@/types/project/ratecard.types'; +import logger from '@/utils/errorLogger'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; + +type financeState = { + isRatecardDrawerOpen: boolean; + isFinanceDrawerOpen: boolean; + isImportRatecardsDrawerOpen: boolean; + currency: string; + isFinanceDrawerloading?: boolean; + drawerRatecard?: RatecardType | null; +}; + +const initialState: financeState = { + isRatecardDrawerOpen: false, + isFinanceDrawerOpen: false, + isImportRatecardsDrawerOpen: false, + currency: 'LKR', + isFinanceDrawerloading: false, + drawerRatecard: null, +}; +interface FetchRateCardsParams { + index: number; + size: number; + field: string | null; + order: string | null; + search: string | null; +} +// Async thunks +export const fetchRateCards = createAsyncThunk( + 'ratecards/fetchAll', + async (params: FetchRateCardsParams, { rejectWithValue }) => { + try { + const response = await rateCardApiService.getRateCards( + params.index, + params.size, + params.field, + params.order, + params.search + ); + return response.body; + } catch (error) { + logger.error('Fetch RateCards', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to fetch rate cards'); + } + } +); + +export const fetchRateCardById = createAsyncThunk( + 'ratecard/fetchById', + async (id: string, { rejectWithValue }) => { + try { + const response = await rateCardApiService.getRateCardById(id); + return response.body; + } catch (error) { + logger.error('Fetch RateCardById', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to fetch rate card'); + } + } +); + +export const createRateCard = createAsyncThunk( + 'ratecards/create', + async (body: RatecardType, { rejectWithValue }) => { + try { + const response = await rateCardApiService.createRateCard(body); + return response.body; + } catch (error) { + logger.error('Create RateCard', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to create rate card'); + } + } +); + +export const updateRateCard = createAsyncThunk( + 'ratecards/update', + async ({ id, body }: { id: string; body: RatecardType }, { rejectWithValue }) => { + try { + const response = await rateCardApiService.updateRateCard(id, body); + return response.body; + } catch (error) { + logger.error('Update RateCard', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to update rate card'); + } + } +); + +export const deleteRateCard = createAsyncThunk( + 'ratecards/delete', + async (id: string, { rejectWithValue }) => { + try { + await rateCardApiService.deleteRateCard(id); + return id; + } catch (error) { + logger.error('Delete RateCard', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to delete rate card'); + } + } +); + +const financeSlice = createSlice({ + name: 'financeReducer', + initialState, + reducers: { + toggleRatecardDrawer: (state) => { + state.isRatecardDrawerOpen = !state.isRatecardDrawerOpen; + }, + toggleFinanceDrawer: (state) => { + state.isFinanceDrawerOpen = !state.isFinanceDrawerOpen; + }, + toggleImportRatecardsDrawer: (state) => { + state.isImportRatecardsDrawerOpen = !state.isImportRatecardsDrawerOpen; + }, + changeCurrency: (state, action: PayloadAction) => { + state.currency = action.payload; + }, + ratecardDrawerLoading: (state, action: PayloadAction) => { + state.isFinanceDrawerloading = action.payload; + }, + clearDrawerRatecard: (state) => { + state.drawerRatecard = null; + }, + }, + extraReducers: (builder) => { + builder + // ...other cases... + .addCase(fetchRateCardById.pending, (state) => { + state.isFinanceDrawerloading = true; + state.drawerRatecard = null; + }) + .addCase(fetchRateCardById.fulfilled, (state, action) => { + state.isFinanceDrawerloading = false; + state.drawerRatecard = action.payload; + }) + .addCase(fetchRateCardById.rejected, (state) => { + state.isFinanceDrawerloading = false; + state.drawerRatecard = null; + }); + }, +}); + +export const { + toggleRatecardDrawer, + toggleFinanceDrawer, + toggleImportRatecardsDrawer, + changeCurrency, + ratecardDrawerLoading, + clearDrawerRatecard, +} = financeSlice.actions; +export default financeSlice.reducer; diff --git a/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx b/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx new file mode 100644 index 00000000..c5888fb4 --- /dev/null +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/import-ratecards-drawer.tsx @@ -0,0 +1,114 @@ +import { Drawer, Typography, Button, Table, Menu, Flex } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAppSelector } from '../../../hooks/useAppSelector'; +import { useAppDispatch } from '../../../hooks/useAppDispatch'; +import { fetchData } from '../../../utils/fetchData'; +import { toggleImportRatecardsDrawer } from '../finance-slice'; +import { RatecardType } from '@/types/project/ratecard.types'; +const ImportRatecardsDrawer: React.FC = () => { + const [ratecardsList, setRatecardsList] = useState([]); + const [selectedRatecardId, setSelectedRatecardId] = useState( + null + ); + + // localization + const { t } = useTranslation('project-view-finance'); + + // get drawer state from client reducer + const isDrawerOpen = useAppSelector( + (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( + (state) => state.financeReducer.currency + ).toUpperCase(); + + // find the selected rate card's job roles + const selectedRatecard = + ratecardsList.find( + (ratecard) => ratecard.ratecardId === selectedRatecardId + ) || null; + + // table columns + const columns = [ + { + title: t('jobTitleColumn'), + dataIndex: 'jobTitle', + render: (text: string) => ( + + {text} + + ), + }, + { + title: `${t('ratePerHourColumn')} (${currency})`, + dataIndex: 'ratePerHour', + render: (text: number) => {text}, + }, + ]; + + return ( + + {t('ratecardsPluralText')} + + } + footer={ +
+ +
+ } + open={isDrawerOpen} + onClose={() => dispatch(toggleImportRatecardsDrawer())} + width={1000} + > + + {/* sidebar menu */} + setSelectedRatecardId(key)} + > + {ratecardsList.map((ratecard) => ( + + {ratecard.ratecardName} + + ))} + + + {/* table for job roles */} + record.jobId} + onRow={() => { + return { + className: 'group', + style: { + cursor: 'pointer', + }, + }; + }} + pagination={false} + /> + + + ); +}; + +export default ImportRatecardsDrawer; diff --git a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx new file mode 100644 index 00000000..9f3cc432 --- /dev/null +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx @@ -0,0 +1,325 @@ +import { Drawer, Select, Typography, Flex, Button, Input, Table } from 'antd'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAppSelector } from '../../../hooks/useAppSelector'; +import { useAppDispatch } from '../../../hooks/useAppDispatch'; +import { clearDrawerRatecard, fetchRateCardById, fetchRateCards, toggleRatecardDrawer, updateRateCard } from '../finance-slice'; +import { RatecardType, IJobType } from '@/types/project/ratecard.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 { DeleteOutlined } from '@ant-design/icons'; + +interface PaginationType { + current: number; + pageSize: number; + field: string; + order: string; + total: number; + pageSizeOptions: string[]; + size: 'small' | 'default'; +} +const RatecardDrawer = ({ + type, + ratecardId, + onSaved, +}: { + type: 'create' | 'update'; + ratecardId: string; + onSaved?: () => void; +}) => { + const [ratecardsList, setRatecardsList] = useState([]); + // initial Job Roles List (dummy data) + const [roles, setRoles] = useState([]); + + const { t } = useTranslation('settings/ratecard-settings'); + // get drawer state from client reducer + const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading); + const drawerRatecard = useAppSelector(state => state.financeReducer.drawerRatecard); + const isDrawerOpen = useAppSelector( + (state) => state.financeReducer.isRatecardDrawerOpen + ); + const dispatch = useAppDispatch(); + + const [isAddingRole, setIsAddingRole] = useState(false); + const [selectedJobTitleId, setSelectedJobTitleId] = useState(undefined); + const [searchQuery, setSearchQuery] = useState(''); + const [currency, setCurrency] = useState('LKR'); + const [name, setName] = useState('Untitled Rate Card'); + const [jobTitles, setJobTitles] = useState({}); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: DEFAULT_PAGE_SIZE, + field: 'name', + order: 'desc', + total: 0, + pageSizeOptions: ['5', '10', '15', '20', '50', '100'], + size: 'small', + }); + + const getJobTitles = useMemo(() => { + return async () => { + const response = await jobTitlesApiService.getJobTitles( + pagination.current, + pagination.pageSize, + pagination.field, + pagination.order, + searchQuery + ); + if (response.done) { + setJobTitles(response.body); + setPagination(prev => ({ ...prev, total: response.body.total || 0 })); + } + }; + }, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery]); + + // fetch rate cards data + useEffect(() => { + getJobTitles(); + }, []); + + // get currently selected ratecard + const selectedRatecard = ratecardsList.find( + (ratecard) => ratecard.id === ratecardId + ); + + useEffect(() => { + if (type === 'update' && ratecardId) { + dispatch(fetchRateCardById(ratecardId)); + } + // ...reset logic for create... + }, [type, ratecardId, dispatch]); + + useEffect(() => { + + if (type === 'update' && drawerRatecard) { + setRoles(drawerRatecard.jobRolesList || []); + setName(drawerRatecard.name || ''); + setCurrency(drawerRatecard.currency || 'LKR'); + } + }, [drawerRatecard, type]); + + // Add All handler + const handleAddAllRoles = () => { + if (!jobTitles.data) return; + // Filter out job titles already in roles + const existingIds = new Set(roles.map(r => r.job_title_id)); + const newRoles = jobTitles.data + .filter(jt => !existingIds.has(jt.id!)) + .map(jt => ({ + jobtitle: jt.name, + rate_card_id: ratecardId, + job_title_id: jt.id!, + rate: 0, + })); + setRoles([...roles, ...newRoles]); + }; + + // add new job role handler + const handleAddRole = () => { + setIsAddingRole(true); + setSelectedJobTitleId(undefined); + }; + const handleDeleteRole = (index: number) => { + const updatedRoles = [...roles]; + updatedRoles.splice(index, 1); + setRoles(updatedRoles); + }; + const handleSelectJobTitle = (jobTitleId: string) => { + const jobTitle = jobTitles.data?.find(jt => jt.id === jobTitleId); + if (jobTitle) { + const newRole = { + jobtitle: jobTitle.name, + rate_card_id: ratecardId, + job_title_id: jobTitleId, + rate: 0, + }; + setRoles([...roles, newRole]); + } + setIsAddingRole(false); + setSelectedJobTitleId(undefined); + }; + + const handleSave = async () => { + if (type === 'update' && ratecardId) { + try { + await dispatch(updateRateCard({ + id: ratecardId, + body: { + name, + currency, + jobRolesList: roles, + }, + }) as any); + // Refresh the rate cards list in Redux + await dispatch(fetchRateCards({ + index: 1, + size: 10, + field: 'name', + order: 'desc', + search: '', + }) as any); + if (onSaved) onSaved(); + dispatch(toggleRatecardDrawer()); + + } catch (error) { + console.error('Failed to update rate card', error); + } finally { + setRoles([]); + setName('Untitled Rate Card'); + setCurrency('LKR'); + } + } + }; + + // table columns + const columns = [ + { + title: t('jobTitleColumn'), + dataIndex: 'jobtitle', + render: (text: string, record: any, index: number) => ( + { + const updatedRoles = [...roles]; + updatedRoles[index].jobtitle = e.target.value; + setRoles(updatedRoles); + }} + /> + ), + }, + { + title: `${t('ratePerHourColumn')} (${currency})`, + dataIndex: 'rate', + render: (text: number, record: any, index: number) => ( + { + const updatedRoles = roles.map((role, idx) => + idx === index ? { ...role, rate: parseInt(e.target.value, 10) || 0 } : role + ); + setRoles(updatedRoles); + }} + /> + ), + }, + { + title: t('actionsColumn') || 'Actions', + dataIndex: 'actions', + render: (_: any, __: any, index: number) => ( +
record.job_title_id} + pagination={false} + footer={() => ( + isAddingRole ? ( + + ) : ( + + ) + )} + /> + + ); +}; + +export default RatecardDrawer; 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; }; diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index fc4b8e87..43571cc5 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -5,6 +5,7 @@ import ProjectViewMembers from '@/pages/projects/projectView/members/project-vie import ProjectViewUpdates from '@/pages/projects/project-view-1/updates/project-view-updates'; import ProjectViewTaskList from '@/pages/projects/projectView/taskList/project-view-task-list'; import ProjectViewBoard from '@/pages/projects/projectView/board/project-view-board'; +import ProjectViewFinance from '@/pages/projects/projectView/finance/project-view-finance'; // type of a tab items type TabItems = { @@ -67,4 +68,10 @@ export const tabItems: TabItems[] = [ label: 'Updates', element: React.createElement(ProjectViewUpdates), }, + { + index: 8, + key: 'finance', + label: 'Finance', + element: React.createElement(ProjectViewFinance), + }, ]; diff --git a/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts b/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts new file mode 100644 index 00000000..e08bd430 --- /dev/null +++ b/worklenz-frontend/src/lib/project/project-view-finance-table-columns.ts @@ -0,0 +1,59 @@ +type FinanceTableColumnsType = { + key: string; + name: string; + width: number; + type: 'string' | 'hours' | 'currency'; + }; + + // finance table columns + export const financeTableColumns: FinanceTableColumnsType[] = [ + { + key: 'task', + name: 'task', + width: 240, + type: 'string', + }, + { + key: 'members', + name: 'members', + width: 160, + type: 'string', + }, + { + key: 'hours', + name: 'hours', + width: 80, + type: 'hours', + }, + { + key: 'cost', + name: 'cost', + width: 120, + type: 'currency', + }, + { + key: 'fixedCost', + name: 'fixedCost', + width: 120, + type: 'currency', + }, + { + key: 'totalBudget', + name: 'totalBudgetedCost', + width: 120, + type: 'currency', + }, + { + key: 'totalActual', + name: 'totalActualCost', + width: 120, + type: 'currency', + }, + { + key: 'variance', + name: 'variance', + width: 120, + type: 'currency', + }, + ]; + \ No newline at end of file diff --git a/worklenz-frontend/src/lib/settings/settings-constants.ts b/worklenz-frontend/src/lib/settings/settings-constants.ts index 9855b008..7463e695 100644 --- a/worklenz-frontend/src/lib/settings/settings-constants.ts +++ b/worklenz-frontend/src/lib/settings/settings-constants.ts @@ -1,5 +1,6 @@ import { BankOutlined, + DollarCircleOutlined, FileZipOutlined, GlobalOutlined, GroupOutlined, @@ -26,6 +27,7 @@ import TeamMembersSettings from '@/pages/settings/team-members/team-members-sett import TeamsSettings from '../../pages/settings/teams/teams-settings'; import ChangePassword from '@/pages/settings/change-password/change-password'; import LanguageAndRegionSettings from '@/pages/settings/language-and-region/language-and-region-settings'; +import RatecardSettings from '@/pages/settings/ratecard/ratecard-settings'; import AppearanceSettings from '@/pages/settings/appearance/appearance-settings'; // type of menu item in settings sidebar @@ -132,6 +134,13 @@ export const settingsItems: SettingMenuItems[] = [ element: React.createElement(TeamMembersSettings), adminOnly: true, }, + { + key: 'ratecard', + name: 'Rate Card', + endpoint: 'ratecard', + icon: React.createElement(DollarCircleOutlined), + element: React.createElement(RatecardSettings), + }, { key: 'teams', name: 'teams', diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx new file mode 100644 index 00000000..b421d9de --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-tab.tsx @@ -0,0 +1,44 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import FinanceTableWrapper from './finance-table/finance-table-wrapper'; +import { fetchData } from '../../../../../utils/fetchData'; + +const FinanceTab = ({ + groupType, +}: { + groupType: 'status' | 'priority' | 'phases'; +}) => { + // Save each table's list according to the groups + const [statusTables, setStatusTables] = useState([]); + const [priorityTables, setPriorityTables] = useState([]); + const [activeTablesList, setActiveTablesList] = useState([]); + + // Fetch data for status tables + useMemo(() => { + fetchData('/finance-mock-data/finance-task-status.json', setStatusTables); + }, []); + + // Fetch data for priority tables + useMemo(() => { + fetchData( + '/finance-mock-data/finance-task-priority.json', + setPriorityTables + ); + }, []); + + // Update activeTablesList based on groupType and fetched data + useEffect(() => { + if (groupType === 'status') { + setActiveTablesList(statusTables); + } else if (groupType === 'priority') { + setActiveTablesList(priorityTables); + } + }, [groupType, priorityTables, statusTables]); + + return ( +
+ +
+ ); +}; + +export default FinanceTab; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx new file mode 100644 index 00000000..60054604 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx @@ -0,0 +1,253 @@ +import React, { useEffect, useState } from 'react'; +import { Checkbox, Flex, Typography } from 'antd'; +import { themeWiseColor } from '../../../../../../utils/themeWiseColor'; +import { useAppSelector } from '../../../../../../hooks/useAppSelector'; +import { useTranslation } from 'react-i18next'; +import { useAppDispatch } from '../../../../../../hooks/useAppDispatch'; +import { toggleFinanceDrawer } from '@/features/finance/finance-slice'; +import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns'; +import FinanceTable from './finance-table'; +import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer'; + +const FinanceTableWrapper = ({ + activeTablesList, +}: { + activeTablesList: any; +}) => { + const [isScrolling, setIsScrolling] = useState(false); + + //? this state for inside this state individualy in finance table only display the data of the last table's task when a task is clicked The selectedTask state does not synchronize across tables so thats why move the selectedTask state to a parent component + const [selectedTask, setSelectedTask] = useState(null); + + // localization + const { t } = useTranslation('project-view-finance'); + + const dispatch = useAppDispatch(); + + // function on task click + const onTaskClick = (task: any) => { + setSelectedTask(task); + dispatch(toggleFinanceDrawer()); + }; + + // trigger the table scrolling + useEffect(() => { + const tableContainer = document.querySelector('.tasklist-container'); + const handleScroll = () => { + if (tableContainer) { + setIsScrolling(tableContainer.scrollLeft > 0); + } + }; + + // add the scroll event listener + tableContainer?.addEventListener('scroll', handleScroll); + + // cleanup on unmount + return () => { + tableContainer?.removeEventListener('scroll', handleScroll); + }; + }, []); + + // get theme data from theme reducer + const themeMode = useAppSelector((state) => state.themeReducer.mode); + + // get tasklist and currently using currency from finance reducer + const { currency } = useAppSelector((state) => state.financeReducer); + + // totals of all the tasks + const totals = activeTablesList.reduce( + ( + acc: { + hours: number; + cost: number; + fixedCost: number; + totalBudget: number; + totalActual: number; + variance: number; + }, + table: { tasks: any[] } + ) => { + table.tasks.forEach((task: any) => { + acc.hours += task.hours || 0; + acc.cost += task.cost || 0; + acc.fixedCost += task.fixedCost || 0; + acc.totalBudget += task.totalBudget || 0; + acc.totalActual += task.totalActual || 0; + acc.variance += task.variance || 0; + }); + return acc; + }, + { + hours: 0, + cost: 0, + fixedCost: 0, + totalBudget: 0, + totalActual: 0, + variance: 0, + } + ); + + const renderFinancialTableHeaderContent = (columnKey: any) => { + switch (columnKey) { + case 'hours': + return ( + + {totals.hours} + + ); + case 'cost': + return ( + + {totals.cost} + + ); + case 'fixedCost': + return ( + + {totals.fixedCost} + + ); + case 'totalBudget': + return ( + + {totals.totalBudget} + + ); + case 'totalActual': + return ( + + {totals.totalActual} + + ); + case 'variance': + return ( + + {totals.variance} + + ); + default: + return null; + } + }; + + // layout styles for table and the columns + const customColumnHeaderStyles = (key: string) => + `px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`; + + const customColumnStyles = (key: string) => + `px-2 text-left ${key === 'totalRow' && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`}`; + + return ( + <> + +
+ + + + {financeTableColumns.map((col) => ( + + ))} + + + + + {financeTableColumns.map( + (col) => + (col.type === 'hours' || col.type === 'currency') && ( + + ) + )} + + + {activeTablesList.map((table: any, index: number) => ( + + ))} + +
+ + + + {t(`${col.name}Column`)}{' '} + {col.type === 'currency' && `(${currency.toUpperCase()})`} + +
+ + {t('totalText')} + + + {renderFinancialTableHeaderContent(col.key)} +
+
+ + {selectedTask && } + + ); +}; + +export default FinanceTableWrapper; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx new file mode 100644 index 00000000..b6ea67ad --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx @@ -0,0 +1,287 @@ +import { Avatar, Checkbox, Flex, Input, Tooltip, Typography } from 'antd'; +import React, { useEffect, useMemo, useState } from 'react'; +import CustomAvatar from '../../../../../../components/CustomAvatar'; +import { useAppSelector } from '../../../../../../hooks/useAppSelector'; +import { + DollarCircleOutlined, + DownOutlined, + RightOutlined, +} from '@ant-design/icons'; +import { themeWiseColor } from '../../../../../../utils/themeWiseColor'; +import { colors } from '../../../../../../styles/colors'; +import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns'; + +type FinanceTableProps = { + table: any; + isScrolling: boolean; + onTaskClick: (task: any) => void; +}; + +const FinanceTable = ({ + table, + isScrolling, + onTaskClick, +}: FinanceTableProps) => { + const [isCollapse, setIsCollapse] = useState(false); + const [selectedTask, setSelectedTask] = useState(null); + + // get theme data from theme reducer + const themeMode = useAppSelector((state) => state.themeReducer.mode); + + // totals of the current table + const totals = useMemo( + () => ({ + hours: (table?.tasks || []).reduce( + (sum: any, task: { hours: any }) => sum + task.hours, + 0 + ), + cost: (table?.tasks || []).reduce( + (sum: any, task: { cost: any }) => sum + task.cost, + 0 + ), + fixedCost: (table?.tasks || []).reduce( + (sum: any, task: { fixedCost: any }) => sum + task.fixedCost, + 0 + ), + totalBudget: (table?.tasks || []).reduce( + (sum: any, task: { totalBudget: any }) => sum + task.totalBudget, + 0 + ), + totalActual: (table?.tasks || []).reduce( + (sum: any, task: { totalActual: any }) => sum + task.totalActual, + 0 + ), + variance: (table?.tasks || []).reduce( + (sum: any, task: { variance: any }) => sum + task.variance, + 0 + ), + }), + [table] + ); + + useEffect(() => { + console.log('Selected Task:', selectedTask); + }, [selectedTask]); + + const renderFinancialTableHeaderContent = (columnKey: any) => { + switch (columnKey) { + case 'hours': + return ( + + {totals.hours} + + ); + case 'cost': + return ( + + {totals.cost} + + ); + case 'fixedCost': + return ( + + {totals.fixedCost} + + ); + case 'totalBudget': + return ( + + {totals.totalBudget} + + ); + case 'totalActual': + return ( + + {totals.totalActual} + + ); + case 'variance': + return ( + + {totals.variance} + + ); + default: + return null; + } + }; + + const renderFinancialTableColumnContent = (columnKey: any, task: any) => { + switch (columnKey) { + case 'task': + return ( + + + + {task.task} + + + {task.isbBillable && } + + + ); + case 'members': + return ( + + {task.members.map((member: any) => ( + + ))} + + ); + case 'hours': + return {task.hours}; + case 'cost': + return {task.cost}; + case 'fixedCost': + return ( + + ); + case 'totalBudget': + return ( + + ); + case 'totalActual': + return {task.totalActual}; + case 'variance': + return ( + + {task.variance} + + ); + default: + return null; + } + }; + + // layout styles for table and the columns + const customColumnHeaderStyles = (key: string) => + `px-2 text-left ${key === 'tableTitle' && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`}`; + + const customColumnStyles = (key: string) => + `px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[52px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`; + + return ( + <> + {/* header row */} + + setIsCollapse((prev) => !prev)} + > + + {isCollapse ? : } + {table.name} ({table.tasks.length}) + + + + {financeTableColumns.map( + (col) => + col.key !== 'task' && + col.key !== 'members' && ( + + {renderFinancialTableHeaderContent(col.key)} + + ) + )} + + + {/* task rows */} + {table.tasks.map((task: any) => ( + onTaskClick(task)} + > + + + + {financeTableColumns.map((col) => ( + + {renderFinancialTableColumnContent(col.key, task)} + + ))} + + ))} + + ); +}; + +export default FinanceTable; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/group-by-filter-dropdown.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/group-by-filter-dropdown.tsx new file mode 100644 index 00000000..fad9365d --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance-header/group-by-filter-dropdown.tsx @@ -0,0 +1,56 @@ +import { CaretDownFilled } from '@ant-design/icons'; +import { Flex, Select } from 'antd'; +import React from 'react'; +import { useSelectedProject } from '../../../../../hooks/useSelectedProject'; +import { useAppSelector } from '../../../../../hooks/useAppSelector'; +import { useTranslation } from 'react-i18next'; + +type GroupByFilterDropdownProps = { + activeGroup: 'status' | 'priority' | 'phases'; + setActiveGroup: (group: 'status' | 'priority' | 'phases') => void; +}; + +const GroupByFilterDropdown = ({ + activeGroup, + setActiveGroup, +}: GroupByFilterDropdownProps) => { + // localization + const { t } = useTranslation('project-view-finance'); + + const handleChange = (value: string) => { + setActiveGroup(value as 'status' | 'priority' | 'phases'); + }; + + // get selected project from useSelectedPro + const selectedProject = useSelectedProject(); + + //get phases details from phases slice + const phase = + useAppSelector((state) => state.phaseReducer.phaseList).find( + (phase) => phase?.projectId === selectedProject?.projectId + ) || null; + + const groupDropdownMenuItems = [ + { key: 'status', value: 'status', label: t('statusText') }, + { key: 'priority', value: 'priority', label: t('priorityText') }, + { + key: 'phase', + value: 'phase', + label: phase ? phase?.phase : t('phaseText'), + }, + ]; + + return ( + + {t('groupByText')}: + dispatch(changeCurrency(value))} + /> + + + + )} + + + ); +}; + +export default ProjectViewFinanceHeader; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx new file mode 100644 index 00000000..d2c685f7 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx @@ -0,0 +1,32 @@ +import { Flex } from 'antd'; +import React, { useState } from 'react'; +import ProjectViewFinanceHeader from './project-view-finance-header/project-view-finance-header'; +import FinanceTab from './finance-tab/finance-tab'; +import RatecardTab from './ratecard-tab/ratecard-tab'; + +type FinanceTabType = 'finance' | 'ratecard'; +type GroupTypes = 'status' | 'priority' | 'phases'; + +const ProjectViewFinance = () => { + const [activeTab, setActiveTab] = useState('finance'); + const [activeGroup, setActiveGroup] = useState('status'); + + return ( + + + + {activeTab === 'finance' ? ( + + ) : ( + + )} + + ); +}; + +export default ProjectViewFinance; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx new file mode 100644 index 00000000..6119c8b9 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/ratecard-tab.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import RatecardTable from './reatecard-table/ratecard-table'; +import { Button, Flex, Typography } from 'antd'; +import { useTranslation } from 'react-i18next'; +import ImportRatecardsDrawer from '@/features/finance/ratecard-drawer/import-ratecards-drawer'; + +const RatecardTab = () => { + // localization + const { t } = useTranslation('project-view-finance'); + + return ( + + + + + {t('ratecardImportantNotice')} + + + + {/* import ratecards drawer */} + + + ); +}; + +export default RatecardTab; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx new file mode 100644 index 00000000..85d73b25 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx @@ -0,0 +1,150 @@ +import { Avatar, Button, Input, Table, TableProps } from 'antd'; +import React, { useState } from 'react'; +import CustomAvatar from '../../../../../../components/CustomAvatar'; +import { PlusOutlined } from '@ant-design/icons'; +import { useAppSelector } from '../../../../../../hooks/useAppSelector'; +import { useTranslation } from 'react-i18next'; +import { JobRoleType } from '@/types/project/ratecard.types'; + +const initialJobRolesList: JobRoleType[] = [ + { + jobId: 'J001', + jobTitle: 'Project Manager', + ratePerHour: 50, + members: ['Alice Johnson', 'Bob Smith'], + }, + { + 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 [roles, setRoles] = useState(initialJobRolesList); + + // localization + const { t } = useTranslation('project-view-finance'); + + // get currently using currency from finance reducer + const currency = useAppSelector( + (state) => state.financeReducer.currency + ).toUpperCase(); + + const handleAddRole = () => { + const newRole: JobRoleType = { + jobId: `J00${roles.length + 1}`, + jobTitle: 'New Role', + ratePerHour: 0, + members: [], + }; + setRoles([...roles, newRole]); + }; + + const columns: TableProps['columns'] = [ + { + title: t('jobTitleColumn'), + dataIndex: 'jobTitle', + render: (text: string, record: JobRoleType, index: number) => ( + { + const updatedRoles = [...roles]; + updatedRoles[index].jobTitle = e.target.value; + setRoles(updatedRoles); + }} + /> + ), + }, + { + title: `${t('ratePerHourColumn')} (${currency})`, + dataIndex: 'ratePerHour', + render: (text: number, record: JobRoleType, index: number) => ( + { + const updatedRoles = [...roles]; + updatedRoles[index].ratePerHour = parseInt(e.target.value, 10) || 0; + setRoles(updatedRoles); + }} + /> + ), + }, + { + title: t('membersColumn'), + dataIndex: 'members', + render: (members: string[]) => + members?.length > 0 ? ( + + {members.map((member, i) => ( + + ))} + + ) : ( +