Merge pull request #135 from shancds/finance-module

Finance module
This commit is contained in:
Chamika J
2025-05-21 10:05:47 +05:30
committed by GitHub
37 changed files with 3060 additions and 128 deletions

View File

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

View File

@@ -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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
// 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<IWorkLenzResponse> {
// 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<IWorkLenzResponse> {
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));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<IServerResponse<IRatecardViewModel>> {
const s = encodeURIComponent(search || '');
const queryString = toQueryString({ index, size, field, order, search: s });
const response = await apiClient.get<IServerResponse<IRatecardViewModel>>(
`${rootUrl}${queryString}`
);
return response.data;
},
async getRateCardById(id: string): Promise<IServerResponse<RatecardType>> {
const response = await apiClient.get<IServerResponse<RatecardType>>(`${rootUrl}/${id}`);
return response.data;
},
async createRateCard(body: RatecardType): Promise<IServerResponse<RatecardType>> {
const response = await apiClient.post<IServerResponse<RatecardType>>(rootUrl, body);
return response.data;
},
async updateRateCard(id: string, body: RatecardType): Promise<IServerResponse<RatecardType>> {
const response = await apiClient.put<IServerResponse<RatecardType>>(`${rootUrl}/${id}`, body);
return response.data;
},
async deleteRateCard(id: string): Promise<IServerResponse<void>> {
const response = await apiClient.delete<IServerResponse<void>>(`${rootUrl}/${id}`);
return response.data;
},
};

View File

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

View File

@@ -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 (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{selectedTask?.task || t('noTaskSelected')}
</Typography.Text>
}
open={isDrawerOpen}
onClose={handleClose}
destroyOnClose={true}
width={480}
>
<div>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
marginBottom: '16px',
}}
>
<thead>
<tr
style={{
height: 48,
backgroundColor: themeWiseColor(
'#F5F5F5',
'#1d1d1d',
themeMode
),
}}
>
<th
style={{
textAlign: 'left',
padding: 8,
}}
></th>
<th
style={{
textAlign: 'right',
padding: 8,
}}
>
{t('labourHoursColumn')}
</th>
<th
style={{
textAlign: 'right',
padding: 8,
}}
>
{t('costColumn')} ({currency})
</th>
</tr>
</thead>
<div className="mb-4"></div>
<tbody>
{Object.values(groupedMembers).map((group: any) => (
<React.Fragment key={group.jobRole}>
{/* Group Header */}
<tr
style={{
backgroundColor: themeWiseColor(
'#D9D9D9',
'#000',
themeMode
),
height: 56,
}}
className="border-b-[1px] font-semibold"
>
<td style={{ padding: 8 }}>{group.jobRole}</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{group.laborHours}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{group.cost}
</td>
</tr>
{/* Member Rows */}
{group.members.map((member: any, index: number) => (
<tr
key={`${group.jobRole}-${index}`}
className="border-b-[1px]"
style={{ height: 56 }}
>
<td
style={{
padding: 8,
paddingLeft: 32,
}}
>
{member.name}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.laborHours}
</td>
<td
style={{
textAlign: 'right',
padding: 8,
}}
>
{member.cost}
</td>
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
</Drawer>
);
};
export default FinanceDrawer;

View File

@@ -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<string>) => {
state.currency = action.payload;
},
ratecardDrawerLoading: (state, action: PayloadAction<boolean>) => {
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;

View File

@@ -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<RatecardType[]>([]);
const [selectedRatecardId, setSelectedRatecardId] = useState<string | null>(
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) => (
<Typography.Text className="group-hover:text-[#1890ff]">
{text}
</Typography.Text>
),
},
{
title: `${t('ratePerHourColumn')} (${currency})`,
dataIndex: 'ratePerHour',
render: (text: number) => <Typography.Text>{text}</Typography.Text>,
},
];
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{t('ratecardsPluralText')}
</Typography.Text>
}
footer={
<div style={{ textAlign: 'right' }}>
<Button type="primary">Import</Button>
</div>
}
open={isDrawerOpen}
onClose={() => dispatch(toggleImportRatecardsDrawer())}
width={1000}
>
<Flex gap={12}>
{/* sidebar menu */}
<Menu
mode="vertical"
style={{ width: '20%' }}
selectedKeys={
selectedRatecardId
? [selectedRatecardId]
: [ratecardsList[0]?.ratecardId]
}
onClick={({ key }) => setSelectedRatecardId(key)}
>
{ratecardsList.map((ratecard) => (
<Menu.Item key={ratecard.ratecardId}>
{ratecard.ratecardName}
</Menu.Item>
))}
</Menu>
{/* table for job roles */}
<Table
style={{ flex: 1 }}
dataSource={selectedRatecard?.jobRolesList || []}
columns={columns}
rowKey={(record) => record.jobId}
onRow={() => {
return {
className: 'group',
style: {
cursor: 'pointer',
},
};
}}
pagination={false}
/>
</Flex>
</Drawer>
);
};
export default ImportRatecardsDrawer;

View File

@@ -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<RatecardType[]>([]);
// initial Job Roles List (dummy data)
const [roles, setRoles] = useState<IJobType[]>([]);
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<string | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState('');
const [currency, setCurrency] = useState('LKR');
const [name, setName] = useState<string>('Untitled Rate Card');
const [jobTitles, setJobTitles] = useState<IJobTitlesViewModel>({});
const [pagination, setPagination] = useState<PaginationType>({
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) => (
<Input
value={text}
placeholder="Enter job title"
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
color: '#1890ff',
}}
onChange={(e) => {
const updatedRoles = [...roles];
updatedRoles[index].jobtitle = e.target.value;
setRoles(updatedRoles);
}}
/>
),
},
{
title: `${t('ratePerHourColumn')} (${currency})`,
dataIndex: 'rate',
render: (text: number, record: any, index: number) => (
<Input
type="number"
value={roles[index]?.rate ?? 0}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
}}
onChange={(e) => {
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) => (
<Button
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteRole(index)}
/>
),
},
];
return (
<Drawer
loading={drawerLoading}
title={
<Flex align="center" justify="space-between">
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
<Input
value={name}
placeholder="Enter rate card name"
style={{
fontWeight: 500,
fontSize: 16,
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
}}
onChange={e => {
setName(e.target.value);
}}
/>
</Typography.Text>
<Flex gap={8} align="center">
<Typography.Text>{t('currency')}</Typography.Text>
<Select
value={currency}
options={[
{ value: 'LKR', label: 'LKR' },
{ value: 'USD', label: 'USD' },
{ value: 'INR', label: 'INR' },
]}
onChange={(value) => setCurrency(value)}
/>
{/* Add All Button */}
<Button onClick={handleAddAllRoles} type="default">
{t('addAllButton') || 'Add All'}
</Button>
</Flex>
</Flex>
}
open={isDrawerOpen}
onClose={() => dispatch(toggleRatecardDrawer())}
width={700}
footer={
<Flex justify="end" gap={16} style={{ marginTop: 16 }}>
<Button style={{ marginBottom: 24 }} onClick={handleSave} type="primary">{t('saveButton')}</Button>
</Flex>
}
>
{/* ratecard Table directly inside the Drawer */}
<Table
dataSource={roles}
columns={columns}
rowKey={(record) => record.job_title_id}
pagination={false}
footer={() => (
isAddingRole ? (
<Select
showSearch
style={{ minWidth: 200 }}
placeholder={t('selectJobTitle')}
optionFilterProp="children"
value={selectedJobTitleId}
onChange={handleSelectJobTitle}
onBlur={() => setIsAddingRole(false)}
filterOption={(input, option) =>
(option?.children as string).toLowerCase().includes(input.toLowerCase())
}
>
{jobTitles.data?.map((jt) => (
<Select.Option key={jt.id} value={jt.id}>
{jt.name}
</Select.Option>
))}
</Select>
) : (
<Button
type="dashed"
onClick={handleAddRole}
block
style={{ margin: 0, padding: 0 }}
>
{t('addRoleButton')}
</Button>
)
)}
/>
</Drawer>
);
};
export default RatecardDrawer;

View File

@@ -1,7 +1,7 @@
export type NavRoutesType = {
name: string;
path: string;
adminOnly: boolean;
adminOnly?: boolean;
freePlanFeature?: boolean;
};

View File

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

View File

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

View File

@@ -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',

View File

@@ -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<any[]>([]);
const [priorityTables, setPriorityTables] = useState<any[]>([]);
const [activeTablesList, setActiveTablesList] = useState<any[]>([]);
// 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 (
<div>
<FinanceTableWrapper activeTablesList={activeTablesList} />
</div>
);
};
export default FinanceTab;

View File

@@ -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 (
<Typography.Text style={{ fontSize: 18 }}>
{totals.hours}
</Typography.Text>
);
case 'cost':
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.cost}
</Typography.Text>
);
case 'fixedCost':
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.fixedCost}
</Typography.Text>
);
case 'totalBudget':
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.totalBudget}
</Typography.Text>
);
case 'totalActual':
return (
<Typography.Text style={{ fontSize: 18 }}>
{totals.totalActual}
</Typography.Text>
);
case 'variance':
return (
<Typography.Text
style={{
color: totals.variance < 0 ? '#FF0000' : '#6DC376',
fontSize: 18,
}}
>
{totals.variance}
</Typography.Text>
);
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 (
<>
<Flex
vertical
className="tasklist-container min-h-0 max-w-full overflow-x-auto"
>
<table>
<tbody>
<tr
style={{
height: 56,
fontWeight: 600,
backgroundColor: themeWiseColor(
'#fafafa',
'#1d1d1d',
themeMode
),
borderBlockEnd: `2px solid rgb(0 0 0 / 0.05)`,
}}
>
<td
style={{ width: 32, paddingInline: 16 }}
className={customColumnHeaderStyles('selector')}
>
<Checkbox />
</td>
{financeTableColumns.map((col) => (
<td
key={col.key}
style={{
minWidth: col.width,
paddingInline: 16,
textAlign:
col.type === 'hours' || col.type === 'currency'
? 'center'
: 'start',
}}
className={`${customColumnHeaderStyles(col.key)} before:constent relative before:absolute before:left-0 before:top-1/2 before:h-[36px] before:w-0.5 before:-translate-y-1/2 ${themeMode === 'dark' ? 'before:bg-white/10' : 'before:bg-black/5'}`}
>
<Typography.Text>
{t(`${col.name}Column`)}{' '}
{col.type === 'currency' && `(${currency.toUpperCase()})`}
</Typography.Text>
</td>
))}
</tr>
<tr
style={{
height: 56,
fontWeight: 500,
backgroundColor: themeWiseColor(
'#fbfbfb',
'#141414',
themeMode
),
}}
>
<td
colSpan={3}
style={{
paddingInline: 16,
backgroundColor: themeWiseColor(
'#fbfbfb',
'#141414',
themeMode
),
}}
className={customColumnStyles('totalRow')}
>
<Typography.Text style={{ fontSize: 18 }}>
{t('totalText')}
</Typography.Text>
</td>
{financeTableColumns.map(
(col) =>
(col.type === 'hours' || col.type === 'currency') && (
<td
key={col.key}
style={{
minWidth: col.width,
paddingInline: 16,
textAlign: 'end',
}}
>
{renderFinancialTableHeaderContent(col.key)}
</td>
)
)}
</tr>
{activeTablesList.map((table: any, index: number) => (
<FinanceTable
key={index}
table={table}
isScrolling={isScrolling}
onTaskClick={onTaskClick}
/>
))}
</tbody>
</table>
</Flex>
{selectedTask && <FinanceDrawer task={selectedTask} />}
</>
);
};
export default FinanceTableWrapper;

View File

@@ -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<boolean>(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 (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.hours}
</Typography.Text>
);
case 'cost':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.cost}
</Typography.Text>
);
case 'fixedCost':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.fixedCost}
</Typography.Text>
);
case 'totalBudget':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.totalBudget}
</Typography.Text>
);
case 'totalActual':
return (
<Typography.Text style={{ color: colors.darkGray }}>
{totals.totalActual}
</Typography.Text>
);
case 'variance':
return (
<Typography.Text
style={{
color:
totals.variance < 0
? '#FF0000'
: themeWiseColor('#6DC376', colors.darkGray, themeMode),
}}
>
{totals.variance}
</Typography.Text>
);
default:
return null;
}
};
const renderFinancialTableColumnContent = (columnKey: any, task: any) => {
switch (columnKey) {
case 'task':
return (
<Tooltip title={task.task}>
<Flex gap={8} align="center">
<Typography.Text
ellipsis={{ expanded: false }}
style={{ maxWidth: 160 }}
>
{task.task}
</Typography.Text>
{task.isbBillable && <DollarCircleOutlined />}
</Flex>
</Tooltip>
);
case 'members':
return (
<Avatar.Group>
{task.members.map((member: any) => (
<CustomAvatar avatarName={member.name} size={26} />
))}
</Avatar.Group>
);
case 'hours':
return <Typography.Text>{task.hours}</Typography.Text>;
case 'cost':
return <Typography.Text>{task.cost}</Typography.Text>;
case 'fixedCost':
return (
<Input
value={task.fixedCost}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
textAlign: 'right',
padding: 0,
}}
/>
);
case 'totalBudget':
return (
<Input
value={task.totalBudget}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
textAlign: 'right',
padding: 0,
}}
/>
);
case 'totalActual':
return <Typography.Text>{task.totalActual}</Typography.Text>;
case 'variance':
return (
<Typography.Text
style={{
color: task.variance < 0 ? '#FF0000' : '#6DC376',
}}
>
{task.variance}
</Typography.Text>
);
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 */}
<tr
style={{
height: 40,
backgroundColor: themeWiseColor(
table.color_code,
table.color_code_dark,
themeMode
),
fontWeight: 600,
}}
className="group"
>
<td
colSpan={3}
style={{
width: 48,
textTransform: 'capitalize',
textAlign: 'left',
paddingInline: 16,
backgroundColor: themeWiseColor(
table.color_code,
table.color_code_dark,
themeMode
),
cursor: 'pointer',
}}
className={customColumnHeaderStyles('tableTitle')}
onClick={(e) => setIsCollapse((prev) => !prev)}
>
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
{isCollapse ? <RightOutlined /> : <DownOutlined />}
{table.name} ({table.tasks.length})
</Flex>
</td>
{financeTableColumns.map(
(col) =>
col.key !== 'task' &&
col.key !== 'members' && (
<td
key={col.key}
style={{
width: col.width,
paddingInline: 16,
textAlign: 'end',
}}
>
{renderFinancialTableHeaderContent(col.key)}
</td>
)
)}
</tr>
{/* task rows */}
{table.tasks.map((task: any) => (
<tr
key={task.taskId}
style={{ height: 52 }}
className={`${isCollapse ? 'hidden' : 'static'} cursor-pointer border-b-[1px] ${themeMode === 'dark' ? 'hover:bg-[#000000]' : 'hover:bg-[#f8f7f9]'} `}
onClick={() => onTaskClick(task)}
>
<td
style={{ paddingInline: 16 }}
className={customColumnStyles('selector')}
>
<Checkbox />
</td>
{financeTableColumns.map((col) => (
<td
key={col.key}
className={customColumnStyles(col.key)}
style={{
width: col.width,
paddingInline: 16,
textAlign:
col.type === 'hours' || col.type === 'currency'
? 'end'
: 'start',
}}
>
{renderFinancialTableColumnContent(col.key, task)}
</td>
))}
</tr>
))}
</>
);
};
export default FinanceTable;

View File

@@ -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 (
<Flex align="center" gap={4} style={{ marginInlineStart: 12 }}>
{t('groupByText')}:
<Select
defaultValue={'status'}
options={groupDropdownMenuItems}
onChange={handleChange}
suffixIcon={<CaretDownFilled />}
/>
</Flex>
);
};
export default GroupByFilterDropdown;

View File

@@ -0,0 +1,86 @@
import { Button, ConfigProvider, Flex, Select, Typography } from 'antd';
import React from 'react';
import GroupByFilterDropdown from './group-by-filter-dropdown';
import { DownOutlined } from '@ant-design/icons';
import { useAppDispatch } from '../../../../../hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice';
type ProjectViewFinanceHeaderProps = {
activeTab: 'finance' | 'ratecard';
setActiveTab: (tab: 'finance' | 'ratecard') => void;
activeGroup: 'status' | 'priority' | 'phases';
setActiveGroup: (group: 'status' | 'priority' | 'phases') => void;
};
const ProjectViewFinanceHeader = ({
activeTab,
setActiveTab,
activeGroup,
setActiveGroup,
}: ProjectViewFinanceHeaderProps) => {
// localization
const { t } = useTranslation('project-view-finance');
const dispatch = useAppDispatch();
return (
<ConfigProvider wave={{ disabled: true }}>
<Flex gap={16} align="center" justify="space-between">
<Flex gap={16} align="center">
<Flex>
<Button
className={`${activeTab === 'finance' && 'border-[#1890ff] text-[#1890ff]'} rounded-r-none`}
onClick={() => setActiveTab('finance')}
>
{t('financeText')}
</Button>
<Button
className={`${activeTab === 'ratecard' && 'border-[#1890ff] text-[#1890ff]'} rounded-l-none`}
onClick={() => setActiveTab('ratecard')}
>
{t('ratecardSingularText')}
</Button>
</Flex>
{activeTab === 'finance' && (
<GroupByFilterDropdown
activeGroup={activeGroup}
setActiveGroup={setActiveGroup}
/>
)}
</Flex>
{activeTab === 'finance' ? (
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
{t('exportButton')}
</Button>
) : (
<Flex gap={8} align="center">
<Flex gap={8} align="center">
<Typography.Text>{t('currencyText')}</Typography.Text>
<Select
defaultValue={'lkr'}
options={[
{ value: 'lkr', label: 'LKR' },
{ value: 'usd', label: 'USD' },
{ value: 'inr', label: 'INR' },
]}
onChange={(value) => dispatch(changeCurrency(value))}
/>
</Flex>
<Button
type="primary"
onClick={() => dispatch(toggleImportRatecardsDrawer())}
>
{t('importButton')}
</Button>
</Flex>
)}
</Flex>
</ConfigProvider>
);
};
export default ProjectViewFinanceHeader;

View File

@@ -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<FinanceTabType>('finance');
const [activeGroup, setActiveGroup] = useState<GroupTypes>('status');
return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
<ProjectViewFinanceHeader
activeTab={activeTab}
setActiveTab={setActiveTab}
activeGroup={activeGroup}
setActiveGroup={setActiveGroup}
/>
{activeTab === 'finance' ? (
<FinanceTab groupType={activeGroup} />
) : (
<RatecardTab />
)}
</Flex>
);
};
export default ProjectViewFinance;

View File

@@ -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 (
<Flex vertical gap={8}>
<RatecardTable />
<Typography.Text
type="danger"
style={{ display: 'block', marginTop: '10px' }}
>
{t('ratecardImportantNotice')}
</Typography.Text>
<Button
type="primary"
style={{
marginTop: '10px',
width: 'fit-content',
alignSelf: 'flex-end',
}}
>
{t('saveButton')}
</Button>
{/* import ratecards drawer */}
<ImportRatecardsDrawer />
</Flex>
);
};
export default RatecardTab;

View File

@@ -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<JobRoleType[]>(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<JobRoleType>['columns'] = [
{
title: t('jobTitleColumn'),
dataIndex: 'jobTitle',
render: (text: string, record: JobRoleType, index: number) => (
<Input
value={text}
placeholder="Enter job title"
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
color: '#1890ff',
}}
onChange={(e) => {
const updatedRoles = [...roles];
updatedRoles[index].jobTitle = e.target.value;
setRoles(updatedRoles);
}}
/>
),
},
{
title: `${t('ratePerHourColumn')} (${currency})`,
dataIndex: 'ratePerHour',
render: (text: number, record: JobRoleType, index: number) => (
<Input
type="number"
value={text}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
padding: 0,
}}
onChange={(e) => {
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 ? (
<Avatar.Group>
{members.map((member, i) => (
<CustomAvatar key={i} avatarName={member} size={26} />
))}
</Avatar.Group>
) : (
<Button
shape="circle"
icon={
<PlusOutlined
style={{
fontSize: 12,
width: 22,
height: 22,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
}
/>
),
},
];
return (
<Table
dataSource={roles}
columns={columns}
rowKey={(record) => record.jobId}
pagination={false}
footer={() => (
<Button
type="dashed"
onClick={handleAddRole}
style={{ width: 'fit-content' }}
>
{t('addRoleButton')}
</Button>
)}
/>
);
};
export default RatecardTable;

View File

@@ -23,11 +23,11 @@ const CustomColumnSelectionCell = ({
const { t } = useTranslation('task-list-table');
// Debug the selectionsList and value
console.log('CustomColumnSelectionCell props:', {
selectionsList,
value,
selectionsCount: selectionsList?.length || 0
});
// console.log('CustomColumnSelectionCell props:', {
// selectionsList,
// value,
// selectionsCount: selectionsList?.length || 0
// });
// Set initial selection based on value prop
useEffect(() => {

View File

@@ -854,10 +854,10 @@ const SelectionFieldCell: React.FC<{
useEffect(() => {
if (!loggedInfo) {
console.log('Selection column data:', {
columnKey,
selectionsList,
});
// console.log('Selection column data:', {
// columnKey,
// selectionsList,
// });
setLoggedInfo(true);
}
}, [columnKey, selectionsList, loggedInfo]);

View File

@@ -0,0 +1,230 @@
import {
DeleteOutlined,
EditOutlined,
ExclamationCircleFilled,
SearchOutlined,
} from '@ant-design/icons';
import {
Button,
Card,
Flex,
Input,
Popconfirm,
Table,
TableProps,
Tooltip,
Typography,
} from 'antd';
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { colors } from '../../../styles/colors';
import { useAppDispatch } from '../../../hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '../../../hooks/useDoumentTItle';
import { durationDateFormat } from '../../../utils/durationDateFormat';
import { createRateCard, deleteRateCard, fetchRateCardById, toggleRatecardDrawer } from '../../../features/finance/finance-slice';
import RatecardDrawer from '../../../features/finance/ratecard-drawer/ratecard-drawer';
import { rateCardApiService } from '@/api/settings/rate-cards/rate-cards.api.service';
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
import { RatecardType } from '@/types/project/ratecard.types';
import { useAppSelector } from '../../../hooks/useAppSelector';
interface PaginationType {
current: number;
pageSize: number;
field: string;
order: string;
total: number;
pageSizeOptions: string[];
size: 'small' | 'default';
}
const RatecardSettings: React.FC = () => {
const { t } = useTranslation('/settings/ratecard-settings');
const dispatch = useAppDispatch();
useDocumentTitle('Manage Rate Cards');
const [loading, setLoading] = useState(false);
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedRatecardId, setSelectedRatecardId] = useState<string | null>(null);
const [ratecardDrawerType, setRatecardDrawerType] = useState<'create' | 'update'>('create');
const [pagination, setPagination] = useState<PaginationType>({
current: 1,
pageSize: DEFAULT_PAGE_SIZE,
field: 'name',
order: 'desc',
total: 0,
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
size: 'small',
});
const filteredRatecardsData = useMemo(() => {
return ratecardsList.filter((item) =>
item.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [ratecardsList, searchQuery]);
const fetchRateCards = useCallback(async () => {
setLoading(true);
try {
const response = await rateCardApiService.getRateCards(
pagination.current,
pagination.pageSize,
pagination.field,
pagination.order,
searchQuery
);
if (response.done) {
setRatecardsList(response.body.data || []);
setPagination(prev => ({ ...prev, total: response.body.total || 0 }));
}
} catch (error) {
console.error('Failed to fetch rate cards:', error);
} finally {
setLoading(false);
}
}, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery]);
useEffect(() => {
fetchRateCards();
}, [toggleRatecardDrawer]);
const handleRatecardCreate = useCallback(async () => {
const resultAction = await dispatch(createRateCard({
name: 'Untitled Rate Card',
jobRolesList: [],
currency: 'LKR',
}) as any);
if (createRateCard.fulfilled.match(resultAction)) {
const created = resultAction.payload;
setRatecardDrawerType('update');
setSelectedRatecardId(created.id ?? null);
dispatch(toggleRatecardDrawer());
}
}, [dispatch]);
const handleRatecardUpdate = useCallback((id: string) => {
setRatecardDrawerType('update');
dispatch(fetchRateCardById(id));
setSelectedRatecardId(id);
dispatch(toggleRatecardDrawer());
}, [dispatch]);
const handleTableChange = useCallback((newPagination: any, filters: any, sorter: any) => {
setPagination(prev => ({
...prev,
current: newPagination.current,
pageSize: newPagination.pageSize,
field: sorter.field || 'name',
order: sorter.order === 'ascend' ? 'asc' : 'desc',
}));
}, []);
const columns: TableProps['columns'] = useMemo(() => [
{
key: 'rateName',
title: t('nameColumn'),
render: (record: RatecardType) => (
<Typography.Text style={{ color: '#1890ff', cursor: 'pointer' }}
onClick={() => setSelectedRatecardId(record.id ?? null)}>
{record.name}
</Typography.Text>
),
},
{
key: 'created',
title: t('createdColumn'),
render: (record: RatecardType) => (
<Typography.Text onClick={() => setSelectedRatecardId(record.id ?? null)}>
{durationDateFormat(record.created_at)}
</Typography.Text>
),
},
{
key: 'actionBtns',
width: 80,
render: (record: RatecardType) => (
<Flex gap={8} className="hidden group-hover:flex">
<Tooltip title="Edit">
<Button
size="small"
icon={<EditOutlined />}
onClick={() => record.id && handleRatecardUpdate(record.id)}
/>
</Tooltip>
<Popconfirm
title={t('deleteConfirmationTitle')}
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')}
onConfirm={async () => {
setLoading(true);
if (record.id) {
await dispatch(deleteRateCard(record.id));
await fetchRateCards();
}
setLoading(false);
}}
>
<Tooltip title="Delete">
<Button
shape="default"
icon={<DeleteOutlined />}
size="small"
/>
</Tooltip>
</Popconfirm>
</Flex>
),
},
], [t, handleRatecardUpdate]);
return (
<Card
style={{ width: '100%' }}
title={
<Flex justify="flex-end" align="center" gap={8}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
style={{ maxWidth: 232 }}
suffix={<SearchOutlined />}
/>
<Button type="primary" onClick={handleRatecardCreate}>
{t('createRatecard')}
</Button>
</Flex>
}
>
<Table
loading={loading}
className="custom-two-colors-row-table"
dataSource={filteredRatecardsData}
columns={columns}
rowKey="id"
pagination={{
...pagination,
showSizeChanger: true,
onChange: (page, pageSize) => setPagination(prev => ({ ...prev, current: page, pageSize })),
}}
onChange={handleTableChange}
rowClassName="group"
/>
<RatecardDrawer
type={ratecardDrawerType}
ratecardId={selectedRatecardId || ''}
onSaved={fetchRateCards} // Pass the fetch function as a prop
/>
</Card>
);
};
export default RatecardSettings;

View File

@@ -0,0 +1,6 @@
export interface IJobType {
jobId: string;
jobTitle: string;
ratePerHour?: number;
};

View File

@@ -0,0 +1,25 @@
export interface IJobType {
jobId?: string;
jobtitle?: string;
ratePerHour?: number;
rate_card_id?: string;
job_title_id: string;
rate?: number;
};
export interface JobRoleType extends IJobType {
members: string[] | null;
}
export interface RatecardType {
id?: string;
created_at?: string;
name?: string;
jobRolesList?: IJobType[];
currency?: string;
};
export interface IRatecardViewModel {
total?: number;
data?: RatecardType[];
}