@@ -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';
|
||||
157
worklenz-backend/src/controllers/ratecard-controller.ts
Normal file
157
worklenz-backend/src/controllers/ratecard-controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -56,7 +56,9 @@ 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();
|
||||
|
||||
@@ -64,6 +66,7 @@ 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);
|
||||
|
||||
48
worklenz-backend/src/routes/apis/ratecard-api-router.ts
Normal file
48
worklenz-backend/src/routes/apis/ratecard-api-router.ts
Normal 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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
166
worklenz-frontend/src/features/finance/finance-slice.ts
Normal file
166
worklenz-frontend/src/features/finance/finance-slice.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,7 +1,7 @@
|
||||
export type NavRoutesType = {
|
||||
name: string;
|
||||
path: string;
|
||||
adminOnly: boolean;
|
||||
adminOnly?: boolean;
|
||||
freePlanFeature?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
6
worklenz-frontend/src/types/project/job.types.ts
Normal file
6
worklenz-frontend/src/types/project/job.types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface IJobType {
|
||||
jobId: string;
|
||||
jobTitle: string;
|
||||
ratePerHour?: number;
|
||||
};
|
||||
|
||||
25
worklenz-frontend/src/types/project/ratecard.types.ts
Normal file
25
worklenz-frontend/src/types/project/ratecard.types.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user