feat(finance): implement project finance and rate card management features
- Added new controllers for managing project finance and rate cards, including CRUD operations for rate card roles and project finance tasks. - Introduced API routes for project finance and rate card functionalities, enhancing the backend structure. - Developed frontend components for displaying and managing project finance data, including a finance drawer and rate card settings. - Enhanced localization files to support new UI elements and ensure consistency across multiple languages. - Implemented utility functions for handling man-days and financial calculations, improving overall functionality.
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
"Bash(npm run type-check:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(cp:*)"
|
||||
"Bash(cp:*)",
|
||||
"Bash(ls:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
1860
worklenz-backend/src/controllers/project-finance-controller.ts
Normal file
1860
worklenz-backend/src/controllers/project-finance-controller.ts
Normal file
File diff suppressed because it is too large
Load Diff
292
worklenz-backend/src/controllers/project-ratecard-controller.ts
Normal file
292
worklenz-backend/src/controllers/project-ratecard-controller.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import db from "../config/db";
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
|
||||
export default class ProjectRateCardController extends WorklenzControllerBase {
|
||||
|
||||
// Insert a single role for a project
|
||||
@HandleExceptions()
|
||||
public static async createOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id, job_title_id, rate, man_day_rate } = req.body;
|
||||
if (!project_id || !job_title_id || typeof rate !== "number") {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
||||
}
|
||||
|
||||
// Handle both rate and man_day_rate fields
|
||||
const columns = ["project_id", "job_title_id", "rate"];
|
||||
const values = [project_id, job_title_id, rate];
|
||||
|
||||
if (typeof man_day_rate !== "undefined") {
|
||||
columns.push("man_day_rate");
|
||||
values.push(man_day_rate);
|
||||
}
|
||||
|
||||
const q = `
|
||||
INSERT INTO finance_project_rate_card_roles (${columns.join(", ")})
|
||||
VALUES (${values.map((_, i) => `$${i + 1}`).join(", ")})
|
||||
ON CONFLICT (project_id, job_title_id) DO UPDATE SET
|
||||
rate = EXCLUDED.rate${typeof man_day_rate !== "undefined" ? ", man_day_rate = EXCLUDED.man_day_rate" : ""}
|
||||
RETURNING *,
|
||||
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle;
|
||||
`;
|
||||
const result = await db.query(q, values);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
// Insert multiple roles for a project
|
||||
@HandleExceptions()
|
||||
public static async createMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id, roles } = req.body;
|
||||
if (!Array.isArray(roles) || !project_id) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
||||
}
|
||||
|
||||
// Handle both rate and man_day_rate fields for each role
|
||||
const columns = ["project_id", "job_title_id", "rate", "man_day_rate"];
|
||||
const values = roles.map((role: any) => [
|
||||
project_id,
|
||||
role.job_title_id,
|
||||
typeof role.rate !== "undefined" ? role.rate : 0,
|
||||
typeof role.man_day_rate !== "undefined" ? role.man_day_rate : 0
|
||||
]);
|
||||
|
||||
const q = `
|
||||
INSERT INTO finance_project_rate_card_roles (${columns.join(", ")})
|
||||
VALUES ${values.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`).join(",")}
|
||||
ON CONFLICT (project_id, job_title_id) DO UPDATE SET
|
||||
rate = EXCLUDED.rate,
|
||||
man_day_rate = EXCLUDED.man_day_rate
|
||||
RETURNING *,
|
||||
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle;
|
||||
`;
|
||||
const flatValues = values.flat();
|
||||
const result = await db.query(q, flatValues);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
// Get all roles for a project
|
||||
@HandleExceptions()
|
||||
public static async getByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id } = req.params;
|
||||
const q = `
|
||||
SELECT
|
||||
fprr.*,
|
||||
jt.name as jobtitle,
|
||||
(
|
||||
SELECT COALESCE(json_agg(pm.id), '[]'::json)
|
||||
FROM project_members pm
|
||||
WHERE pm.project_rate_card_role_id = fprr.id
|
||||
) AS members
|
||||
FROM finance_project_rate_card_roles fprr
|
||||
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
||||
WHERE fprr.project_id = $1
|
||||
ORDER BY fprr.created_at;
|
||||
`;
|
||||
const result = await db.query(q, [project_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
// Get a single role by id
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
const q = `
|
||||
SELECT
|
||||
fprr.*,
|
||||
jt.name as jobtitle,
|
||||
(
|
||||
SELECT COALESCE(json_agg(pm.id), '[]'::json)
|
||||
FROM project_members pm
|
||||
WHERE pm.project_rate_card_role_id = fprr.id
|
||||
) AS members
|
||||
FROM finance_project_rate_card_roles fprr
|
||||
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
||||
WHERE fprr.id = $1;
|
||||
`;
|
||||
const result = await db.query(q, [id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
|
||||
// Update a single role by id
|
||||
@HandleExceptions()
|
||||
public static async updateById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
const { job_title_id, rate, man_day_rate } = req.body;
|
||||
let setClause = "job_title_id = $1, updated_at = NOW()";
|
||||
const values = [job_title_id];
|
||||
if (typeof man_day_rate !== "undefined") {
|
||||
setClause += ", man_day_rate = $2";
|
||||
values.push(man_day_rate);
|
||||
} else {
|
||||
setClause += ", rate = $2";
|
||||
values.push(rate);
|
||||
}
|
||||
values.push(id);
|
||||
const q = `
|
||||
WITH updated AS (
|
||||
UPDATE finance_project_rate_card_roles
|
||||
SET ${setClause}
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
),
|
||||
jobtitles AS (
|
||||
SELECT u.*, jt.name AS jobtitle
|
||||
FROM updated u
|
||||
JOIN job_titles jt ON jt.id = u.job_title_id
|
||||
),
|
||||
members AS (
|
||||
SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id
|
||||
FROM project_members pm
|
||||
WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles)
|
||||
GROUP BY pm.project_rate_card_role_id
|
||||
)
|
||||
SELECT jt.*, m.members
|
||||
FROM jobtitles jt
|
||||
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
|
||||
`;
|
||||
const result = await db.query(q, values);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
|
||||
// update project member rate for a project with members
|
||||
@HandleExceptions()
|
||||
public static async updateProjectMemberByProjectIdAndMemberId(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const { project_id, id } = req.params;
|
||||
const { project_rate_card_role_id } = req.body;
|
||||
|
||||
if (!project_id || !id || !project_rate_card_role_id) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Missing values"));
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Check current role assignment
|
||||
const checkQuery = `
|
||||
SELECT project_rate_card_role_id
|
||||
FROM project_members
|
||||
WHERE id = $1 AND project_id = $2;
|
||||
`;
|
||||
const { rows: checkRows } = await db.query(checkQuery, [id, project_id]);
|
||||
|
||||
const currentRoleId = checkRows[0]?.project_rate_card_role_id;
|
||||
|
||||
if (currentRoleId !== null && currentRoleId !== project_rate_card_role_id) {
|
||||
// Step 2: Fetch members with the requested role
|
||||
const membersQuery = `
|
||||
SELECT COALESCE(json_agg(id), '[]'::json) AS members
|
||||
FROM project_members
|
||||
WHERE project_id = $1 AND project_rate_card_role_id = $2;
|
||||
`;
|
||||
const { rows: memberRows } = await db.query(membersQuery, [project_id, project_rate_card_role_id]);
|
||||
|
||||
return res.status(200).send(
|
||||
new ServerResponse(false, memberRows[0], "Already Assigned !")
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Perform the update
|
||||
const updateQuery = `
|
||||
UPDATE project_members
|
||||
SET project_rate_card_role_id = CASE
|
||||
WHEN project_rate_card_role_id = $1 THEN NULL
|
||||
ELSE $1
|
||||
END
|
||||
WHERE id = $2
|
||||
AND project_id = $3
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM finance_project_rate_card_roles
|
||||
WHERE id = $1 AND project_id = $3
|
||||
)
|
||||
RETURNING project_rate_card_role_id;
|
||||
`;
|
||||
const { rows: updateRows } = await db.query(updateQuery, [project_rate_card_role_id, id, project_id]);
|
||||
|
||||
if (updateRows.length === 0) {
|
||||
return res.status(200).send(new ServerResponse(true, [], "Project member not found or invalid project_rate_card_role_id"));
|
||||
}
|
||||
|
||||
const updatedRoleId = updateRows[0].project_rate_card_role_id || project_rate_card_role_id;
|
||||
|
||||
// Step 4: Fetch updated members list
|
||||
const membersQuery = `
|
||||
SELECT COALESCE(json_agg(id), '[]'::json) AS members
|
||||
FROM project_members
|
||||
WHERE project_id = $1 AND project_rate_card_role_id = $2;
|
||||
`;
|
||||
const { rows: finalMembers } = await db.query(membersQuery, [project_id, updatedRoleId]);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, finalMembers[0]));
|
||||
} catch (error) {
|
||||
return res.status(500).send(new ServerResponse(false, null, "Internal server error"));
|
||||
}
|
||||
}
|
||||
// Update all roles for a project (delete then insert)
|
||||
@HandleExceptions()
|
||||
public static async updateByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id, roles } = req.body;
|
||||
if (!Array.isArray(roles) || !project_id) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
||||
}
|
||||
if (roles.length === 0) {
|
||||
// If no roles provided, do nothing and return empty array
|
||||
return res.status(200).send(new ServerResponse(true, []));
|
||||
}
|
||||
// Build upsert query for all roles
|
||||
const columns = ["project_id", "job_title_id", "rate", "man_day_rate"];
|
||||
const values = roles.map((role: any) => [
|
||||
project_id,
|
||||
role.job_title_id,
|
||||
typeof role.rate !== "undefined" ? role.rate : null,
|
||||
typeof role.man_day_rate !== "undefined" ? role.man_day_rate : null
|
||||
]);
|
||||
const q = `
|
||||
WITH upserted AS (
|
||||
INSERT INTO finance_project_rate_card_roles (${columns.join(", ")})
|
||||
VALUES ${values.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`).join(",")}
|
||||
ON CONFLICT (project_id, job_title_id)
|
||||
DO UPDATE SET rate = EXCLUDED.rate, man_day_rate = EXCLUDED.man_day_rate, updated_at = NOW()
|
||||
RETURNING *
|
||||
),
|
||||
jobtitles AS (
|
||||
SELECT upr.*, jt.name AS jobtitle
|
||||
FROM upserted upr
|
||||
JOIN job_titles jt ON jt.id = upr.job_title_id
|
||||
),
|
||||
members AS (
|
||||
SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id
|
||||
FROM project_members pm
|
||||
WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles)
|
||||
GROUP BY pm.project_rate_card_role_id
|
||||
)
|
||||
SELECT jt.*, m.members
|
||||
FROM jobtitles jt
|
||||
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
|
||||
`;
|
||||
const flatValues = values.flat();
|
||||
const result = await db.query(q, flatValues);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
// Delete a single role by id
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`;
|
||||
const result = await db.query(q, [id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
|
||||
// Delete all roles for a project
|
||||
@HandleExceptions()
|
||||
public static async deleteByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id } = req.params;
|
||||
const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`;
|
||||
const result = await db.query(q, [project_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
198
worklenz-backend/src/controllers/ratecard-controller.ts
Normal file
198
worklenz-backend/src/controllers/ratecard-controller.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
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.man_day_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, man_day_rate)
|
||||
VALUES ($1, $2, $3, $4);`,
|
||||
[
|
||||
req.params.id,
|
||||
role.job_title_id,
|
||||
role.rate ?? 0,
|
||||
role.man_day_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));
|
||||
}
|
||||
}
|
||||
@@ -1,120 +1,130 @@
|
||||
import express from "express";
|
||||
|
||||
import AccessControlsController from "../../controllers/access-controls-controller";
|
||||
import AuthController from "../../controllers/auth-controller";
|
||||
import LogsController from "../../controllers/logs-controller";
|
||||
import OverviewController from "../../controllers/overview-controller";
|
||||
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
|
||||
|
||||
import attachmentsApiRouter from "./attachments-api-router";
|
||||
import clientsApiRouter from "./clients-api-router";
|
||||
import jobTitlesApiRouter from "./job-titles-api-router";
|
||||
import notificationsApiRouter from "./notifications-api-router";
|
||||
import personalOverviewApiRouter from "./personal-overview-api-router";
|
||||
import projectMembersApiRouter from "./project-members-api-router";
|
||||
import projectsApiRouter from "./projects-api-router";
|
||||
import settingsApiRouter from "./settings-api-router";
|
||||
import statusesApiRouter from "./statuses-api-router";
|
||||
import subTasksApiRouter from "./sub-tasks-api-router";
|
||||
import taskCommentsApiRouter from "./task-comments-api-router";
|
||||
import taskWorkLogApiRouter from "./task-work-log-api-router";
|
||||
import tasksApiRouter from "./tasks-api-router";
|
||||
import teamMembersApiRouter from "./team-members-api-router";
|
||||
import teamsApiRouter from "./teams-api-router";
|
||||
import timezonesApiRouter from "./timezones-api-router";
|
||||
import todoListApiRouter from "./todo-list-api-router";
|
||||
import projectStatusesApiRouter from "./project-statuses-api-router";
|
||||
import labelsApiRouter from "./labels-api-router";
|
||||
import sharedProjectsApiRouter from "./shared-projects-api-router";
|
||||
import resourceAllocationApiRouter from "./resource-allocation-api-router";
|
||||
import taskTemplatesApiRouter from "./task-templates-api-router";
|
||||
import projectInsightsApiRouter from "./project-insights-api-router";
|
||||
import passwordValidator from "../../middlewares/validators/password-validator";
|
||||
import adminCenterApiRouter from "./admin-center-api-router";
|
||||
import reportingApiRouter from "./reporting-api-router";
|
||||
import activityLogsApiRouter from "./activity-logs-api-router";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectFoldersApiRouter from "./project-folders-api-router";
|
||||
import taskPhasesApiRouter from "./task-phases-api-router";
|
||||
import projectCategoriesApiRouter from "./project-categories-api-router";
|
||||
import homePageApiRouter from "./home-page-api-router";
|
||||
import ganttApiRouter from "./gantt-api-router";
|
||||
import projectCommentsApiRouter from "./project-comments-api-router";
|
||||
import reportingExportApiRouter from "./reporting-export-api-router";
|
||||
import projectHealthsApiRouter from "./project-healths-api-router";
|
||||
import ptTasksApiRouter from "./pt-tasks-api-router";
|
||||
import projectTemplatesApiRouter from "./project-templates-api";
|
||||
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
|
||||
import ptStatusesApiRouter from "./pt-statuses-api-router";
|
||||
import workloadApiRouter from "./gannt-apis/workload-api-router";
|
||||
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
|
||||
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
||||
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
||||
import projectManagerApiRouter from "./project-managers-api-router";
|
||||
|
||||
import billingApiRouter from "./billing-api-router";
|
||||
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
||||
|
||||
import taskRecurringApiRouter from "./task-recurring-api-router";
|
||||
|
||||
import express from "express";
|
||||
|
||||
import AccessControlsController from "../../controllers/access-controls-controller";
|
||||
import AuthController from "../../controllers/auth-controller";
|
||||
import LogsController from "../../controllers/logs-controller";
|
||||
import OverviewController from "../../controllers/overview-controller";
|
||||
import TaskPrioritiesController from "../../controllers/task-priorities-controller";
|
||||
|
||||
import attachmentsApiRouter from "./attachments-api-router";
|
||||
import clientsApiRouter from "./clients-api-router";
|
||||
import jobTitlesApiRouter from "./job-titles-api-router";
|
||||
import notificationsApiRouter from "./notifications-api-router";
|
||||
import personalOverviewApiRouter from "./personal-overview-api-router";
|
||||
import projectMembersApiRouter from "./project-members-api-router";
|
||||
import projectsApiRouter from "./projects-api-router";
|
||||
import settingsApiRouter from "./settings-api-router";
|
||||
import statusesApiRouter from "./statuses-api-router";
|
||||
import subTasksApiRouter from "./sub-tasks-api-router";
|
||||
import taskCommentsApiRouter from "./task-comments-api-router";
|
||||
import taskWorkLogApiRouter from "./task-work-log-api-router";
|
||||
import tasksApiRouter from "./tasks-api-router";
|
||||
import teamMembersApiRouter from "./team-members-api-router";
|
||||
import teamsApiRouter from "./teams-api-router";
|
||||
import timezonesApiRouter from "./timezones-api-router";
|
||||
import todoListApiRouter from "./todo-list-api-router";
|
||||
import projectStatusesApiRouter from "./project-statuses-api-router";
|
||||
import labelsApiRouter from "./labels-api-router";
|
||||
import sharedProjectsApiRouter from "./shared-projects-api-router";
|
||||
import resourceAllocationApiRouter from "./resource-allocation-api-router";
|
||||
import taskTemplatesApiRouter from "./task-templates-api-router";
|
||||
import projectInsightsApiRouter from "./project-insights-api-router";
|
||||
import passwordValidator from "../../middlewares/validators/password-validator";
|
||||
import adminCenterApiRouter from "./admin-center-api-router";
|
||||
import reportingApiRouter from "./reporting-api-router";
|
||||
import activityLogsApiRouter from "./activity-logs-api-router";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectFoldersApiRouter from "./project-folders-api-router";
|
||||
import taskPhasesApiRouter from "./task-phases-api-router";
|
||||
import projectCategoriesApiRouter from "./project-categories-api-router";
|
||||
import homePageApiRouter from "./home-page-api-router";
|
||||
import ganttApiRouter from "./gantt-api-router";
|
||||
import projectCommentsApiRouter from "./project-comments-api-router";
|
||||
import reportingExportApiRouter from "./reporting-export-api-router";
|
||||
import projectHealthsApiRouter from "./project-healths-api-router";
|
||||
import ptTasksApiRouter from "./pt-tasks-api-router";
|
||||
import projectTemplatesApiRouter from "./project-templates-api";
|
||||
import ptTaskPhasesApiRouter from "./pt_task-phases-api-router";
|
||||
import ptStatusesApiRouter from "./pt-statuses-api-router";
|
||||
import workloadApiRouter from "./gannt-apis/workload-api-router";
|
||||
import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
|
||||
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
||||
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
||||
import projectManagerApiRouter from "./project-managers-api-router";
|
||||
|
||||
import billingApiRouter from "./billing-api-router";
|
||||
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
||||
|
||||
import taskRecurringApiRouter from "./task-recurring-api-router";
|
||||
|
||||
import customColumnsApiRouter from "./custom-columns-api-router";
|
||||
import projectFinanceApiRouter from "./project-finance-api-router";
|
||||
import projectRatecardApiRouter from "./project-ratecard-api-router";
|
||||
import ratecardApiRouter from "./ratecard-api-router";
|
||||
|
||||
const api = express.Router();
|
||||
|
||||
api.use("/projects", projectsApiRouter);
|
||||
api.use("/team-members", teamMembersApiRouter);
|
||||
api.use("/job-titles", jobTitlesApiRouter);
|
||||
api.use("/clients", clientsApiRouter);
|
||||
api.use("/teams", teamsApiRouter);
|
||||
api.use("/tasks", tasksApiRouter);
|
||||
api.use("/settings", settingsApiRouter);
|
||||
api.use("/personal-overview", personalOverviewApiRouter);
|
||||
api.use("/statuses", statusesApiRouter);
|
||||
api.use("/todo-list", todoListApiRouter);
|
||||
api.use("/notifications", notificationsApiRouter);
|
||||
api.use("/attachments", attachmentsApiRouter);
|
||||
api.use("/sub-tasks", subTasksApiRouter);
|
||||
api.use("/project-members", projectMembersApiRouter);
|
||||
api.use("/task-time-log", taskWorkLogApiRouter);
|
||||
api.use("/task-comments", taskCommentsApiRouter);
|
||||
api.use("/timezones", timezonesApiRouter);
|
||||
api.use("/project-statuses", projectStatusesApiRouter);
|
||||
api.use("/labels", labelsApiRouter);
|
||||
api.use("/resource-allocation", resourceAllocationApiRouter);
|
||||
api.use("/shared/projects", sharedProjectsApiRouter);
|
||||
api.use("/task-templates", taskTemplatesApiRouter);
|
||||
api.use("/project-insights", projectInsightsApiRouter);
|
||||
api.use("/admin-center", adminCenterApiRouter);
|
||||
api.use("/reporting", reportingApiRouter);
|
||||
api.use("/activity-logs", activityLogsApiRouter);
|
||||
api.use("/projects-folders", projectFoldersApiRouter);
|
||||
api.use("/task-phases", taskPhasesApiRouter);
|
||||
api.use("/project-categories", projectCategoriesApiRouter);
|
||||
api.use("/home", homePageApiRouter);
|
||||
api.use("/gantt", ganttApiRouter);
|
||||
api.use("/project-comments", projectCommentsApiRouter);
|
||||
api.use("/reporting-export", reportingExportApiRouter);
|
||||
api.use("/project-healths", projectHealthsApiRouter);
|
||||
api.use("/project-templates", projectTemplatesApiRouter);
|
||||
api.use("/pt-tasks", ptTasksApiRouter);
|
||||
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
|
||||
api.use("/pt-statuses", ptStatusesApiRouter);
|
||||
api.use("/workload-gannt", workloadApiRouter);
|
||||
api.use("/roadmap-gannt", roadmapApiRouter);
|
||||
api.use("/schedule-gannt", scheduleApiRouter);
|
||||
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
||||
api.use("/project-managers", projectManagerApiRouter);
|
||||
|
||||
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
||||
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
|
||||
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
|
||||
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
|
||||
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
|
||||
|
||||
api.use("/billing", billingApiRouter);
|
||||
api.use("/task-dependencies", taskDependenciesApiRouter);
|
||||
|
||||
api.use("/task-recurring", taskRecurringApiRouter);
|
||||
|
||||
|
||||
const api = express.Router();
|
||||
|
||||
api.use("/projects", projectsApiRouter);
|
||||
api.use("/team-members", teamMembersApiRouter);
|
||||
api.use("/job-titles", jobTitlesApiRouter);
|
||||
api.use("/clients", clientsApiRouter);
|
||||
api.use("/teams", teamsApiRouter);
|
||||
api.use("/tasks", tasksApiRouter);
|
||||
api.use("/settings", settingsApiRouter);
|
||||
api.use("/personal-overview", personalOverviewApiRouter);
|
||||
api.use("/statuses", statusesApiRouter);
|
||||
api.use("/todo-list", todoListApiRouter);
|
||||
api.use("/notifications", notificationsApiRouter);
|
||||
api.use("/attachments", attachmentsApiRouter);
|
||||
api.use("/sub-tasks", subTasksApiRouter);
|
||||
api.use("/project-members", projectMembersApiRouter);
|
||||
api.use("/task-time-log", taskWorkLogApiRouter);
|
||||
api.use("/task-comments", taskCommentsApiRouter);
|
||||
api.use("/timezones", timezonesApiRouter);
|
||||
api.use("/project-statuses", projectStatusesApiRouter);
|
||||
api.use("/labels", labelsApiRouter);
|
||||
api.use("/resource-allocation", resourceAllocationApiRouter);
|
||||
api.use("/shared/projects", sharedProjectsApiRouter);
|
||||
api.use("/task-templates", taskTemplatesApiRouter);
|
||||
api.use("/project-insights", projectInsightsApiRouter);
|
||||
api.use("/admin-center", adminCenterApiRouter);
|
||||
api.use("/reporting", reportingApiRouter);
|
||||
api.use("/activity-logs", activityLogsApiRouter);
|
||||
api.use("/projects-folders", projectFoldersApiRouter);
|
||||
api.use("/task-phases", taskPhasesApiRouter);
|
||||
api.use("/project-categories", projectCategoriesApiRouter);
|
||||
api.use("/home", homePageApiRouter);
|
||||
api.use("/gantt", ganttApiRouter);
|
||||
api.use("/project-comments", projectCommentsApiRouter);
|
||||
api.use("/reporting-export", reportingExportApiRouter);
|
||||
api.use("/project-healths", projectHealthsApiRouter);
|
||||
api.use("/project-templates", projectTemplatesApiRouter);
|
||||
api.use("/pt-tasks", ptTasksApiRouter);
|
||||
api.use("/pt-task-phases", ptTaskPhasesApiRouter);
|
||||
api.use("/pt-statuses", ptStatusesApiRouter);
|
||||
api.use("/workload-gannt", workloadApiRouter);
|
||||
api.use("/roadmap-gannt", roadmapApiRouter);
|
||||
api.use("/schedule-gannt", scheduleApiRouter);
|
||||
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
||||
api.use("/project-managers", projectManagerApiRouter);
|
||||
|
||||
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
||||
api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get));
|
||||
api.post("/change-password", passwordValidator, safeControllerFunction(AuthController.changePassword));
|
||||
api.get("/access-controls/roles", safeControllerFunction(AccessControlsController.getRoles));
|
||||
api.get("/logs/my-dashboard", safeControllerFunction(LogsController.getActivityLog));
|
||||
|
||||
api.use("/billing", billingApiRouter);
|
||||
api.use("/task-dependencies", taskDependenciesApiRouter);
|
||||
|
||||
api.use("/task-recurring", taskRecurringApiRouter);
|
||||
api.use("/custom-columns", customColumnsApiRouter);
|
||||
|
||||
|
||||
api.use("/project-finance", projectFinanceApiRouter);
|
||||
|
||||
api.use("/project-ratecard", projectRatecardApiRouter);
|
||||
|
||||
api.use("/ratecard", ratecardApiRouter);
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import express from "express";
|
||||
|
||||
import ProjectfinanceController from "../../controllers/project-finance-controller";
|
||||
import idParamValidator from "../../middlewares/validators/id-param-validator";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
|
||||
const projectFinanceApiRouter = express.Router();
|
||||
|
||||
projectFinanceApiRouter.get(
|
||||
"/project/:project_id/tasks",
|
||||
safeControllerFunction(ProjectfinanceController.getTasks)
|
||||
);
|
||||
projectFinanceApiRouter.get(
|
||||
"/project/:project_id/tasks/:parent_task_id/subtasks",
|
||||
safeControllerFunction(ProjectfinanceController.getSubTasks)
|
||||
);
|
||||
projectFinanceApiRouter.get(
|
||||
"/task/:id/breakdown",
|
||||
idParamValidator,
|
||||
safeControllerFunction(ProjectfinanceController.getTaskBreakdown)
|
||||
);
|
||||
projectFinanceApiRouter.put(
|
||||
"/task/:task_id/fixed-cost",
|
||||
safeControllerFunction(ProjectfinanceController.updateTaskFixedCost)
|
||||
);
|
||||
|
||||
projectFinanceApiRouter.put(
|
||||
"/project/:project_id/currency",
|
||||
safeControllerFunction(ProjectfinanceController.updateProjectCurrency)
|
||||
);
|
||||
projectFinanceApiRouter.put(
|
||||
"/project/:project_id/budget",
|
||||
safeControllerFunction(ProjectfinanceController.updateProjectBudget)
|
||||
);
|
||||
projectFinanceApiRouter.put(
|
||||
"/project/:project_id/calculation-method",
|
||||
safeControllerFunction(
|
||||
ProjectfinanceController.updateProjectCalculationMethod
|
||||
)
|
||||
);
|
||||
projectFinanceApiRouter.put(
|
||||
"/rate-card-role/:rate_card_role_id/man-day-rate",
|
||||
safeControllerFunction(ProjectfinanceController.updateRateCardManDayRate)
|
||||
);
|
||||
projectFinanceApiRouter.get(
|
||||
"/project/:project_id/export",
|
||||
safeControllerFunction(ProjectfinanceController.exportFinanceData)
|
||||
);
|
||||
|
||||
export default projectFinanceApiRouter;
|
||||
@@ -0,0 +1,19 @@
|
||||
import express from "express";
|
||||
import ProjectRateCardController from "../../controllers/project-ratecard-controller";
|
||||
import idParamValidator from "../../middlewares/validators/id-param-validator";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import projectManagerValidator from "../../middlewares/validators/project-manager-validator";
|
||||
|
||||
const projectRatecardApiRouter = express.Router();
|
||||
|
||||
projectRatecardApiRouter.post("/", projectManagerValidator, safeControllerFunction(ProjectRateCardController.createMany));
|
||||
projectRatecardApiRouter.post("/create-project-rate-card-role",projectManagerValidator,safeControllerFunction(ProjectRateCardController.createOne));
|
||||
projectRatecardApiRouter.get("/project/:project_id",safeControllerFunction(ProjectRateCardController.getByProjectId));
|
||||
projectRatecardApiRouter.get("/:id",idParamValidator,safeControllerFunction(ProjectRateCardController.getById));
|
||||
projectRatecardApiRouter.put("/:id",idParamValidator,safeControllerFunction(ProjectRateCardController.updateById));
|
||||
projectRatecardApiRouter.put("/project/:project_id",safeControllerFunction(ProjectRateCardController.updateByProjectId));
|
||||
projectRatecardApiRouter.put("/project/:project_id/members/:id/rate-card-role",idParamValidator,projectManagerValidator,safeControllerFunction( ProjectRateCardController.updateProjectMemberByProjectIdAndMemberId));
|
||||
projectRatecardApiRouter.delete("/:id",idParamValidator,safeControllerFunction(ProjectRateCardController.deleteById));
|
||||
projectRatecardApiRouter.delete("/project/:project_id",safeControllerFunction(ProjectRateCardController.deleteByProjectId));
|
||||
|
||||
export default projectRatecardApiRouter;
|
||||
13
worklenz-backend/src/routes/apis/ratecard-api-router.ts
Normal file
13
worklenz-backend/src/routes/apis/ratecard-api-router.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import express from "express";
|
||||
|
||||
import RatecardController from "../../controllers/ratecard-controller";
|
||||
|
||||
const ratecardApiRouter = express.Router();
|
||||
|
||||
ratecardApiRouter.post("/", RatecardController.create);
|
||||
ratecardApiRouter.get("/", RatecardController.get);
|
||||
ratecardApiRouter.get("/:id", RatecardController.getById);
|
||||
ratecardApiRouter.put("/:id", RatecardController.update);
|
||||
ratecardApiRouter.delete("/:id", RatecardController.deleteById);
|
||||
|
||||
export default ratecardApiRouter;
|
||||
114
worklenz-frontend/public/locales/alb/project-view-finance.json
Normal file
114
worklenz-frontend/public/locales/alb/project-view-finance.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"financeText": "Finance",
|
||||
"ratecardSingularText": "Rate Card",
|
||||
"groupByText": "Group by",
|
||||
"statusText": "Status",
|
||||
"phaseText": "Phase",
|
||||
"priorityText": "Priority",
|
||||
"exportButton": "Export",
|
||||
"currencyText": "Currency",
|
||||
"importButton": "Import",
|
||||
"filterText": "Filter",
|
||||
"billableOnlyText": "Billable Only",
|
||||
"nonBillableOnlyText": "Non-Billable Only",
|
||||
"allTasksText": "All Tasks",
|
||||
"projectBudgetOverviewText": "Project Budget Overview",
|
||||
"taskColumn": "Task",
|
||||
"membersColumn": "Members",
|
||||
"hoursColumn": "Estimated Hours",
|
||||
"manDaysColumn": "Estimated Man Days",
|
||||
"actualManDaysColumn": "Actual Man Days",
|
||||
"effortVarianceColumn": "Effort Variance",
|
||||
"totalTimeLoggedColumn": "Total Time Logged",
|
||||
"costColumn": "Actual Cost",
|
||||
"estimatedCostColumn": "Estimated Cost",
|
||||
"fixedCostColumn": "Fixed Cost",
|
||||
"totalBudgetedCostColumn": "Total Budgeted Cost",
|
||||
"totalActualCostColumn": "Total Actual Cost",
|
||||
"varianceColumn": "Variance",
|
||||
"totalText": "Total",
|
||||
"noTasksFound": "No tasks found",
|
||||
"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",
|
||||
"ratePerManDayColumn": "Rate per man day",
|
||||
"calculationMethodText": "Calculation Method",
|
||||
"hourlyRatesText": "Hourly Rates",
|
||||
"manDaysText": "Man Days",
|
||||
"hoursPerDayText": "Hours per Day",
|
||||
"ratecardPluralText": "Rate Cards",
|
||||
"labourHoursColumn": "Labour Hours",
|
||||
"actions": "Actions",
|
||||
"selectJobTitle": "Select Job Title",
|
||||
"ratecardsPluralText": "Rate Card Templates",
|
||||
"deleteConfirm": "Are you sure ?",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.",
|
||||
"budgetOverviewTooltips": {
|
||||
"manualBudget": "Manual project budget amount set by project manager",
|
||||
"totalActualCost": "Total actual cost including fixed costs",
|
||||
"variance": "Difference between manual budget and actual cost",
|
||||
"utilization": "Percentage of manual budget utilized",
|
||||
"estimatedHours": "Total estimated hours from all tasks",
|
||||
"fixedCosts": "Total fixed costs from all tasks",
|
||||
"timeBasedCost": "Actual cost from time tracking (excluding fixed costs)",
|
||||
"remainingBudget": "Remaining budget amount"
|
||||
},
|
||||
"budgetModal": {
|
||||
"title": "Edit Project Budget",
|
||||
"description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.",
|
||||
"placeholder": "Enter budget amount",
|
||||
"saveButton": "Save",
|
||||
"cancelButton": "Cancel"
|
||||
},
|
||||
"budgetStatistics": {
|
||||
"manualBudget": "Manual Budget",
|
||||
"totalActualCost": "Total Actual Cost",
|
||||
"variance": "Variance",
|
||||
"budgetUtilization": "Budget Utilization",
|
||||
"estimatedHours": "Estimated Hours",
|
||||
"fixedCosts": "Fixed Costs",
|
||||
"timeBasedCost": "Time-based Cost",
|
||||
"remainingBudget": "Remaining Budget",
|
||||
"noManualBudgetSet": "(No Manual Budget Set)"
|
||||
},
|
||||
"budgetSettingsDrawer": {
|
||||
"title": "Project Budget Settings",
|
||||
"budgetConfiguration": "Budget Configuration",
|
||||
"projectBudget": "Project Budget",
|
||||
"projectBudgetTooltip": "Total budget allocated for this project",
|
||||
"currency": "Currency",
|
||||
"costCalculationMethod": "Cost Calculation Method",
|
||||
"calculationMethod": "Calculation Method",
|
||||
"workingHoursPerDay": "Working Hours per Day",
|
||||
"workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations",
|
||||
"hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates",
|
||||
"manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates",
|
||||
"importantNotes": "Important Notes",
|
||||
"calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project",
|
||||
"immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals",
|
||||
"projectWideNote": "• Budget settings apply to the entire project and all its tasks",
|
||||
"cancel": "Cancel",
|
||||
"saveChanges": "Save Changes",
|
||||
"budgetSettingsUpdated": "Budget settings updated successfully",
|
||||
"budgetSettingsUpdateFailed": "Failed to update budget settings"
|
||||
},
|
||||
"columnTooltips": {
|
||||
"hours": "Total estimated hours for all tasks. Calculated from task time estimates.",
|
||||
"manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.",
|
||||
"actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.",
|
||||
"effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.",
|
||||
"totalTimeLogged": "Total time actually logged by team members across all tasks.",
|
||||
"estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.",
|
||||
"estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.",
|
||||
"actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.",
|
||||
"fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.",
|
||||
"totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.",
|
||||
"totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.",
|
||||
"totalActual": "Total actual cost including time-based cost + Fixed Costs.",
|
||||
"variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"nameColumn": "Emri",
|
||||
"createdColumn": "Krijuar",
|
||||
"noProjectsAvailable": "Nuk ka projekte të disponueshme",
|
||||
"deleteConfirmationTitle": "Jeni i sigurt që doni të fshini këtë rate card?",
|
||||
"deleteConfirmationOk": "Po, fshij",
|
||||
"deleteConfirmationCancel": "Anulo",
|
||||
"searchPlaceholder": "Kërko rate cards sipas emrit",
|
||||
"createRatecard": "Krijo Rate Card",
|
||||
"editTooltip": "Redakto rate card",
|
||||
"deleteTooltip": "Fshi rate card",
|
||||
"fetchError": "Dështoi të merret rate card",
|
||||
"createError": "Dështoi të krijohet rate card",
|
||||
"deleteSuccess": "Rate card u fshi me sukses",
|
||||
"deleteError": "Dështoi të fshihet rate card",
|
||||
|
||||
"jobTitleColumn": "Titulli i punës",
|
||||
"ratePerHourColumn": "Tarifa për orë",
|
||||
"ratePerDayColumn": "Tarifa për ditë",
|
||||
"ratePerManDayColumn": "Tarifa për ditë-njeri",
|
||||
"saveButton": "Ruaj",
|
||||
"addRoleButton": "Shto rol",
|
||||
"createRatecardSuccessMessage": "Rate card u krijua me sukses",
|
||||
"createRatecardErrorMessage": "Dështoi të krijohet rate card",
|
||||
"updateRatecardSuccessMessage": "Rate card u përditësua me sukses",
|
||||
"updateRatecardErrorMessage": "Dështoi të përditësohet rate card",
|
||||
"currency": "Monedha",
|
||||
"actionsColumn": "Veprime",
|
||||
"addAllButton": "Shto të gjitha",
|
||||
"removeAllButton": "Hiq të gjitha",
|
||||
"selectJobTitle": "Zgjidh titullin e punës",
|
||||
"unsavedChangesTitle": "Keni ndryshime të paruajtura",
|
||||
"unsavedChangesMessage": "Dëshironi të ruani ndryshimet para se të largoheni?",
|
||||
"unsavedChangesSave": "Ruaj",
|
||||
"unsavedChangesDiscard": "Hidh poshtë",
|
||||
"ratecardNameRequired": "Emri i rate card është i detyrueshëm",
|
||||
"ratecardNamePlaceholder": "Shkruani emrin e rate card",
|
||||
"noRatecardsFound": "Nuk u gjetën rate cards",
|
||||
"loadingRateCards": "Duke ngarkuar rate cards...",
|
||||
"noJobTitlesAvailable": "Nuk ka tituj pune të disponueshëm",
|
||||
"noRolesAdded": "Ende nuk janë shtuar role",
|
||||
"createFirstJobTitle": "Krijo titullin e parë të punës",
|
||||
"jobRolesTitle": "Rolet e punës",
|
||||
"noJobTitlesMessage": "Ju lutemi krijoni tituj pune së pari në cilësimet përpara se të shtoni role në rate cards.",
|
||||
"createNewJobTitle": "Krijo titull të ri pune",
|
||||
"jobTitleNamePlaceholder": "Shkruani emrin e titullit të punës",
|
||||
"jobTitleNameRequired": "Emri i titullit të punës është i detyrueshëm",
|
||||
"jobTitleCreatedSuccess": "Titulli i punës u krijua me sukses",
|
||||
"jobTitleCreateError": "Dështoi të krijohet titulli i punës",
|
||||
"createButton": "Krijo",
|
||||
"cancelButton": "Anulo"
|
||||
}
|
||||
114
worklenz-frontend/public/locales/de/project-view-finance.json
Normal file
114
worklenz-frontend/public/locales/de/project-view-finance.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"financeText": "Finance",
|
||||
"ratecardSingularText": "Rate Card",
|
||||
"groupByText": "Group by",
|
||||
"statusText": "Status",
|
||||
"phaseText": "Phase",
|
||||
"priorityText": "Priority",
|
||||
"exportButton": "Export",
|
||||
"currencyText": "Currency",
|
||||
"importButton": "Import",
|
||||
"filterText": "Filter",
|
||||
"billableOnlyText": "Billable Only",
|
||||
"nonBillableOnlyText": "Non-Billable Only",
|
||||
"allTasksText": "All Tasks",
|
||||
"projectBudgetOverviewText": "Project Budget Overview",
|
||||
"taskColumn": "Task",
|
||||
"membersColumn": "Members",
|
||||
"hoursColumn": "Estimated Hours",
|
||||
"manDaysColumn": "Estimated Man Days",
|
||||
"actualManDaysColumn": "Actual Man Days",
|
||||
"effortVarianceColumn": "Effort Variance",
|
||||
"totalTimeLoggedColumn": "Total Time Logged",
|
||||
"costColumn": "Actual Cost",
|
||||
"estimatedCostColumn": "Estimated Cost",
|
||||
"fixedCostColumn": "Fixed Cost",
|
||||
"totalBudgetedCostColumn": "Total Budgeted Cost",
|
||||
"totalActualCostColumn": "Total Actual Cost",
|
||||
"varianceColumn": "Variance",
|
||||
"totalText": "Total",
|
||||
"noTasksFound": "No tasks found",
|
||||
"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",
|
||||
"ratePerManDayColumn": "Rate per man day",
|
||||
"calculationMethodText": "Calculation Method",
|
||||
"hourlyRatesText": "Hourly Rates",
|
||||
"manDaysText": "Man Days",
|
||||
"hoursPerDayText": "Hours per Day",
|
||||
"ratecardPluralText": "Rate Cards",
|
||||
"labourHoursColumn": "Labour Hours",
|
||||
"actions": "Actions",
|
||||
"selectJobTitle": "Select Job Title",
|
||||
"ratecardsPluralText": "Rate Card Templates",
|
||||
"deleteConfirm": "Are you sure ?",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.",
|
||||
"budgetOverviewTooltips": {
|
||||
"manualBudget": "Manual project budget amount set by project manager",
|
||||
"totalActualCost": "Total actual cost including fixed costs",
|
||||
"variance": "Difference between manual budget and actual cost",
|
||||
"utilization": "Percentage of manual budget utilized",
|
||||
"estimatedHours": "Total estimated hours from all tasks",
|
||||
"fixedCosts": "Total fixed costs from all tasks",
|
||||
"timeBasedCost": "Actual cost from time tracking (excluding fixed costs)",
|
||||
"remainingBudget": "Remaining budget amount"
|
||||
},
|
||||
"budgetModal": {
|
||||
"title": "Edit Project Budget",
|
||||
"description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.",
|
||||
"placeholder": "Enter budget amount",
|
||||
"saveButton": "Save",
|
||||
"cancelButton": "Cancel"
|
||||
},
|
||||
"budgetStatistics": {
|
||||
"manualBudget": "Manual Budget",
|
||||
"totalActualCost": "Total Actual Cost",
|
||||
"variance": "Variance",
|
||||
"budgetUtilization": "Budget Utilization",
|
||||
"estimatedHours": "Estimated Hours",
|
||||
"fixedCosts": "Fixed Costs",
|
||||
"timeBasedCost": "Time-based Cost",
|
||||
"remainingBudget": "Remaining Budget",
|
||||
"noManualBudgetSet": "(No Manual Budget Set)"
|
||||
},
|
||||
"budgetSettingsDrawer": {
|
||||
"title": "Project Budget Settings",
|
||||
"budgetConfiguration": "Budget Configuration",
|
||||
"projectBudget": "Project Budget",
|
||||
"projectBudgetTooltip": "Total budget allocated for this project",
|
||||
"currency": "Currency",
|
||||
"costCalculationMethod": "Cost Calculation Method",
|
||||
"calculationMethod": "Calculation Method",
|
||||
"workingHoursPerDay": "Working Hours per Day",
|
||||
"workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations",
|
||||
"hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates",
|
||||
"manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates",
|
||||
"importantNotes": "Important Notes",
|
||||
"calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project",
|
||||
"immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals",
|
||||
"projectWideNote": "• Budget settings apply to the entire project and all its tasks",
|
||||
"cancel": "Cancel",
|
||||
"saveChanges": "Save Changes",
|
||||
"budgetSettingsUpdated": "Budget settings updated successfully",
|
||||
"budgetSettingsUpdateFailed": "Failed to update budget settings"
|
||||
},
|
||||
"columnTooltips": {
|
||||
"hours": "Total estimated hours for all tasks. Calculated from task time estimates.",
|
||||
"manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.",
|
||||
"actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.",
|
||||
"effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.",
|
||||
"totalTimeLogged": "Total time actually logged by team members across all tasks.",
|
||||
"estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.",
|
||||
"estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.",
|
||||
"actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.",
|
||||
"fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.",
|
||||
"totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.",
|
||||
"totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.",
|
||||
"totalActual": "Total actual cost including time-based cost + Fixed Costs.",
|
||||
"variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"nameColumn": "Name",
|
||||
"createdColumn": "Erstellt",
|
||||
"noProjectsAvailable": "Keine Projekte verfügbar",
|
||||
"deleteConfirmationTitle": "Sind Sie sicher, dass Sie diese Rate Card löschen möchten?",
|
||||
"deleteConfirmationOk": "Ja, löschen",
|
||||
"deleteConfirmationCancel": "Abbrechen",
|
||||
"searchPlaceholder": "Rate Cards nach Name suchen",
|
||||
"createRatecard": "Rate Card erstellen",
|
||||
"editTooltip": "Rate Card bearbeiten",
|
||||
"deleteTooltip": "Rate Card löschen",
|
||||
"fetchError": "Rate Cards konnten nicht abgerufen werden",
|
||||
"createError": "Rate Card konnte nicht erstellt werden",
|
||||
"deleteSuccess": "Rate Card erfolgreich gelöscht",
|
||||
"deleteError": "Rate Card konnte nicht gelöscht werden",
|
||||
|
||||
"jobTitleColumn": "Berufsbezeichnung",
|
||||
"ratePerHourColumn": "Stundensatz",
|
||||
"ratePerDayColumn": "Tagessatz",
|
||||
"ratePerManDayColumn": "Satz pro Manntag",
|
||||
"saveButton": "Speichern",
|
||||
"addRoleButton": "Rolle hinzufügen",
|
||||
"createRatecardSuccessMessage": "Rate Card erfolgreich erstellt",
|
||||
"createRatecardErrorMessage": "Rate Card konnte nicht erstellt werden",
|
||||
"updateRatecardSuccessMessage": "Rate Card erfolgreich aktualisiert",
|
||||
"updateRatecardErrorMessage": "Rate Card konnte nicht aktualisiert werden",
|
||||
"currency": "Währung",
|
||||
"actionsColumn": "Aktionen",
|
||||
"addAllButton": "Alle hinzufügen",
|
||||
"removeAllButton": "Alle entfernen",
|
||||
"selectJobTitle": "Berufsbezeichnung auswählen",
|
||||
"unsavedChangesTitle": "Sie haben ungespeicherte Änderungen",
|
||||
"unsavedChangesMessage": "Möchten Sie Ihre Änderungen vor dem Verlassen speichern?",
|
||||
"unsavedChangesSave": "Speichern",
|
||||
"unsavedChangesDiscard": "Verwerfen",
|
||||
"ratecardNameRequired": "Rate Card Name ist erforderlich",
|
||||
"ratecardNamePlaceholder": "Rate Card Name eingeben",
|
||||
"noRatecardsFound": "Keine Rate Cards gefunden",
|
||||
"loadingRateCards": "Rate Cards werden geladen...",
|
||||
"noJobTitlesAvailable": "Keine Berufsbezeichnungen verfügbar",
|
||||
"noRolesAdded": "Noch keine Rollen hinzugefügt",
|
||||
"createFirstJobTitle": "Erste Berufsbezeichnung erstellen",
|
||||
"jobRolesTitle": "Job-Rollen",
|
||||
"noJobTitlesMessage": "Bitte erstellen Sie zuerst Berufsbezeichnungen in den Einstellungen, bevor Sie Rollen zu Rate Cards hinzufügen.",
|
||||
"createNewJobTitle": "Neue Berufsbezeichnung erstellen",
|
||||
"jobTitleNamePlaceholder": "Name der Berufsbezeichnung eingeben",
|
||||
"jobTitleNameRequired": "Name der Berufsbezeichnung ist erforderlich",
|
||||
"jobTitleCreatedSuccess": "Berufsbezeichnung erfolgreich erstellt",
|
||||
"jobTitleCreateError": "Berufsbezeichnung konnte nicht erstellt werden",
|
||||
"createButton": "Erstellen",
|
||||
"cancelButton": "Abbrechen"
|
||||
}
|
||||
122
worklenz-frontend/public/locales/en/project-view-finance.json
Normal file
122
worklenz-frontend/public/locales/en/project-view-finance.json
Normal file
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"financeText": "Finance",
|
||||
"ratecardSingularText": "Rate Card",
|
||||
"groupByText": "Group by",
|
||||
"statusText": "Status",
|
||||
"phaseText": "Phase",
|
||||
"priorityText": "Priority",
|
||||
"exportButton": "Export",
|
||||
"currencyText": "Currency",
|
||||
"importButton": "Import",
|
||||
"filterText": "Filter",
|
||||
"billableOnlyText": "Billable Only",
|
||||
"nonBillableOnlyText": "Non-Billable Only",
|
||||
"allTasksText": "All Tasks",
|
||||
"projectBudgetOverviewText": "Project Budget Overview",
|
||||
|
||||
"taskColumn": "Task",
|
||||
"membersColumn": "Members",
|
||||
"hoursColumn": "Estimated Hours",
|
||||
"manDaysColumn": "Estimated Man Days",
|
||||
"actualManDaysColumn": "Actual Man Days",
|
||||
"effortVarianceColumn": "Effort Variance",
|
||||
"totalTimeLoggedColumn": "Total Time Logged",
|
||||
"costColumn": "Actual Cost",
|
||||
"estimatedCostColumn": "Estimated Cost",
|
||||
"fixedCostColumn": "Fixed Cost",
|
||||
"totalBudgetedCostColumn": "Total Budgeted Cost",
|
||||
"totalActualCostColumn": "Total Actual Cost",
|
||||
"varianceColumn": "Variance",
|
||||
"totalText": "Total",
|
||||
"noTasksFound": "No tasks found",
|
||||
|
||||
"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",
|
||||
"ratePerManDayColumn": "Rate per man day",
|
||||
"calculationMethodText": "Calculation Method",
|
||||
"hourlyRatesText": "Hourly Rates",
|
||||
"manDaysText": "Man Days",
|
||||
"hoursPerDayText": "Hours per Day",
|
||||
"ratecardPluralText": "Rate Cards",
|
||||
"labourHoursColumn": "Labour Hours",
|
||||
"actions": "Actions",
|
||||
"selectJobTitle": "Select Job Title",
|
||||
"ratecardsPluralText": "Rate Card Templates",
|
||||
"deleteConfirm": "Are you sure ?",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.",
|
||||
|
||||
"budgetOverviewTooltips": {
|
||||
"manualBudget": "Manual project budget amount set by project manager",
|
||||
"totalActualCost": "Total actual cost including fixed costs",
|
||||
"variance": "Difference between manual budget and actual cost",
|
||||
"utilization": "Percentage of manual budget utilized",
|
||||
"estimatedHours": "Total estimated hours from all tasks",
|
||||
"fixedCosts": "Total fixed costs from all tasks",
|
||||
"timeBasedCost": "Actual cost from time tracking (excluding fixed costs)",
|
||||
"remainingBudget": "Remaining budget amount"
|
||||
},
|
||||
|
||||
"budgetModal": {
|
||||
"title": "Edit Project Budget",
|
||||
"description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.",
|
||||
"placeholder": "Enter budget amount",
|
||||
"saveButton": "Save",
|
||||
"cancelButton": "Cancel"
|
||||
},
|
||||
|
||||
"budgetStatistics": {
|
||||
"manualBudget": "Manual Budget",
|
||||
"totalActualCost": "Total Actual Cost",
|
||||
"variance": "Variance",
|
||||
"budgetUtilization": "Budget Utilization",
|
||||
"estimatedHours": "Estimated Hours",
|
||||
"fixedCosts": "Fixed Costs",
|
||||
"timeBasedCost": "Time-based Cost",
|
||||
"remainingBudget": "Remaining Budget",
|
||||
"noManualBudgetSet": "(No Manual Budget Set)"
|
||||
},
|
||||
|
||||
"budgetSettingsDrawer": {
|
||||
"title": "Project Budget Settings",
|
||||
"budgetConfiguration": "Budget Configuration",
|
||||
"projectBudget": "Project Budget",
|
||||
"projectBudgetTooltip": "Total budget allocated for this project",
|
||||
"currency": "Currency",
|
||||
"costCalculationMethod": "Cost Calculation Method",
|
||||
"calculationMethod": "Calculation Method",
|
||||
"workingHoursPerDay": "Working Hours per Day",
|
||||
"workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations",
|
||||
"hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates",
|
||||
"manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates",
|
||||
"importantNotes": "Important Notes",
|
||||
"calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project",
|
||||
"immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals",
|
||||
"projectWideNote": "• Budget settings apply to the entire project and all its tasks",
|
||||
"cancel": "Cancel",
|
||||
"saveChanges": "Save Changes",
|
||||
"budgetSettingsUpdated": "Budget settings updated successfully",
|
||||
"budgetSettingsUpdateFailed": "Failed to update budget settings"
|
||||
},
|
||||
|
||||
"columnTooltips": {
|
||||
"hours": "Total estimated hours for all tasks. Calculated from task time estimates.",
|
||||
"manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.",
|
||||
"actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.",
|
||||
"effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.",
|
||||
"totalTimeLogged": "Total time actually logged by team members across all tasks.",
|
||||
"estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.",
|
||||
"estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.",
|
||||
"actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.",
|
||||
"fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.",
|
||||
"totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.",
|
||||
"totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.",
|
||||
"totalActual": "Total actual cost including time-based cost + Fixed Costs.",
|
||||
"variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"nameColumn": "Name",
|
||||
"createdColumn": "Created",
|
||||
"noProjectsAvailable": "No projects available",
|
||||
"deleteConfirmationTitle": "Are you sure you want to delete this rate card?",
|
||||
"deleteConfirmationOk": "Yes, delete",
|
||||
"deleteConfirmationCancel": "Cancel",
|
||||
"searchPlaceholder": "Search rate cards by name",
|
||||
"createRatecard": "Create Rate Card",
|
||||
"editTooltip": "Edit rate card",
|
||||
"deleteTooltip": "Delete rate card",
|
||||
"fetchError": "Failed to fetch rate cards",
|
||||
"createError": "Failed to create rate card",
|
||||
"deleteSuccess": "Rate card deleted successfully",
|
||||
"deleteError": "Failed to delete rate card",
|
||||
|
||||
"jobTitleColumn": "Job title",
|
||||
"ratePerHourColumn": "Rate per hour",
|
||||
"ratePerDayColumn": "Rate per day",
|
||||
"ratePerManDayColumn": "Rate per man day",
|
||||
"saveButton": "Save",
|
||||
"addRoleButton": "Add Role",
|
||||
"createRatecardSuccessMessage": "Rate card created successfully",
|
||||
"createRatecardErrorMessage": "Failed to create rate card",
|
||||
"updateRatecardSuccessMessage": "Rate card updated successfully",
|
||||
"updateRatecardErrorMessage": "Failed to update rate card",
|
||||
"currency": "Currency",
|
||||
"actionsColumn": "Actions",
|
||||
"addAllButton": "Add All",
|
||||
"removeAllButton": "Remove All",
|
||||
"selectJobTitle": "Select job title",
|
||||
"unsavedChangesTitle": "You have unsaved changes",
|
||||
"unsavedChangesMessage": "Do you want to save your changes before leaving?",
|
||||
"unsavedChangesSave": "Save",
|
||||
"unsavedChangesDiscard": "Discard",
|
||||
"ratecardNameRequired": "Rate card name is required",
|
||||
"ratecardNamePlaceholder": "Enter rate card name",
|
||||
"noRatecardsFound": "No rate cards found",
|
||||
"loadingRateCards": "Loading rate cards...",
|
||||
"noJobTitlesAvailable": "No job titles available",
|
||||
"noRolesAdded": "No roles added yet",
|
||||
"createFirstJobTitle": "Create First Job Title",
|
||||
"jobRolesTitle": "Job Roles",
|
||||
"noJobTitlesMessage": "Please create job titles first in the Job Titles settings before adding roles to rate cards.",
|
||||
"createNewJobTitle": "Create New Job Title",
|
||||
"jobTitleNamePlaceholder": "Enter job title name",
|
||||
"jobTitleNameRequired": "Job title name is required",
|
||||
"jobTitleCreatedSuccess": "Job title created successfully",
|
||||
"jobTitleCreateError": "Failed to create job title",
|
||||
"createButton": "Create",
|
||||
"cancelButton": "Cancel"
|
||||
}
|
||||
114
worklenz-frontend/public/locales/es/project-view-finance.json
Normal file
114
worklenz-frontend/public/locales/es/project-view-finance.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"financeText": "Finance",
|
||||
"ratecardSingularText": "Rate Card",
|
||||
"groupByText": "Group by",
|
||||
"statusText": "Status",
|
||||
"phaseText": "Phase",
|
||||
"priorityText": "Priority",
|
||||
"exportButton": "Export",
|
||||
"currencyText": "Currency",
|
||||
"importButton": "Import",
|
||||
"filterText": "Filter",
|
||||
"billableOnlyText": "Billable Only",
|
||||
"nonBillableOnlyText": "Non-Billable Only",
|
||||
"allTasksText": "All Tasks",
|
||||
"projectBudgetOverviewText": "Project Budget Overview",
|
||||
"taskColumn": "Task",
|
||||
"membersColumn": "Members",
|
||||
"hoursColumn": "Estimated Hours",
|
||||
"manDaysColumn": "Estimated Man Days",
|
||||
"actualManDaysColumn": "Actual Man Days",
|
||||
"effortVarianceColumn": "Effort Variance",
|
||||
"totalTimeLoggedColumn": "Total Time Logged",
|
||||
"costColumn": "Actual Cost",
|
||||
"estimatedCostColumn": "Estimated Cost",
|
||||
"fixedCostColumn": "Fixed Cost",
|
||||
"totalBudgetedCostColumn": "Total Budgeted Cost",
|
||||
"totalActualCostColumn": "Total Actual Cost",
|
||||
"varianceColumn": "Variance",
|
||||
"totalText": "Total",
|
||||
"noTasksFound": "No tasks found",
|
||||
"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",
|
||||
"ratePerManDayColumn": "Rate per man day",
|
||||
"calculationMethodText": "Calculation Method",
|
||||
"hourlyRatesText": "Hourly Rates",
|
||||
"manDaysText": "Man Days",
|
||||
"hoursPerDayText": "Hours per Day",
|
||||
"ratecardPluralText": "Rate Cards",
|
||||
"labourHoursColumn": "Labour Hours",
|
||||
"actions": "Actions",
|
||||
"selectJobTitle": "Select Job Title",
|
||||
"ratecardsPluralText": "Rate Card Templates",
|
||||
"deleteConfirm": "Are you sure ?",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.",
|
||||
"budgetOverviewTooltips": {
|
||||
"manualBudget": "Manual project budget amount set by project manager",
|
||||
"totalActualCost": "Total actual cost including fixed costs",
|
||||
"variance": "Difference between manual budget and actual cost",
|
||||
"utilization": "Percentage of manual budget utilized",
|
||||
"estimatedHours": "Total estimated hours from all tasks",
|
||||
"fixedCosts": "Total fixed costs from all tasks",
|
||||
"timeBasedCost": "Actual cost from time tracking (excluding fixed costs)",
|
||||
"remainingBudget": "Remaining budget amount"
|
||||
},
|
||||
"budgetModal": {
|
||||
"title": "Edit Project Budget",
|
||||
"description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.",
|
||||
"placeholder": "Enter budget amount",
|
||||
"saveButton": "Save",
|
||||
"cancelButton": "Cancel"
|
||||
},
|
||||
"budgetStatistics": {
|
||||
"manualBudget": "Manual Budget",
|
||||
"totalActualCost": "Total Actual Cost",
|
||||
"variance": "Variance",
|
||||
"budgetUtilization": "Budget Utilization",
|
||||
"estimatedHours": "Estimated Hours",
|
||||
"fixedCosts": "Fixed Costs",
|
||||
"timeBasedCost": "Time-based Cost",
|
||||
"remainingBudget": "Remaining Budget",
|
||||
"noManualBudgetSet": "(No Manual Budget Set)"
|
||||
},
|
||||
"budgetSettingsDrawer": {
|
||||
"title": "Project Budget Settings",
|
||||
"budgetConfiguration": "Budget Configuration",
|
||||
"projectBudget": "Project Budget",
|
||||
"projectBudgetTooltip": "Total budget allocated for this project",
|
||||
"currency": "Currency",
|
||||
"costCalculationMethod": "Cost Calculation Method",
|
||||
"calculationMethod": "Calculation Method",
|
||||
"workingHoursPerDay": "Working Hours per Day",
|
||||
"workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations",
|
||||
"hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates",
|
||||
"manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates",
|
||||
"importantNotes": "Important Notes",
|
||||
"calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project",
|
||||
"immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals",
|
||||
"projectWideNote": "• Budget settings apply to the entire project and all its tasks",
|
||||
"cancel": "Cancel",
|
||||
"saveChanges": "Save Changes",
|
||||
"budgetSettingsUpdated": "Budget settings updated successfully",
|
||||
"budgetSettingsUpdateFailed": "Failed to update budget settings"
|
||||
},
|
||||
"columnTooltips": {
|
||||
"hours": "Total estimated hours for all tasks. Calculated from task time estimates.",
|
||||
"manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.",
|
||||
"actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.",
|
||||
"effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.",
|
||||
"totalTimeLogged": "Total time actually logged by team members across all tasks.",
|
||||
"estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.",
|
||||
"estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.",
|
||||
"actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.",
|
||||
"fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.",
|
||||
"totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.",
|
||||
"totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.",
|
||||
"totalActual": "Total actual cost including time-based cost + Fixed Costs.",
|
||||
"variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"nameColumn": "Nombre",
|
||||
"createdColumn": "Creado",
|
||||
"noProjectsAvailable": "No hay proyectos disponibles",
|
||||
"deleteConfirmationTitle": "¿Está seguro de que desea eliminar esta rate card?",
|
||||
"deleteConfirmationOk": "Sí, eliminar",
|
||||
"deleteConfirmationCancel": "Cancelar",
|
||||
"searchPlaceholder": "Buscar rate cards por nombre",
|
||||
"createRatecard": "Crear Rate Card",
|
||||
"editTooltip": "Editar rate card",
|
||||
"deleteTooltip": "Eliminar rate card",
|
||||
"fetchError": "No se pudieron obtener las rate cards",
|
||||
"createError": "No se pudo crear la rate card",
|
||||
"deleteSuccess": "Rate card eliminada con éxito",
|
||||
"deleteError": "No se pudo eliminar la rate card",
|
||||
|
||||
"jobTitleColumn": "Título del trabajo",
|
||||
"ratePerHourColumn": "Tarifa por hora",
|
||||
"ratePerDayColumn": "Tarifa por día",
|
||||
"ratePerManDayColumn": "Tarifa por día-hombre",
|
||||
"saveButton": "Guardar",
|
||||
"addRoleButton": "Agregar rol",
|
||||
"createRatecardSuccessMessage": "Rate card creada con éxito",
|
||||
"createRatecardErrorMessage": "No se pudo crear la rate card",
|
||||
"updateRatecardSuccessMessage": "Rate card actualizada con éxito",
|
||||
"updateRatecardErrorMessage": "No se pudo actualizar la rate card",
|
||||
"currency": "Moneda",
|
||||
"actionsColumn": "Acciones",
|
||||
"addAllButton": "Agregar todo",
|
||||
"removeAllButton": "Eliminar todo",
|
||||
"selectJobTitle": "Seleccionar título del trabajo",
|
||||
"unsavedChangesTitle": "Tiene cambios sin guardar",
|
||||
"unsavedChangesMessage": "¿Desea guardar los cambios antes de salir?",
|
||||
"unsavedChangesSave": "Guardar",
|
||||
"unsavedChangesDiscard": "Descartar",
|
||||
"ratecardNameRequired": "El nombre de la rate card es obligatorio",
|
||||
"ratecardNamePlaceholder": "Ingrese el nombre de la rate card",
|
||||
"noRatecardsFound": "No se encontraron rate cards",
|
||||
"loadingRateCards": "Cargando rate cards...",
|
||||
"noJobTitlesAvailable": "No hay títulos de trabajo disponibles",
|
||||
"noRolesAdded": "Aún no se han agregado roles",
|
||||
"createFirstJobTitle": "Crear primer título de trabajo",
|
||||
"jobRolesTitle": "Roles de trabajo",
|
||||
"noJobTitlesMessage": "Por favor, cree primero títulos de trabajo en la configuración antes de agregar roles a las rate cards.",
|
||||
"createNewJobTitle": "Crear nuevo título de trabajo",
|
||||
"jobTitleNamePlaceholder": "Ingrese el nombre del título de trabajo",
|
||||
"jobTitleNameRequired": "El nombre del título de trabajo es obligatorio",
|
||||
"jobTitleCreatedSuccess": "Título de trabajo creado con éxito",
|
||||
"jobTitleCreateError": "No se pudo crear el título de trabajo",
|
||||
"createButton": "Crear",
|
||||
"cancelButton": "Cancelar"
|
||||
}
|
||||
114
worklenz-frontend/public/locales/pt/project-view-finance.json
Normal file
114
worklenz-frontend/public/locales/pt/project-view-finance.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"financeText": "Finance",
|
||||
"ratecardSingularText": "Rate Card",
|
||||
"groupByText": "Group by",
|
||||
"statusText": "Status",
|
||||
"phaseText": "Phase",
|
||||
"priorityText": "Priority",
|
||||
"exportButton": "Export",
|
||||
"currencyText": "Currency",
|
||||
"importButton": "Import",
|
||||
"filterText": "Filter",
|
||||
"billableOnlyText": "Billable Only",
|
||||
"nonBillableOnlyText": "Non-Billable Only",
|
||||
"allTasksText": "All Tasks",
|
||||
"projectBudgetOverviewText": "Project Budget Overview",
|
||||
"taskColumn": "Task",
|
||||
"membersColumn": "Members",
|
||||
"hoursColumn": "Estimated Hours",
|
||||
"manDaysColumn": "Estimated Man Days",
|
||||
"actualManDaysColumn": "Actual Man Days",
|
||||
"effortVarianceColumn": "Effort Variance",
|
||||
"totalTimeLoggedColumn": "Total Time Logged",
|
||||
"costColumn": "Actual Cost",
|
||||
"estimatedCostColumn": "Estimated Cost",
|
||||
"fixedCostColumn": "Fixed Cost",
|
||||
"totalBudgetedCostColumn": "Total Budgeted Cost",
|
||||
"totalActualCostColumn": "Total Actual Cost",
|
||||
"varianceColumn": "Variance",
|
||||
"totalText": "Total",
|
||||
"noTasksFound": "No tasks found",
|
||||
"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",
|
||||
"ratePerManDayColumn": "Rate per man day",
|
||||
"calculationMethodText": "Calculation Method",
|
||||
"hourlyRatesText": "Hourly Rates",
|
||||
"manDaysText": "Man Days",
|
||||
"hoursPerDayText": "Hours per Day",
|
||||
"ratecardPluralText": "Rate Cards",
|
||||
"labourHoursColumn": "Labour Hours",
|
||||
"actions": "Actions",
|
||||
"selectJobTitle": "Select Job Title",
|
||||
"ratecardsPluralText": "Rate Card Templates",
|
||||
"deleteConfirm": "Are you sure ?",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.",
|
||||
"budgetOverviewTooltips": {
|
||||
"manualBudget": "Manual project budget amount set by project manager",
|
||||
"totalActualCost": "Total actual cost including fixed costs",
|
||||
"variance": "Difference between manual budget and actual cost",
|
||||
"utilization": "Percentage of manual budget utilized",
|
||||
"estimatedHours": "Total estimated hours from all tasks",
|
||||
"fixedCosts": "Total fixed costs from all tasks",
|
||||
"timeBasedCost": "Actual cost from time tracking (excluding fixed costs)",
|
||||
"remainingBudget": "Remaining budget amount"
|
||||
},
|
||||
"budgetModal": {
|
||||
"title": "Edit Project Budget",
|
||||
"description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.",
|
||||
"placeholder": "Enter budget amount",
|
||||
"saveButton": "Save",
|
||||
"cancelButton": "Cancel"
|
||||
},
|
||||
"budgetStatistics": {
|
||||
"manualBudget": "Manual Budget",
|
||||
"totalActualCost": "Total Actual Cost",
|
||||
"variance": "Variance",
|
||||
"budgetUtilization": "Budget Utilization",
|
||||
"estimatedHours": "Estimated Hours",
|
||||
"fixedCosts": "Fixed Costs",
|
||||
"timeBasedCost": "Time-based Cost",
|
||||
"remainingBudget": "Remaining Budget",
|
||||
"noManualBudgetSet": "(No Manual Budget Set)"
|
||||
},
|
||||
"budgetSettingsDrawer": {
|
||||
"title": "Project Budget Settings",
|
||||
"budgetConfiguration": "Budget Configuration",
|
||||
"projectBudget": "Project Budget",
|
||||
"projectBudgetTooltip": "Total budget allocated for this project",
|
||||
"currency": "Currency",
|
||||
"costCalculationMethod": "Cost Calculation Method",
|
||||
"calculationMethod": "Calculation Method",
|
||||
"workingHoursPerDay": "Working Hours per Day",
|
||||
"workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations",
|
||||
"hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates",
|
||||
"manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates",
|
||||
"importantNotes": "Important Notes",
|
||||
"calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project",
|
||||
"immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals",
|
||||
"projectWideNote": "• Budget settings apply to the entire project and all its tasks",
|
||||
"cancel": "Cancel",
|
||||
"saveChanges": "Save Changes",
|
||||
"budgetSettingsUpdated": "Budget settings updated successfully",
|
||||
"budgetSettingsUpdateFailed": "Failed to update budget settings"
|
||||
},
|
||||
"columnTooltips": {
|
||||
"hours": "Total estimated hours for all tasks. Calculated from task time estimates.",
|
||||
"manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.",
|
||||
"actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.",
|
||||
"effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.",
|
||||
"totalTimeLogged": "Total time actually logged by team members across all tasks.",
|
||||
"estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.",
|
||||
"estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.",
|
||||
"actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.",
|
||||
"fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.",
|
||||
"totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.",
|
||||
"totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.",
|
||||
"totalActual": "Total actual cost including time-based cost + Fixed Costs.",
|
||||
"variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"nameColumn": "Nome",
|
||||
"createdColumn": "Criado",
|
||||
"noProjectsAvailable": "Nenhum projeto disponível",
|
||||
"deleteConfirmationTitle": "Tem certeza de que deseja excluir este rate card?",
|
||||
"deleteConfirmationOk": "Sim, excluir",
|
||||
"deleteConfirmationCancel": "Cancelar",
|
||||
"searchPlaceholder": "Pesquisar rate cards por nome",
|
||||
"createRatecard": "Criar Rate Card",
|
||||
"editTooltip": "Editar rate card",
|
||||
"deleteTooltip": "Excluir rate card",
|
||||
"fetchError": "Falha ao buscar rate cards",
|
||||
"createError": "Falha ao criar rate card",
|
||||
"deleteSuccess": "Rate card excluído com sucesso",
|
||||
"deleteError": "Falha ao excluir rate card",
|
||||
|
||||
"jobTitleColumn": "Cargo",
|
||||
"ratePerHourColumn": "Taxa por hora",
|
||||
"ratePerDayColumn": "Taxa por dia",
|
||||
"ratePerManDayColumn": "Taxa por dia-homem",
|
||||
"saveButton": "Salvar",
|
||||
"addRoleButton": "Adicionar função",
|
||||
"createRatecardSuccessMessage": "Rate card criado com sucesso",
|
||||
"createRatecardErrorMessage": "Falha ao criar rate card",
|
||||
"updateRatecardSuccessMessage": "Rate card atualizado com sucesso",
|
||||
"updateRatecardErrorMessage": "Falha ao atualizar rate card",
|
||||
"currency": "Moeda",
|
||||
"actionsColumn": "Ações",
|
||||
"addAllButton": "Adicionar todos",
|
||||
"removeAllButton": "Remover todos",
|
||||
"selectJobTitle": "Selecionar cargo",
|
||||
"unsavedChangesTitle": "Você tem alterações não salvas",
|
||||
"unsavedChangesMessage": "Deseja salvar as alterações antes de sair?",
|
||||
"unsavedChangesSave": "Salvar",
|
||||
"unsavedChangesDiscard": "Descartar",
|
||||
"ratecardNameRequired": "O nome do rate card é obrigatório",
|
||||
"ratecardNamePlaceholder": "Digite o nome do rate card",
|
||||
"noRatecardsFound": "Nenhum rate card encontrado",
|
||||
"loadingRateCards": "Carregando rate cards...",
|
||||
"noJobTitlesAvailable": "Nenhum cargo disponível",
|
||||
"noRolesAdded": "Nenhuma função adicionada ainda",
|
||||
"createFirstJobTitle": "Criar primeiro cargo",
|
||||
"jobRolesTitle": "Funções de trabalho",
|
||||
"noJobTitlesMessage": "Por favor, crie cargos primeiro nas configurações antes de adicionar funções aos rate cards.",
|
||||
"createNewJobTitle": "Criar novo cargo",
|
||||
"jobTitleNamePlaceholder": "Digite o nome do cargo",
|
||||
"jobTitleNameRequired": "O nome do cargo é obrigatório",
|
||||
"jobTitleCreatedSuccess": "Cargo criado com sucesso",
|
||||
"jobTitleCreateError": "Falha ao criar cargo",
|
||||
"createButton": "Criar",
|
||||
"cancelButton": "Cancelar"
|
||||
}
|
||||
114
worklenz-frontend/public/locales/zh/project-view-finance.json
Normal file
114
worklenz-frontend/public/locales/zh/project-view-finance.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"financeText": "Finance",
|
||||
"ratecardSingularText": "Rate Card",
|
||||
"groupByText": "Group by",
|
||||
"statusText": "Status",
|
||||
"phaseText": "Phase",
|
||||
"priorityText": "Priority",
|
||||
"exportButton": "Export",
|
||||
"currencyText": "Currency",
|
||||
"importButton": "Import",
|
||||
"filterText": "Filter",
|
||||
"billableOnlyText": "Billable Only",
|
||||
"nonBillableOnlyText": "Non-Billable Only",
|
||||
"allTasksText": "All Tasks",
|
||||
"projectBudgetOverviewText": "Project Budget Overview",
|
||||
"taskColumn": "Task",
|
||||
"membersColumn": "Members",
|
||||
"hoursColumn": "Estimated Hours",
|
||||
"manDaysColumn": "Estimated Man Days",
|
||||
"actualManDaysColumn": "Actual Man Days",
|
||||
"effortVarianceColumn": "Effort Variance",
|
||||
"totalTimeLoggedColumn": "Total Time Logged",
|
||||
"costColumn": "Actual Cost",
|
||||
"estimatedCostColumn": "Estimated Cost",
|
||||
"fixedCostColumn": "Fixed Cost",
|
||||
"totalBudgetedCostColumn": "Total Budgeted Cost",
|
||||
"totalActualCostColumn": "Total Actual Cost",
|
||||
"varianceColumn": "Variance",
|
||||
"totalText": "Total",
|
||||
"noTasksFound": "No tasks found",
|
||||
"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",
|
||||
"ratePerManDayColumn": "Rate per man day",
|
||||
"calculationMethodText": "Calculation Method",
|
||||
"hourlyRatesText": "Hourly Rates",
|
||||
"manDaysText": "Man Days",
|
||||
"hoursPerDayText": "Hours per Day",
|
||||
"ratecardPluralText": "Rate Cards",
|
||||
"labourHoursColumn": "Labour Hours",
|
||||
"actions": "Actions",
|
||||
"selectJobTitle": "Select Job Title",
|
||||
"ratecardsPluralText": "Rate Card Templates",
|
||||
"deleteConfirm": "Are you sure ?",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"alreadyImportedRateCardMessage": "A rate card has already been imported. Clear all imported rate cards to add a new one.",
|
||||
"budgetOverviewTooltips": {
|
||||
"manualBudget": "Manual project budget amount set by project manager",
|
||||
"totalActualCost": "Total actual cost including fixed costs",
|
||||
"variance": "Difference between manual budget and actual cost",
|
||||
"utilization": "Percentage of manual budget utilized",
|
||||
"estimatedHours": "Total estimated hours from all tasks",
|
||||
"fixedCosts": "Total fixed costs from all tasks",
|
||||
"timeBasedCost": "Actual cost from time tracking (excluding fixed costs)",
|
||||
"remainingBudget": "Remaining budget amount"
|
||||
},
|
||||
"budgetModal": {
|
||||
"title": "Edit Project Budget",
|
||||
"description": "Set a manual budget for this project. This budget will be used for all financial calculations and should include both time-based costs and fixed costs.",
|
||||
"placeholder": "Enter budget amount",
|
||||
"saveButton": "Save",
|
||||
"cancelButton": "Cancel"
|
||||
},
|
||||
"budgetStatistics": {
|
||||
"manualBudget": "Manual Budget",
|
||||
"totalActualCost": "Total Actual Cost",
|
||||
"variance": "Variance",
|
||||
"budgetUtilization": "Budget Utilization",
|
||||
"estimatedHours": "Estimated Hours",
|
||||
"fixedCosts": "Fixed Costs",
|
||||
"timeBasedCost": "Time-based Cost",
|
||||
"remainingBudget": "Remaining Budget",
|
||||
"noManualBudgetSet": "(No Manual Budget Set)"
|
||||
},
|
||||
"budgetSettingsDrawer": {
|
||||
"title": "Project Budget Settings",
|
||||
"budgetConfiguration": "Budget Configuration",
|
||||
"projectBudget": "Project Budget",
|
||||
"projectBudgetTooltip": "Total budget allocated for this project",
|
||||
"currency": "Currency",
|
||||
"costCalculationMethod": "Cost Calculation Method",
|
||||
"calculationMethod": "Calculation Method",
|
||||
"workingHoursPerDay": "Working Hours per Day",
|
||||
"workingHoursPerDayTooltip": "Number of working hours in a day for man-day calculations",
|
||||
"hourlyCalculationInfo": "Costs will be calculated using estimated hours × hourly rates",
|
||||
"manDaysCalculationInfo": "Costs will be calculated using estimated man days × daily rates",
|
||||
"importantNotes": "Important Notes",
|
||||
"calculationMethodChangeNote": "• Changing the calculation method will affect how costs are calculated for all tasks in this project",
|
||||
"immediateEffectNote": "• Changes take effect immediately and will recalculate all project totals",
|
||||
"projectWideNote": "• Budget settings apply to the entire project and all its tasks",
|
||||
"cancel": "Cancel",
|
||||
"saveChanges": "Save Changes",
|
||||
"budgetSettingsUpdated": "Budget settings updated successfully",
|
||||
"budgetSettingsUpdateFailed": "Failed to update budget settings"
|
||||
},
|
||||
"columnTooltips": {
|
||||
"hours": "Total estimated hours for all tasks. Calculated from task time estimates.",
|
||||
"manDays": "Total estimated man days for all tasks. Based on {{hoursPerDay}} hours per working day.",
|
||||
"actualManDays": "Actual man days spent based on time logged. Calculated as: Total Time Logged ÷ {{hoursPerDay}} hours per day.",
|
||||
"effortVariance": "Difference between estimated and actual man days. Positive values indicate over-estimation, negative values indicate under-estimation.",
|
||||
"totalTimeLogged": "Total time actually logged by team members across all tasks.",
|
||||
"estimatedCostHourly": "Estimated cost calculated as: Estimated Hours × Hourly Rates for assigned team members.",
|
||||
"estimatedCostManDays": "Estimated cost calculated as: Estimated Man Days × Daily Rates for assigned team members.",
|
||||
"actualCost": "Actual cost based on time logged. Calculated as: Time Logged × Hourly Rates for team members.",
|
||||
"fixedCost": "Fixed costs that don't depend on time spent. Added manually per task.",
|
||||
"totalBudgetHourly": "Total budgeted cost including estimated cost (Hours × Hourly Rates) + Fixed Costs.",
|
||||
"totalBudgetManDays": "Total budgeted cost including estimated cost (Man Days × Daily Rates) + Fixed Costs.",
|
||||
"totalActual": "Total actual cost including time-based cost + Fixed Costs.",
|
||||
"variance": "Cost variance: Total Budgeted Costs - Total Actual Cost. Positive values indicate under-budget, negative values indicate over-budget."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"nameColumn": "名称",
|
||||
"createdColumn": "创建时间",
|
||||
"noProjectsAvailable": "没有可用的项目",
|
||||
"deleteConfirmationTitle": "您确定要删除此费率卡吗?",
|
||||
"deleteConfirmationOk": "是,删除",
|
||||
"deleteConfirmationCancel": "取消",
|
||||
"searchPlaceholder": "按名称搜索费率卡",
|
||||
"createRatecard": "创建费率卡",
|
||||
"editTooltip": "编辑费率卡",
|
||||
"deleteTooltip": "删除费率卡",
|
||||
"fetchError": "获取费率卡失败",
|
||||
"createError": "创建费率卡失败",
|
||||
"deleteSuccess": "费率卡删除成功",
|
||||
"deleteError": "删除费率卡失败",
|
||||
|
||||
"jobTitleColumn": "职位名称",
|
||||
"ratePerHourColumn": "每小时费率",
|
||||
"ratePerDayColumn": "每日费率",
|
||||
"ratePerManDayColumn": "每人每日费率",
|
||||
"saveButton": "保存",
|
||||
"addRoleButton": "添加角色",
|
||||
"createRatecardSuccessMessage": "费率卡创建成功",
|
||||
"createRatecardErrorMessage": "创建费率卡失败",
|
||||
"updateRatecardSuccessMessage": "费率卡更新成功",
|
||||
"updateRatecardErrorMessage": "更新费率卡失败",
|
||||
"currency": "货币",
|
||||
"actionsColumn": "操作",
|
||||
"addAllButton": "全部添加",
|
||||
"removeAllButton": "全部移除",
|
||||
"selectJobTitle": "选择职位名称",
|
||||
"unsavedChangesTitle": "您有未保存的更改",
|
||||
"unsavedChangesMessage": "您想在离开前保存更改吗?",
|
||||
"unsavedChangesSave": "保存",
|
||||
"unsavedChangesDiscard": "放弃",
|
||||
"ratecardNameRequired": "费率卡名称为必填项",
|
||||
"ratecardNamePlaceholder": "输入费率卡名称",
|
||||
"noRatecardsFound": "未找到费率卡",
|
||||
"loadingRateCards": "正在加载费率卡...",
|
||||
"noJobTitlesAvailable": "没有可用的职位名称",
|
||||
"noRolesAdded": "尚未添加角色",
|
||||
"createFirstJobTitle": "创建第一个职位名称",
|
||||
"jobRolesTitle": "职位角色",
|
||||
"noJobTitlesMessage": "请先在职位名称设置中创建职位名称,然后再向费率卡添加角色。",
|
||||
"createNewJobTitle": "创建新职位名称",
|
||||
"jobTitleNamePlaceholder": "输入职位名称",
|
||||
"jobTitleNameRequired": "职位名称为必填项",
|
||||
"jobTitleCreatedSuccess": "职位名称创建成功",
|
||||
"jobTitleCreateError": "职位名称创建失败",
|
||||
"createButton": "创建",
|
||||
"cancelButton": "取消"
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import apiClient from '@api/api-client';
|
||||
import { API_BASE_URL } from '@/shared/constants';
|
||||
import { IServerResponse } from '@/types/common.types';
|
||||
import { IJobType, JobRoleType } from '@/types/project/ratecard.types';
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/project-ratecard`;
|
||||
|
||||
export interface IProjectRateCardRole {
|
||||
id?: string;
|
||||
project_id: string;
|
||||
job_title_id: string;
|
||||
jobtitle?: string;
|
||||
rate: number;
|
||||
man_day_rate?: number;
|
||||
data?: object;
|
||||
roles?: IJobType[];
|
||||
}
|
||||
|
||||
export const projectRateCardApiService = {
|
||||
// Insert multiple roles for a project
|
||||
async insertMany(
|
||||
project_id: string,
|
||||
roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[]
|
||||
): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||
const response = await apiClient.post<IServerResponse<IProjectRateCardRole[]>>(rootUrl, {
|
||||
project_id,
|
||||
roles,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
// Insert a single role for a project
|
||||
async insertOne({
|
||||
project_id,
|
||||
job_title_id,
|
||||
rate,
|
||||
man_day_rate,
|
||||
}: {
|
||||
project_id: string;
|
||||
job_title_id: string;
|
||||
rate: number;
|
||||
man_day_rate?: number;
|
||||
}): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||
const response = await apiClient.post<IServerResponse<IProjectRateCardRole>>(
|
||||
`${rootUrl}/create-project-rate-card-role`,
|
||||
{ project_id, job_title_id, rate, man_day_rate }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get all roles for a project
|
||||
async getFromProjectId(project_id: string): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||
const response = await apiClient.get<IServerResponse<IProjectRateCardRole[]>>(
|
||||
`${rootUrl}/project/${project_id}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get a single role by id
|
||||
async getFromId(id: string): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||
const response = await apiClient.get<IServerResponse<IProjectRateCardRole>>(`${rootUrl}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update a single role by id
|
||||
async updateFromId(
|
||||
id: string,
|
||||
body: { job_title_id: string; rate?: string; man_day_rate?: string }
|
||||
): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||
const response = await apiClient.put<IServerResponse<IProjectRateCardRole>>(
|
||||
`${rootUrl}/${id}`,
|
||||
body
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update all roles for a project (delete then insert)
|
||||
async updateFromProjectId(
|
||||
project_id: string,
|
||||
roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[]
|
||||
): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||
const response = await apiClient.put<IServerResponse<IProjectRateCardRole[]>>(
|
||||
`${rootUrl}/project/${project_id}`,
|
||||
{ project_id, roles }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update project member rate card role
|
||||
async updateMemberRateCardRole(
|
||||
project_id: string,
|
||||
member_id: string,
|
||||
project_rate_card_role_id: string
|
||||
): Promise<IServerResponse<JobRoleType>> {
|
||||
const response = await apiClient.put<IServerResponse<JobRoleType>>(
|
||||
`${rootUrl}/project/${project_id}/members/${member_id}/rate-card-role`,
|
||||
{ project_rate_card_role_id }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete a single role by id
|
||||
async deleteFromId(id: string): Promise<IServerResponse<IProjectRateCardRole>> {
|
||||
const response = await apiClient.delete<IServerResponse<IProjectRateCardRole>>(
|
||||
`${rootUrl}/${id}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete all roles for a project
|
||||
async deleteFromProjectId(project_id: string): Promise<IServerResponse<IProjectRateCardRole[]>> {
|
||||
const response = await apiClient.delete<IServerResponse<IProjectRateCardRole[]>>(
|
||||
`${rootUrl}/project/${project_id}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import { API_BASE_URL } from '@/shared/constants';
|
||||
import { IServerResponse } from '@/types/common.types';
|
||||
import apiClient from '../api-client';
|
||||
import {
|
||||
IProjectFinanceResponse,
|
||||
ITaskBreakdownResponse,
|
||||
IProjectFinanceTask,
|
||||
} from '@/types/project/project-finance.types';
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/project-finance`;
|
||||
|
||||
type BillableFilterType = 'all' | 'billable' | 'non-billable';
|
||||
|
||||
export const projectFinanceApiService = {
|
||||
getProjectTasks: async (
|
||||
projectId: string,
|
||||
groupBy: 'status' | 'priority' | 'phases' = 'status',
|
||||
billableFilter: BillableFilterType = 'billable'
|
||||
): Promise<IServerResponse<IProjectFinanceResponse>> => {
|
||||
const response = await apiClient.get<IServerResponse<IProjectFinanceResponse>>(
|
||||
`${rootUrl}/project/${projectId}/tasks`,
|
||||
{
|
||||
params: {
|
||||
group_by: groupBy,
|
||||
billable_filter: billableFilter,
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSubTasks: async (
|
||||
projectId: string,
|
||||
parentTaskId: string,
|
||||
billableFilter: BillableFilterType = 'billable'
|
||||
): Promise<IServerResponse<IProjectFinanceTask[]>> => {
|
||||
const response = await apiClient.get<IServerResponse<IProjectFinanceTask[]>>(
|
||||
`${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks`,
|
||||
{
|
||||
params: {
|
||||
billable_filter: billableFilter,
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTaskBreakdown: async (taskId: string): Promise<IServerResponse<ITaskBreakdownResponse>> => {
|
||||
const response = await apiClient.get<IServerResponse<ITaskBreakdownResponse>>(
|
||||
`${rootUrl}/task/${taskId}/breakdown`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTaskFixedCost: async (taskId: string, fixedCost: number): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/task/${taskId}/fixed-cost`,
|
||||
{ fixed_cost: fixedCost }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProjectCurrency: async (
|
||||
projectId: string,
|
||||
currency: string
|
||||
): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/project/${projectId}/currency`,
|
||||
{ currency }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProjectBudget: async (projectId: string, budget: number): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/project/${projectId}/budget`,
|
||||
{ budget }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProjectCalculationMethod: async (
|
||||
projectId: string,
|
||||
calculationMethod: 'hourly' | 'man_days',
|
||||
hoursPerDay?: number
|
||||
): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/project/${projectId}/calculation-method`,
|
||||
{
|
||||
calculation_method: calculationMethod,
|
||||
hours_per_day: hoursPerDay,
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTaskEstimatedManDays: async (
|
||||
taskId: string,
|
||||
estimatedManDays: number
|
||||
): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/task/${taskId}/estimated-man-days`,
|
||||
{ estimated_man_days: estimatedManDays }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateRateCardManDayRate: async (
|
||||
rateCardRoleId: string,
|
||||
manDayRate: number
|
||||
): Promise<IServerResponse<any>> => {
|
||||
const response = await apiClient.put<IServerResponse<any>>(
|
||||
`${rootUrl}/rate-card-role/${rateCardRoleId}/man-day-rate`,
|
||||
{ man_day_rate: manDayRate }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
exportFinanceData: async (
|
||||
projectId: string,
|
||||
groupBy: 'status' | 'priority' | 'phases' = 'status',
|
||||
billableFilter: BillableFilterType = 'billable'
|
||||
): Promise<Blob> => {
|
||||
const response = await apiClient.get(`${rootUrl}/project/${projectId}/export`, {
|
||||
params: {
|
||||
groupBy,
|
||||
billable_filter: billableFilter,
|
||||
},
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import apiClient from '@api/api-client';
|
||||
import { API_BASE_URL } from '@/shared/constants';
|
||||
import { IServerResponse } from '@/types/common.types';
|
||||
import { toQueryString } from '@/utils/toQueryString';
|
||||
import { RatecardType, IRatecardViewModel } from '@/types/project/ratecard.types';
|
||||
|
||||
type IRatecard = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/ratecard`;
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -86,6 +86,10 @@ import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||
import projectViewReducer from '@features/project/project-view-slice';
|
||||
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
|
||||
|
||||
import projectFinanceRateCardReducer from '@/features/finance/project-finance-slice';
|
||||
import projectFinancesReducer from '@/features/projects/finance/project-finance.slice';
|
||||
import financeReducer from '@/features/projects/finance/finance-slice';
|
||||
|
||||
export const store = configureStore({
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
@@ -174,6 +178,11 @@ export const store = configureStore({
|
||||
grouping: groupingReducer,
|
||||
taskManagementSelection: selectionReducer,
|
||||
taskManagementFields: taskManagementFieldsReducer,
|
||||
|
||||
// Finance
|
||||
projectFinanceRateCardReducer: projectFinanceRateCardReducer,
|
||||
projectFinancesReducer: projectFinancesReducer,
|
||||
financeReducer: financeReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,57 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Avatar, Tooltip } from '@/shared/antd-imports';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
|
||||
interface AvatarsProps {
|
||||
members: InlineMember[];
|
||||
maxCount?: number;
|
||||
allowClickThrough?: boolean;
|
||||
}
|
||||
|
||||
const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount }) => {
|
||||
const stopPropagation = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const renderAvatar = useCallback(
|
||||
(member: InlineMember, index: number) => (
|
||||
<Tooltip
|
||||
key={member.team_member_id || index}
|
||||
title={member.end && member.names ? member.names.join(', ') : member.name}
|
||||
>
|
||||
{member.avatar_url ? (
|
||||
<span onClick={stopPropagation}>
|
||||
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
||||
</span>
|
||||
) : (
|
||||
<span onClick={stopPropagation}>
|
||||
<Avatar
|
||||
size={28}
|
||||
key={member.team_member_id || index}
|
||||
style={{
|
||||
backgroundColor: member.color_code || '#ececec',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
),
|
||||
[stopPropagation]
|
||||
);
|
||||
|
||||
const visibleMembers = useMemo(() => {
|
||||
return maxCount ? members.slice(0, maxCount) : members;
|
||||
}, [members, maxCount]);
|
||||
|
||||
const avatarElements = useMemo(() => {
|
||||
return visibleMembers.map((member, index) => renderAvatar(member, index));
|
||||
}, [visibleMembers, renderAvatar]);
|
||||
const renderAvatar = (member: InlineMember, index: number, allowClickThrough: boolean = false) => (
|
||||
<Tooltip
|
||||
key={member.team_member_id || index}
|
||||
title={member.end && member.names ? member.names.join(', ') : member.name}
|
||||
>
|
||||
{member.avatar_url ? (
|
||||
<span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
||||
</span>
|
||||
) : (
|
||||
<span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar
|
||||
size={28}
|
||||
key={member.team_member_id || index}
|
||||
style={{
|
||||
backgroundColor: member.color_code || '#ececec',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount, allowClickThrough = false }) => {
|
||||
const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
|
||||
return (
|
||||
<div onClick={stopPropagation}>
|
||||
<Avatar.Group>{avatarElements}</Avatar.Group>
|
||||
<div onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar.Group>
|
||||
{visibleMembers.map((member, index) => renderAvatar(member, index, allowClickThrough))}
|
||||
</Avatar.Group>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Drawer, Typography, Button, Table, Menu, Flex, Spin, Alert } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '../../../hooks/useAppSelector';
|
||||
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
||||
import { fetchRateCards, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice';
|
||||
import { fetchRateCardById } from '@/features/finance/finance-slice';
|
||||
import { insertProjectRateCardRoles } from '@/features/finance/project-finance-slice';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import { IOrganization } from '@/types/admin-center/admin-center.types';
|
||||
import { hourlyRateToManDayRate } from '@/utils/man-days-utils';
|
||||
import { JobRoleType } from '@/types/project/ratecard.types';
|
||||
|
||||
const ImportRateCardsDrawer: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { projectId } = useParams();
|
||||
const { t } = useTranslation('project-view-finance');
|
||||
|
||||
const drawerRatecard = useAppSelector(state => state.financeReducer.drawerRatecard);
|
||||
const ratecardsList = useAppSelector(state => state.financeReducer.ratecardsList || []);
|
||||
const isDrawerOpen = useAppSelector(state => state.financeReducer.isImportRatecardsDrawerOpen);
|
||||
// Get project currency from project finances, fallback to finance reducer currency
|
||||
const projectCurrency = useAppSelector(state => state.projectFinancesReducer.project?.currency);
|
||||
const fallbackCurrency = useAppSelector(state => state.financeReducer.currency);
|
||||
const currency = (projectCurrency || fallbackCurrency || 'USD').toUpperCase();
|
||||
|
||||
const rolesRedux = useAppSelector(state => state.projectFinanceRateCardReducer.rateCardRoles) || [];
|
||||
|
||||
// Loading states
|
||||
const isRatecardsLoading = useAppSelector(state => state.financeReducer.isRatecardsLoading);
|
||||
|
||||
const [selectedRatecardId, setSelectedRatecardId] = useState<string | null>(null);
|
||||
const [organization, setOrganization] = useState<IOrganization | null>(null);
|
||||
|
||||
// Get calculation method from organization
|
||||
const calculationMethod = organization?.calculation_method || 'hourly';
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRatecardId) {
|
||||
dispatch(fetchRateCardById(selectedRatecardId));
|
||||
}
|
||||
}, [selectedRatecardId, dispatch]);
|
||||
|
||||
// Fetch organization details to get calculation method
|
||||
useEffect(() => {
|
||||
const fetchOrganization = async () => {
|
||||
try {
|
||||
const response = await adminCenterApiService.getOrganizationDetails();
|
||||
if (response.done) {
|
||||
setOrganization(response.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch organization details:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isDrawerOpen) {
|
||||
fetchOrganization();
|
||||
}
|
||||
}, [isDrawerOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDrawerOpen) {
|
||||
dispatch(
|
||||
fetchRateCards({
|
||||
index: 1,
|
||||
size: 1000,
|
||||
field: 'name',
|
||||
order: 'asc',
|
||||
search: '',
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [isDrawerOpen, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ratecardsList.length > 0 && !selectedRatecardId) {
|
||||
setSelectedRatecardId(ratecardsList[0].id || null);
|
||||
}
|
||||
}, [ratecardsList, selectedRatecardId]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('jobTitleColumn'),
|
||||
dataIndex: 'jobtitle',
|
||||
render: (text: string) => (
|
||||
<Typography.Text className="group-hover:text-[#1890ff]">{text}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: `${calculationMethod === 'man_days' ? t('ratePerManDayColumn') : t('ratePerHourColumn')} (${currency})`,
|
||||
dataIndex: 'rate',
|
||||
render: (_: any, record: JobRoleType) => (
|
||||
<Typography.Text>
|
||||
{calculationMethod === 'man_days' ? record.man_day_rate : record.rate}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||
{t('ratecardsPluralText')}
|
||||
</Typography.Text>
|
||||
}
|
||||
footer={
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
{/* Alert message */}
|
||||
{rolesRedux.length !== 0 ? (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Alert
|
||||
message={
|
||||
t('alreadyImportedRateCardMessage') ||
|
||||
'A rate card has already been imported. Clear all imported rate cards to add a new one.'
|
||||
}
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
if (!projectId) {
|
||||
// Handle missing project id (show error, etc.)
|
||||
return;
|
||||
}
|
||||
if (drawerRatecard?.jobRolesList?.length) {
|
||||
const isProjectManDays = calculationMethod === 'man_days';
|
||||
const hoursPerDay = organization?.hours_per_day || 8;
|
||||
dispatch(
|
||||
insertProjectRateCardRoles({
|
||||
project_id: projectId,
|
||||
roles: drawerRatecard.jobRolesList
|
||||
.filter(role => typeof role.rate !== 'undefined' && role.job_title_id)
|
||||
.map(role => {
|
||||
if (isProjectManDays) {
|
||||
// If the imported rate card is hourly, convert rate to man_day_rate
|
||||
if (
|
||||
(role.man_day_rate === undefined || role.man_day_rate === 0) &&
|
||||
role.rate
|
||||
) {
|
||||
return {
|
||||
...role,
|
||||
job_title_id: role.job_title_id!,
|
||||
man_day_rate: hourlyRateToManDayRate(
|
||||
Number(role.rate),
|
||||
hoursPerDay
|
||||
),
|
||||
rate: 0,
|
||||
};
|
||||
} else {
|
||||
// Already has man_day_rate
|
||||
return {
|
||||
...role,
|
||||
job_title_id: role.job_title_id!,
|
||||
man_day_rate: Number(role.man_day_rate) || 0,
|
||||
rate: 0,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Project is hourly, import as is
|
||||
return {
|
||||
...role,
|
||||
job_title_id: role.job_title_id!,
|
||||
rate: Number(role.rate) || 0,
|
||||
man_day_rate: Number(role.man_day_rate) || 0,
|
||||
};
|
||||
}
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
dispatch(toggleImportRatecardsDrawer());
|
||||
}}
|
||||
>
|
||||
{t('import')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
open={isDrawerOpen}
|
||||
onClose={() => dispatch(toggleImportRatecardsDrawer())}
|
||||
width={1000}
|
||||
>
|
||||
<Flex gap={12}>
|
||||
{/* Sidebar menu with loading */}
|
||||
<Spin spinning={isRatecardsLoading} style={{ width: '20%' }}>
|
||||
<Menu
|
||||
mode="vertical"
|
||||
style={{ width: '100%' }}
|
||||
selectedKeys={
|
||||
selectedRatecardId
|
||||
? [selectedRatecardId]
|
||||
: ratecardsList[0]?.id
|
||||
? [ratecardsList[0].id]
|
||||
: []
|
||||
}
|
||||
onClick={({ key }) => setSelectedRatecardId(key)}
|
||||
>
|
||||
{ratecardsList.map(ratecard => (
|
||||
<Menu.Item key={ratecard.id}>{ratecard.name}</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
</Spin>
|
||||
|
||||
{/* Table for job roles with loading */}
|
||||
<Table
|
||||
style={{ flex: 1 }}
|
||||
dataSource={drawerRatecard?.jobRolesList || []}
|
||||
columns={columns}
|
||||
rowKey={record => record.job_title_id || record.id || Math.random().toString()}
|
||||
onRow={() => ({
|
||||
className: 'group',
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
pagination={false}
|
||||
loading={isRatecardsLoading}
|
||||
/>
|
||||
</Flex>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportRateCardsDrawer;
|
||||
@@ -0,0 +1,268 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Drawer,
|
||||
Form,
|
||||
Select,
|
||||
InputNumber,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tooltip,
|
||||
message,
|
||||
Alert,
|
||||
SettingOutlined,
|
||||
InfoCircleOutlined,
|
||||
DollarOutlined,
|
||||
CalculatorOutlined,
|
||||
SaveOutlined,
|
||||
CloseOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import {
|
||||
updateProjectFinanceCurrency,
|
||||
fetchProjectFinancesSilent,
|
||||
} from '@/features/projects/finance/project-finance.slice';
|
||||
import { updateProjectCurrency, getProject } from '@/features/project/project.slice';
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
import { CURRENCY_OPTIONS } from '@/shared/currencies';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ProjectBudgetSettingsDrawerProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const ProjectBudgetSettingsDrawer: React.FC<ProjectBudgetSettingsDrawerProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
projectId,
|
||||
}) => {
|
||||
const { t } = useTranslation('project-view-finance');
|
||||
const dispatch = useAppDispatch();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Get project data from Redux
|
||||
const financeProject = useAppSelector(state => state.projectFinancesReducer.project);
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
const activeGroup = useAppSelector(state => state.projectFinancesReducer.activeGroup);
|
||||
const billableFilter = useAppSelector(state => state.projectFinancesReducer.billableFilter);
|
||||
|
||||
// Form initial values
|
||||
const initialValues = {
|
||||
budget: project?.budget || 0,
|
||||
currency: financeProject?.currency || 'USD',
|
||||
};
|
||||
|
||||
// Set form values when drawer opens
|
||||
useEffect(() => {
|
||||
if (visible && (project || financeProject)) {
|
||||
form.setFieldsValue(initialValues);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [visible, project, financeProject, form]);
|
||||
|
||||
// Handle form value changes
|
||||
const handleValuesChange = () => {
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
// Handle save
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const values = await form.validateFields();
|
||||
|
||||
// Update budget if changed
|
||||
if (values.budget !== project?.budget) {
|
||||
await projectFinanceApiService.updateProjectBudget(projectId, values.budget);
|
||||
}
|
||||
|
||||
// Update currency if changed
|
||||
if (values.currency !== financeProject?.currency) {
|
||||
await projectFinanceApiService.updateProjectCurrency(
|
||||
projectId,
|
||||
values.currency.toUpperCase()
|
||||
);
|
||||
dispatch(updateProjectCurrency(values.currency));
|
||||
dispatch(updateProjectFinanceCurrency(values.currency));
|
||||
}
|
||||
|
||||
message.success('Project settings updated successfully');
|
||||
setHasChanges(false);
|
||||
|
||||
// Reload project finances after save
|
||||
dispatch(
|
||||
fetchProjectFinancesSilent({
|
||||
projectId,
|
||||
groupBy: activeGroup,
|
||||
billableFilter,
|
||||
resetExpansions: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Also refresh the main project data to update budget statistics
|
||||
dispatch(getProject(projectId));
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to update project settings:', error);
|
||||
message.error('Failed to update project settings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle cancel
|
||||
const handleCancel = () => {
|
||||
if (hasChanges) {
|
||||
form.setFieldsValue(initialValues);
|
||||
setHasChanges(false);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Space>
|
||||
<SettingOutlined />
|
||||
<span>Project Budget Settings</span>
|
||||
</Space>
|
||||
}
|
||||
width={480}
|
||||
open={visible}
|
||||
onClose={handleCancel}
|
||||
footer={
|
||||
<Space style={{ float: 'right' }}>
|
||||
<Button icon={<CloseOutlined />} onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={loading}
|
||||
disabled={!hasChanges}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={handleValuesChange}
|
||||
initialValues={initialValues}
|
||||
>
|
||||
{/* Budget Configuration */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<DollarOutlined />
|
||||
<span>Budget Configuration</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="budget"
|
||||
label={
|
||||
<Space>
|
||||
<span>Project Budget</span>
|
||||
<Tooltip title="Total budget allocated for this project">
|
||||
<InfoCircleOutlined style={{ color: '#666' }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
precision={2}
|
||||
placeholder="Enter budget amount"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="currency" label="Currency">
|
||||
<Select options={CURRENCY_OPTIONS} placeholder="Select currency" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Calculation Method - Organization Wide Setting */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<CalculatorOutlined />
|
||||
<span>Cost Calculation Method</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text strong>Current Method: </Text>
|
||||
<Text>
|
||||
{financeProject?.calculation_method === 'man_days'
|
||||
? `Man Days (${financeProject?.hours_per_day || 8}h/day)`
|
||||
: 'Hourly Rates'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
message="Organization-wide Setting"
|
||||
description={
|
||||
<Space direction="vertical" size="small">
|
||||
<Text>
|
||||
The calculation method is now configured at the organization level and applies
|
||||
to all projects.
|
||||
</Text>
|
||||
<Text>
|
||||
To change this setting, please visit the{' '}
|
||||
<strong>Admin Center → Overview</strong> page.
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Information Section */}
|
||||
<Card title="Important Notes" size="small" type="inner">
|
||||
<Space direction="vertical" size="small">
|
||||
<Text type="secondary">
|
||||
• Changing the calculation method will affect how costs are calculated for all tasks
|
||||
in this project
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
• Changes take effect immediately and will recalculate all project totals
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
• Budget settings apply to the entire project and all its tasks
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Form>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectBudgetSettingsDrawer;
|
||||
@@ -0,0 +1,298 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Drawer, Typography, Spin } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { closeFinanceDrawer } from '@/features/projects/finance/finance-slice';
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
import { ITaskBreakdownResponse } from '@/types/project/project-finance.types';
|
||||
|
||||
const FinanceDrawer = () => {
|
||||
const [taskBreakdown, setTaskBreakdown] = useState<ITaskBreakdownResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Get task and drawer state from Redux store
|
||||
const selectedTask = useAppSelector(state => state.financeReducer.selectedTask);
|
||||
const isDrawerOpen = useAppSelector(state => state.financeReducer.isFinanceDrawerOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTask?.id && isDrawerOpen) {
|
||||
fetchTaskBreakdown(selectedTask.id);
|
||||
} else {
|
||||
setTaskBreakdown(null);
|
||||
}
|
||||
}, [selectedTask, isDrawerOpen]);
|
||||
|
||||
const fetchTaskBreakdown = async (taskId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await projectFinanceApiService.getTaskBreakdown(taskId);
|
||||
setTaskBreakdown(response.body);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task breakdown:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('project-view-finance');
|
||||
|
||||
// get theme data from theme reducer
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Get project currency from project finances, fallback to finance reducer currency
|
||||
const projectCurrency = useAppSelector(state => state.projectFinancesReducer.project?.currency);
|
||||
const fallbackCurrency = useAppSelector(state => state.financeReducer.currency);
|
||||
const currency = (projectCurrency || fallbackCurrency || 'USD').toUpperCase();
|
||||
|
||||
// function handle drawer close
|
||||
const handleClose = () => {
|
||||
setTaskBreakdown(null);
|
||||
dispatch(closeFinanceDrawer());
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||
{taskBreakdown?.task?.name || selectedTask?.name || t('noTaskSelected')}
|
||||
</Typography.Text>
|
||||
}
|
||||
open={isDrawerOpen}
|
||||
onClose={handleClose}
|
||||
destroyOnHidden={true}
|
||||
width={640}
|
||||
>
|
||||
<div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Task Summary */}
|
||||
{taskBreakdown?.task && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
padding: 16,
|
||||
backgroundColor: themeWiseColor('#f9f9f9', '#1a1a1a', themeMode),
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
strong
|
||||
style={{ fontSize: 16, display: 'block', marginBottom: 12 }}
|
||||
>
|
||||
Task Overview
|
||||
</Typography.Text>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||
Estimated Hours
|
||||
</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
{taskBreakdown.task.estimated_hours?.toFixed(2) || '0.00'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||
Total Logged Hours
|
||||
</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
{taskBreakdown.task.logged_hours?.toFixed(2) || '0.00'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||
Estimated Labor Cost ({currency})
|
||||
</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
{taskBreakdown.task.estimated_labor_cost?.toFixed(2) || '0.00'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||
Actual Labor Cost ({currency})
|
||||
</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
{taskBreakdown.task.actual_labor_cost?.toFixed(2) || '0.00'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||
Fixed Cost ({currency})
|
||||
</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
{taskBreakdown.task.fixed_cost?.toFixed(2) || '0.00'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||
Total Actual Cost ({currency})
|
||||
</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
{taskBreakdown.task.total_actual_cost?.toFixed(2) || '0.00'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member Breakdown Table */}
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12 }}>
|
||||
Member Time Logs & Costs
|
||||
</Typography.Text>
|
||||
<table
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
height: 48,
|
||||
backgroundColor: themeWiseColor('#F5F5F5', '#1d1d1d', themeMode),
|
||||
}}
|
||||
>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
Role / Member
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
Logged Hours
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
Hourly Rate ({currency})
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
Actual Cost ({currency})
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{taskBreakdown?.grouped_members?.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, fontWeight: 'bold' }}>{group.jobRole}</td>
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{group.logged_hours?.toFixed(2) || '0.00'}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
fontWeight: 'bold',
|
||||
color: '#999',
|
||||
}}
|
||||
>
|
||||
-
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{group.actual_cost?.toFixed(2) || '0.00'}
|
||||
</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.logged_hours?.toFixed(2) || '0.00'}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{member.hourly_rate?.toFixed(2) || '0.00'}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{member.actual_cost?.toFixed(2) || '0.00'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinanceDrawer;
|
||||
@@ -0,0 +1,370 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Flex, Typography, Empty, Tooltip } from 'antd';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { openFinanceDrawer } from '@/features/finance/finance-slice';
|
||||
import {
|
||||
FinanceTableColumnKeys,
|
||||
getFinanceTableColumns,
|
||||
} from '@/lib/project/project-view-finance-table-columns';
|
||||
import { formatManDays } from '@/utils/man-days-utils';
|
||||
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
|
||||
import { createPortal } from 'react-dom';
|
||||
import FinanceTable from '../finance-table/FinanceTable';
|
||||
import FinanceDrawer from '../finance-drawer/FinanceDrawer';
|
||||
|
||||
interface FinanceTableWrapperProps {
|
||||
activeTablesList: IProjectFinanceGroup[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
// Utility function to format seconds to time string
|
||||
const formatSecondsToTimeString = (totalSeconds: number): string => {
|
||||
if (!totalSeconds || totalSeconds === 0) return '0s';
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const parts = [];
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesList, loading }) => {
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
|
||||
const { t } = useTranslation('project-view-finance');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onTaskClick = (task: any) => {
|
||||
dispatch(openFinanceDrawer(task));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const tableContainer = document.querySelector('.tasklist-container');
|
||||
const handleScroll = () => {
|
||||
if (tableContainer) {
|
||||
setIsScrolling(tableContainer.scrollLeft > 0);
|
||||
}
|
||||
};
|
||||
|
||||
tableContainer?.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
tableContainer?.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const currency = useAppSelector(
|
||||
state => state.projectFinancesReducer.project?.currency || ''
|
||||
).toUpperCase();
|
||||
const taskGroups = useAppSelector(state => state.projectFinancesReducer.taskGroups);
|
||||
const financeProject = useAppSelector(state => state.projectFinancesReducer.project);
|
||||
|
||||
// Get calculation method and hours per day from project
|
||||
const calculationMethod = financeProject?.calculation_method || 'hourly';
|
||||
const hoursPerDay = financeProject?.hours_per_day || 8;
|
||||
|
||||
// Get dynamic columns based on calculation method
|
||||
const activeColumns = useMemo(
|
||||
() => getFinanceTableColumns(calculationMethod),
|
||||
[calculationMethod]
|
||||
);
|
||||
|
||||
// Function to get tooltip text for column headers
|
||||
const getColumnTooltip = (columnKey: FinanceTableColumnKeys): string => {
|
||||
switch (columnKey) {
|
||||
case FinanceTableColumnKeys.HOURS:
|
||||
return t('columnTooltips.hours');
|
||||
case FinanceTableColumnKeys.MAN_DAYS:
|
||||
return t('columnTooltips.manDays', { hoursPerDay });
|
||||
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||
return t('columnTooltips.totalTimeLogged');
|
||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||
return calculationMethod === 'man_days'
|
||||
? t('columnTooltips.estimatedCostManDays')
|
||||
: t('columnTooltips.estimatedCostHourly');
|
||||
case FinanceTableColumnKeys.COST:
|
||||
return t('columnTooltips.actualCost');
|
||||
case FinanceTableColumnKeys.FIXED_COST:
|
||||
return t('columnTooltips.fixedCost');
|
||||
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||
return calculationMethod === 'man_days'
|
||||
? t('columnTooltips.totalBudgetManDays')
|
||||
: t('columnTooltips.totalBudgetHourly');
|
||||
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
||||
return t('columnTooltips.totalActual');
|
||||
case FinanceTableColumnKeys.VARIANCE:
|
||||
return t('columnTooltips.variance');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Use Redux store data for totals calculation to ensure reactivity
|
||||
const totals = useMemo(() => {
|
||||
// Recursive function to calculate totals from task hierarchy without double counting
|
||||
const calculateTaskTotalsRecursively = (tasks: IProjectFinanceTask[]): any => {
|
||||
return tasks.reduce(
|
||||
(acc, task) => {
|
||||
// For parent tasks with subtasks, aggregate values from subtasks only
|
||||
// For leaf tasks, use their individual values
|
||||
if (task.sub_tasks && task.sub_tasks.length > 0) {
|
||||
// Parent task - only use aggregated values from subtasks (no parent's own values)
|
||||
const subtaskTotals = calculateTaskTotalsRecursively(task.sub_tasks);
|
||||
return {
|
||||
hours: acc.hours + subtaskTotals.hours,
|
||||
manDays: acc.manDays + subtaskTotals.manDays,
|
||||
cost: acc.cost + subtaskTotals.cost,
|
||||
fixedCost: acc.fixedCost + subtaskTotals.fixedCost,
|
||||
totalBudget: acc.totalBudget + subtaskTotals.totalBudget,
|
||||
totalActual: acc.totalActual + subtaskTotals.totalActual,
|
||||
variance: acc.variance + subtaskTotals.variance,
|
||||
total_time_logged: acc.total_time_logged + subtaskTotals.total_time_logged,
|
||||
estimated_cost: acc.estimated_cost + subtaskTotals.estimated_cost,
|
||||
};
|
||||
} else {
|
||||
// Leaf task - use backend-provided calculated values
|
||||
const leafTotalActual = task.total_actual || 0;
|
||||
const leafTotalBudget = task.total_budget || 0;
|
||||
return {
|
||||
hours: acc.hours + (task.estimated_seconds || 0),
|
||||
// Calculate man days from total_minutes, fallback to estimated_seconds if total_minutes is 0
|
||||
manDays:
|
||||
acc.manDays +
|
||||
(task.total_minutes > 0
|
||||
? task.total_minutes / 60 / (hoursPerDay || 8)
|
||||
: task.estimated_seconds / 3600 / (hoursPerDay || 8)),
|
||||
cost: acc.cost + (task.actual_cost_from_logs || 0),
|
||||
fixedCost: acc.fixedCost + (task.fixed_cost || 0),
|
||||
totalBudget: acc.totalBudget + leafTotalBudget,
|
||||
totalActual: acc.totalActual + leafTotalActual,
|
||||
variance: acc.variance + (leafTotalBudget - leafTotalActual),
|
||||
total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0),
|
||||
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0),
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
hours: 0,
|
||||
manDays: 0,
|
||||
cost: 0,
|
||||
fixedCost: 0,
|
||||
totalBudget: 0,
|
||||
totalActual: 0,
|
||||
variance: 0,
|
||||
total_time_logged: 0,
|
||||
estimated_cost: 0,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return activeTablesList.reduce(
|
||||
(acc, table: IProjectFinanceGroup) => {
|
||||
const groupTotals = calculateTaskTotalsRecursively(table.tasks);
|
||||
return {
|
||||
hours: acc.hours + groupTotals.hours,
|
||||
manDays: acc.manDays + groupTotals.manDays,
|
||||
cost: acc.cost + groupTotals.cost,
|
||||
fixedCost: acc.fixedCost + groupTotals.fixedCost,
|
||||
totalBudget: acc.totalBudget + groupTotals.totalBudget,
|
||||
totalActual: acc.totalActual + groupTotals.totalActual,
|
||||
variance: acc.variance + groupTotals.variance,
|
||||
total_time_logged: acc.total_time_logged + groupTotals.total_time_logged,
|
||||
estimated_cost: acc.estimated_cost + groupTotals.estimated_cost,
|
||||
};
|
||||
},
|
||||
{
|
||||
hours: 0,
|
||||
manDays: 0,
|
||||
cost: 0,
|
||||
fixedCost: 0,
|
||||
totalBudget: 0,
|
||||
totalActual: 0,
|
||||
variance: 0,
|
||||
total_time_logged: 0,
|
||||
estimated_cost: 0,
|
||||
}
|
||||
);
|
||||
}, [activeTablesList, hoursPerDay]);
|
||||
|
||||
const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
|
||||
switch (columnKey) {
|
||||
case FinanceTableColumnKeys.HOURS:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{formatSecondsToTimeString(totals.hours)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.MAN_DAYS:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{formatManDays(totals.manDays, 1, hoursPerDay)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.COST:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>{`${totals.cost?.toFixed(2)}`}</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.FIXED_COST:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>{totals.fixedCost?.toFixed(2)}</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{totals.totalBudget?.toFixed(2)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{totals.totalActual?.toFixed(2)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.VARIANCE:
|
||||
return (
|
||||
<Typography.Text
|
||||
style={{
|
||||
color: totals.variance < 0 ? '#d32f2f' : '#2e7d32',
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{totals.variance?.toFixed(2)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{formatSecondsToTimeString(totals.total_time_logged)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: 18 }}>
|
||||
{`${totals.estimated_cost?.toFixed(2)}`}
|
||||
</Typography.Text>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const customColumnHeaderStyles = (key: FinanceTableColumnKeys) =>
|
||||
`px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] 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: FinanceTableColumnKeys) =>
|
||||
`px-2 text-left ${key === FinanceTableColumnKeys.TASK && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[56px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[56px] 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-[#141414]' : 'bg-[#fbfbfb]'}`;
|
||||
|
||||
// Check if there are any tasks across all groups
|
||||
const hasAnyTasks = activeTablesList.some(table => table.tasks && table.tasks.length > 0);
|
||||
|
||||
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)`,
|
||||
}}
|
||||
>
|
||||
{activeColumns.map(col => (
|
||||
<td
|
||||
key={col.key}
|
||||
style={{
|
||||
minWidth: col.width,
|
||||
paddingInline: 16,
|
||||
textAlign:
|
||||
col.type === 'hours' || col.type === 'currency' || col.type === 'man_days'
|
||||
? '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'}`}
|
||||
>
|
||||
<Tooltip title={getColumnTooltip(col.key)} placement="top">
|
||||
<Typography.Text style={{ cursor: 'help' }}>
|
||||
{t(`${col.name}`)} {col.type === 'currency' && `(${currency.toUpperCase()})`}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{hasAnyTasks && (
|
||||
<tr
|
||||
style={{
|
||||
height: 56,
|
||||
fontWeight: 500,
|
||||
backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode),
|
||||
}}
|
||||
>
|
||||
{activeColumns.map((col, index) => (
|
||||
<td
|
||||
key={col.key}
|
||||
style={{
|
||||
minWidth: col.width,
|
||||
paddingInline: 16,
|
||||
textAlign: col.key === FinanceTableColumnKeys.TASK ? 'left' : 'right',
|
||||
backgroundColor: themeWiseColor('#fbfbfb', '#141414', themeMode),
|
||||
}}
|
||||
className={customColumnStyles(col.key)}
|
||||
>
|
||||
{col.key === FinanceTableColumnKeys.TASK ? (
|
||||
<Typography.Text style={{ fontSize: 18 }}>{t('totalText')}</Typography.Text>
|
||||
) : col.key === FinanceTableColumnKeys.MEMBERS ? null : (
|
||||
(col.type === 'hours' ||
|
||||
col.type === 'currency' ||
|
||||
col.type === 'man_days') &&
|
||||
renderFinancialTableHeaderContent(col.key)
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{hasAnyTasks ? (
|
||||
activeTablesList.map(table => (
|
||||
<FinanceTable
|
||||
key={table.group_id}
|
||||
table={table}
|
||||
onTaskClick={onTaskClick}
|
||||
loading={loading}
|
||||
columns={activeColumns}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={activeColumns.length}
|
||||
style={{ padding: '40px 0', textAlign: 'center' }}
|
||||
>
|
||||
<Empty
|
||||
description={
|
||||
<Typography.Text type="secondary">{t('noTasksFound')}</Typography.Text>
|
||||
}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Flex>
|
||||
|
||||
{createPortal(<FinanceDrawer />, document.body)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinanceTableWrapper;
|
||||
@@ -0,0 +1,769 @@
|
||||
import { Flex, InputNumber, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
||||
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,
|
||||
FinanceTableColumnKeys,
|
||||
getFinanceTableColumns,
|
||||
} from '@/lib/project/project-view-finance-table-columns';
|
||||
import { formatManDays } from '@/utils/man-days-utils';
|
||||
import Avatars from '@/components/avatars/avatars';
|
||||
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
|
||||
import {
|
||||
updateTaskFixedCostAsync,
|
||||
toggleTaskExpansion,
|
||||
fetchSubTasks,
|
||||
fetchProjectFinancesSilent,
|
||||
} from '@/features/projects/finance/project-finance.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
setSelectedTaskId,
|
||||
setShowTaskDrawer,
|
||||
fetchTask,
|
||||
} from '@/features/task-drawer/task-drawer.slice';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { canEditFixedCost } from '@/utils/finance-permissions';
|
||||
import './finance-table.css';
|
||||
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||
|
||||
type FinanceTableProps = {
|
||||
table: IProjectFinanceGroup;
|
||||
loading: boolean;
|
||||
onTaskClick: (task: any) => void;
|
||||
columns?: any[];
|
||||
};
|
||||
|
||||
const FinanceTable = ({ table, loading, onTaskClick, columns }: FinanceTableProps) => {
|
||||
const [isCollapse, setIsCollapse] = useState<boolean>(false);
|
||||
const [isScrolling, setIsScrolling] = useState<boolean>(false);
|
||||
const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
|
||||
const [editingFixedCostValue, setEditingFixedCostValue] = useState<number | null>(null);
|
||||
const [tasks, setTasks] = useState<IProjectFinanceTask[]>(table.tasks);
|
||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Get the latest task groups from Redux store
|
||||
const taskGroups = useAppSelector(state => state.projectFinancesReducer.taskGroups);
|
||||
const {
|
||||
activeGroup,
|
||||
billableFilter,
|
||||
project: financeProject,
|
||||
} = useAppSelector(state => state.projectFinancesReducer);
|
||||
|
||||
// Get calculation method and dynamic columns
|
||||
const calculationMethod = financeProject?.calculation_method || 'hourly';
|
||||
const hoursPerDay = financeProject?.hours_per_day || 8;
|
||||
const activeColumns = useMemo(
|
||||
() => columns || getFinanceTableColumns(calculationMethod),
|
||||
[columns, calculationMethod]
|
||||
);
|
||||
|
||||
// Auth and permissions
|
||||
const auth = useAuthService();
|
||||
const currentSession = auth.getCurrentSession();
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
const hasEditPermission = canEditFixedCost(currentSession, project);
|
||||
|
||||
// Update local state when table.tasks or Redux store changes
|
||||
useEffect(() => {
|
||||
const updatedGroup = taskGroups.find(g => g.group_id === table.group_id);
|
||||
if (updatedGroup) {
|
||||
setTasks(updatedGroup.tasks);
|
||||
} else {
|
||||
setTasks(table.tasks);
|
||||
}
|
||||
}, [table.tasks, taskGroups, table.group_id]);
|
||||
|
||||
// Handle click outside to close editing
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (selectedTask && !(event.target as Element)?.closest('.fixed-cost-input')) {
|
||||
// Save current value before closing if it has changed
|
||||
if (editingFixedCostValue !== null) {
|
||||
immediateSaveFixedCost(editingFixedCostValue, selectedTask.id);
|
||||
} else {
|
||||
setSelectedTask(null);
|
||||
setEditingFixedCostValue(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [selectedTask, editingFixedCostValue, tasks]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// get theme data from theme reducer
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const formatNumber = (value: number | undefined | null) => {
|
||||
if (value === undefined || value === null) return '0.00';
|
||||
return value.toFixed(2);
|
||||
};
|
||||
|
||||
// Custom column styles for sticky positioning
|
||||
const customColumnStyles = (key: FinanceTableColumnKeys) =>
|
||||
`px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] 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' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-white'}`;
|
||||
|
||||
const customHeaderColumnStyles = (key: FinanceTableColumnKeys) =>
|
||||
`px-2 text-left ${key === FinanceTableColumnKeys.TASK && 'sticky left-0 z-10'} ${key === FinanceTableColumnKeys.MEMBERS && `sticky left-[240px] 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 renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
|
||||
switch (columnKey) {
|
||||
case FinanceTableColumnKeys.HOURS:
|
||||
return <Typography.Text>{formattedTotals.hours}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.MAN_DAYS:
|
||||
return (
|
||||
<Typography.Text>
|
||||
{formatManDays(formattedTotals.man_days || 0, 1, hoursPerDay)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||
return <Typography.Text>{formattedTotals.total_time_logged}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||
return <Typography.Text>{formatNumber(formattedTotals.estimated_cost)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.COST:
|
||||
return (
|
||||
<Typography.Text>{formatNumber(formattedTotals.actual_cost_from_logs)}</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.FIXED_COST:
|
||||
return <Typography.Text>{formatNumber(formattedTotals.fixed_cost)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||
return <Typography.Text>{formatNumber(formattedTotals.total_budget)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
||||
return <Typography.Text>{formatNumber(formattedTotals.total_actual)}</Typography.Text>;
|
||||
case FinanceTableColumnKeys.VARIANCE:
|
||||
return (
|
||||
<Typography.Text
|
||||
style={{
|
||||
color: formattedTotals.variance < 0 ? '#d32f2f' : '#2e7d32',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{formatNumber(formattedTotals.variance)}
|
||||
</Typography.Text>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFixedCostChange = async (value: number | null, taskId: string) => {
|
||||
const fixedCost = value || 0;
|
||||
|
||||
// Find the task to check if it's a parent task
|
||||
const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => {
|
||||
for (const task of tasks) {
|
||||
if (task.id === id) return task;
|
||||
if (task.sub_tasks) {
|
||||
const found = findTask(task.sub_tasks, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const task = findTask(tasks, taskId);
|
||||
if (!task) {
|
||||
console.error('Task not found:', taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent editing fixed cost for parent tasks
|
||||
if (task.sub_tasks_count > 0) {
|
||||
console.warn(
|
||||
'Cannot edit fixed cost for parent tasks. Fixed cost is calculated from subtasks.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Update the task fixed cost - this will automatically trigger hierarchical recalculation
|
||||
// The Redux slice handles parent task updates through recalculateTaskHierarchy
|
||||
await dispatch(
|
||||
updateTaskFixedCostAsync({ taskId, groupId: table.group_id, fixedCost })
|
||||
).unwrap();
|
||||
|
||||
// Trigger a silent refresh with expansion reset to show updated data clearly
|
||||
if (projectId) {
|
||||
dispatch(
|
||||
fetchProjectFinancesSilent({
|
||||
projectId,
|
||||
groupBy: activeGroup,
|
||||
billableFilter,
|
||||
resetExpansions: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedTask(null);
|
||||
setEditingFixedCostValue(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update fixed cost:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
|
||||
const handleTaskNameClick = (taskId: string) => {
|
||||
if (!taskId || !projectId) return;
|
||||
|
||||
dispatch(setSelectedTaskId(taskId));
|
||||
dispatch(fetchPhasesByProjectId(projectId));
|
||||
dispatch(fetchPriorities());
|
||||
dispatch(fetchTask({ taskId, projectId }));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
};
|
||||
|
||||
// Handle task expansion/collapse
|
||||
const handleTaskExpansion = async (task: IProjectFinanceTask) => {
|
||||
if (!projectId) return;
|
||||
|
||||
// If task has subtasks but they're not loaded yet, load them
|
||||
if (task.sub_tasks_count > 0 && !task.sub_tasks) {
|
||||
dispatch(fetchSubTasks({ projectId, parentTaskId: task.id }));
|
||||
} else {
|
||||
// Just toggle the expansion state
|
||||
dispatch(toggleTaskExpansion({ taskId: task.id, groupId: table.group_id }));
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced save function for fixed cost
|
||||
const debouncedSaveFixedCost = (value: number | null, taskId: string) => {
|
||||
// Clear existing timeout
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
// Find the current task to check if value actually changed
|
||||
const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => {
|
||||
for (const task of tasks) {
|
||||
if (task.id === id) return task;
|
||||
if (task.sub_tasks) {
|
||||
const found = findTask(task.sub_tasks, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const currentTask = findTask(tasks, taskId);
|
||||
const currentFixedCost = currentTask?.fixed_cost || 0;
|
||||
const newFixedCost = value || 0;
|
||||
|
||||
// Only save if the value actually changed
|
||||
if (newFixedCost !== currentFixedCost && value !== null) {
|
||||
handleFixedCostChange(value, taskId);
|
||||
// Don't close the input automatically - let user explicitly close it
|
||||
}
|
||||
}, 5000); // Save after 5 seconds of inactivity
|
||||
};
|
||||
|
||||
// Immediate save function (for enter/blur)
|
||||
const immediateSaveFixedCost = (value: number | null, taskId: string) => {
|
||||
// Clear any pending debounced save
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Find the current task to check if value actually changed
|
||||
const findTask = (tasks: IProjectFinanceTask[], id: string): IProjectFinanceTask | null => {
|
||||
for (const task of tasks) {
|
||||
if (task.id === id) return task;
|
||||
if (task.sub_tasks) {
|
||||
const found = findTask(task.sub_tasks, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const currentTask = findTask(tasks, taskId);
|
||||
const currentFixedCost = currentTask?.fixed_cost || 0;
|
||||
const newFixedCost = value || 0;
|
||||
|
||||
// Only save if the value actually changed
|
||||
if (newFixedCost !== currentFixedCost && value !== null) {
|
||||
handleFixedCostChange(value, taskId);
|
||||
} else {
|
||||
// Just close the editor without saving
|
||||
setSelectedTask(null);
|
||||
setEditingFixedCostValue(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate indentation based on nesting level
|
||||
const getTaskIndentation = (level: number) => level * 32; // 32px per level for better visibility
|
||||
|
||||
// Recursive function to render task hierarchy
|
||||
const renderTaskHierarchy = (
|
||||
task: IProjectFinanceTask,
|
||||
level: number = 0
|
||||
): React.ReactElement[] => {
|
||||
const elements: React.ReactElement[] = [];
|
||||
|
||||
// Add the current task
|
||||
const isHovered = hoveredTaskId === task.id;
|
||||
const rowIndex = elements.length;
|
||||
const defaultBg =
|
||||
rowIndex % 2 === 0
|
||||
? themeWiseColor('#fafafa', '#232323', themeMode)
|
||||
: themeWiseColor('#ffffff', '#181818', themeMode);
|
||||
const hoverBg = themeMode === 'dark' ? 'rgba(64, 169, 255, 0.08)' : 'rgba(24, 144, 255, 0.04)';
|
||||
|
||||
elements.push(
|
||||
<tr
|
||||
key={task.id}
|
||||
style={{
|
||||
height: 40,
|
||||
background: isHovered ? hoverBg : defaultBg,
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
className={`finance-table-task-row ${level > 0 ? 'finance-table-nested-task' : ''} ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
onMouseEnter={() => setHoveredTaskId(task.id)}
|
||||
onMouseLeave={() => setHoveredTaskId(null)}
|
||||
>
|
||||
{activeColumns.map(col => (
|
||||
<td
|
||||
key={`${task.id}-${col.key}`}
|
||||
style={{
|
||||
width: col.width,
|
||||
paddingInline: 16,
|
||||
textAlign: col.type === 'string' ? 'left' : 'right',
|
||||
backgroundColor:
|
||||
col.key === FinanceTableColumnKeys.TASK ||
|
||||
col.key === FinanceTableColumnKeys.MEMBERS
|
||||
? isHovered
|
||||
? hoverBg
|
||||
: defaultBg
|
||||
: isHovered
|
||||
? hoverBg
|
||||
: 'transparent',
|
||||
cursor: 'default',
|
||||
}}
|
||||
className={customColumnStyles(col.key)}
|
||||
onClick={
|
||||
col.key === FinanceTableColumnKeys.FIXED_COST ? e => e.stopPropagation() : undefined
|
||||
}
|
||||
>
|
||||
{renderFinancialTableColumnContent(col.key, task, level)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
|
||||
// Add subtasks recursively if they are expanded and loaded
|
||||
if (task.show_sub_tasks && task.sub_tasks) {
|
||||
task.sub_tasks.forEach(subTask => {
|
||||
elements.push(...renderTaskHierarchy(subTask, level + 1));
|
||||
});
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
const renderFinancialTableColumnContent = (
|
||||
columnKey: FinanceTableColumnKeys,
|
||||
task: IProjectFinanceTask,
|
||||
level: number = 0
|
||||
) => {
|
||||
switch (columnKey) {
|
||||
case FinanceTableColumnKeys.TASK:
|
||||
return (
|
||||
<Tooltip title={task.name}>
|
||||
<Flex gap={8} align="center" style={{ paddingLeft: getTaskIndentation(level) }}>
|
||||
{/* Expand/collapse icon for parent tasks */}
|
||||
{task.sub_tasks_count > 0 && (
|
||||
<div
|
||||
className="finance-table-expand-btn"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
width: 18,
|
||||
height: 18,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleTaskExpansion(task);
|
||||
}}
|
||||
>
|
||||
{task.show_sub_tasks ? (
|
||||
<DownOutlined style={{ fontSize: 12 }} />
|
||||
) : (
|
||||
<RightOutlined style={{ fontSize: 12 }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacer for tasks without subtasks to align with those that have expand icons */}
|
||||
{task.sub_tasks_count === 0 && level > 0 && (
|
||||
<div style={{ width: 18, height: 18, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
{/* Task name */}
|
||||
<Typography.Text
|
||||
className="finance-table-task-name"
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
maxWidth: Math.max(
|
||||
100,
|
||||
200 - getTaskIndentation(level) - (task.sub_tasks_count > 0 ? 26 : 18)
|
||||
),
|
||||
cursor: 'pointer',
|
||||
color: '#1890ff',
|
||||
fontSize: Math.max(12, 14 - level * 0.3), // Slightly smaller font for deeper levels
|
||||
opacity: Math.max(0.85, 1 - level * 0.03), // Slightly faded for deeper levels
|
||||
fontWeight: level > 0 ? 400 : 500, // Slightly lighter weight for nested tasks
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleTaskNameClick(task.id);
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.textDecoration = 'underline';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.textDecoration = 'none';
|
||||
}}
|
||||
>
|
||||
{task.name}
|
||||
</Typography.Text>
|
||||
{task.billable && <DollarCircleOutlined style={{ fontSize: 12, flexShrink: 0 }} />}
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
);
|
||||
case FinanceTableColumnKeys.MEMBERS:
|
||||
return (
|
||||
task.members && (
|
||||
<div
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onTaskClick(task);
|
||||
}}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Avatars
|
||||
members={task.members.map(member => ({
|
||||
...member,
|
||||
avatar_url: member.avatar_url || undefined,
|
||||
}))}
|
||||
allowClickThrough={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
case FinanceTableColumnKeys.HOURS:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>
|
||||
{task.estimated_hours}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.MAN_DAYS:
|
||||
// Backend now provides correct recursive aggregation for parent tasks
|
||||
const taskManDays =
|
||||
task.total_minutes > 0
|
||||
? task.total_minutes / 60 / (hoursPerDay || 8)
|
||||
: task.estimated_seconds / 3600 / (hoursPerDay || 8);
|
||||
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>
|
||||
{formatManDays(taskManDays, 1, hoursPerDay)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.TOTAL_TIME_LOGGED:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>
|
||||
{task.total_time_logged}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.ESTIMATED_COST:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>
|
||||
{formatNumber(task.estimated_cost)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.FIXED_COST:
|
||||
// Parent tasks with subtasks should not be editable - they aggregate from subtasks
|
||||
const isParentTask = task.sub_tasks_count > 0;
|
||||
const canEditThisTask = hasEditPermission && !isParentTask;
|
||||
|
||||
return selectedTask?.id === task.id && canEditThisTask ? (
|
||||
<InputNumber
|
||||
value={editingFixedCostValue !== null ? editingFixedCostValue : task.fixed_cost}
|
||||
onChange={value => {
|
||||
setEditingFixedCostValue(value);
|
||||
// Trigger debounced save for up/down arrow clicks
|
||||
debouncedSaveFixedCost(value, task.id);
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Immediate save on blur
|
||||
immediateSaveFixedCost(editingFixedCostValue, task.id);
|
||||
}}
|
||||
onPressEnter={() => {
|
||||
// Immediate save on enter
|
||||
immediateSaveFixedCost(editingFixedCostValue, task.id);
|
||||
}}
|
||||
onFocus={e => {
|
||||
// Select all text when input is focused
|
||||
e.target.select();
|
||||
}}
|
||||
autoFocus
|
||||
style={{ width: '100%', textAlign: 'right', fontSize: Math.max(12, 14 - level * 0.5) }}
|
||||
formatter={value => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||
parser={value => Number(value!.replace(/\$\s?|(,*)/g, ''))}
|
||||
min={0}
|
||||
precision={2}
|
||||
className="fixed-cost-input"
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text
|
||||
style={{
|
||||
cursor: canEditThisTask ? 'pointer' : 'default',
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
opacity: canEditThisTask ? 1 : 0.7,
|
||||
fontSize: Math.max(12, 14 - level * 0.5),
|
||||
fontStyle: isParentTask ? 'italic' : 'normal',
|
||||
color: isParentTask ? (themeMode === 'dark' ? '#888' : '#666') : 'inherit',
|
||||
}}
|
||||
onClick={
|
||||
canEditThisTask
|
||||
? e => {
|
||||
e.stopPropagation();
|
||||
setSelectedTask(task);
|
||||
setEditingFixedCostValue(task.fixed_cost);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={isParentTask ? 'Fixed cost is calculated from subtasks' : undefined}
|
||||
>
|
||||
{formatNumber(task.fixed_cost)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.VARIANCE:
|
||||
// Calculate variance as Budget - Actual (positive = under budget = good)
|
||||
const varianceBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0);
|
||||
const varianceActual = (task.actual_cost_from_logs || 0) + (task.fixed_cost || 0);
|
||||
const taskVariance = varianceBudget - varianceActual;
|
||||
return (
|
||||
<Typography.Text
|
||||
style={{
|
||||
color: taskVariance < 0 ? '#d32f2f' : '#2e7d32',
|
||||
fontSize: Math.max(12, 14 - level * 0.5),
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{formatNumber(taskVariance)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.TOTAL_BUDGET:
|
||||
const taskTotalBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0);
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>
|
||||
{formatNumber(taskTotalBudget)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.TOTAL_ACTUAL:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>
|
||||
{formatNumber(task.total_actual || 0)}
|
||||
</Typography.Text>
|
||||
);
|
||||
case FinanceTableColumnKeys.COST:
|
||||
return (
|
||||
<Typography.Text style={{ fontSize: Math.max(12, 14 - level * 0.5) }}>
|
||||
{formatNumber(task.actual_cost_from_logs || 0)}
|
||||
</Typography.Text>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Utility function to format seconds to time string
|
||||
const formatSecondsToTimeString = (totalSeconds: number): string => {
|
||||
if (!totalSeconds || totalSeconds === 0) return '0s';
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const parts = [];
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
// Generate flattened task list with all nested levels
|
||||
const flattenedTasks = useMemo(() => {
|
||||
const flattened: React.ReactElement[] = [];
|
||||
|
||||
tasks.forEach(task => {
|
||||
flattened.push(...renderTaskHierarchy(task, 0));
|
||||
});
|
||||
|
||||
return flattened;
|
||||
}, [tasks, selectedTask, editingFixedCostValue, hasEditPermission, themeMode, hoveredTaskId]);
|
||||
|
||||
// Calculate totals for the current table - backend provides correct aggregated values
|
||||
const totals = useMemo(() => {
|
||||
const calculateTaskTotals = (taskList: IProjectFinanceTask[]): any => {
|
||||
let totals = {
|
||||
hours: 0,
|
||||
man_days: 0,
|
||||
total_time_logged: 0,
|
||||
estimated_cost: 0,
|
||||
actual_cost_from_logs: 0,
|
||||
fixed_cost: 0,
|
||||
total_budget: 0,
|
||||
total_actual: 0,
|
||||
variance: 0,
|
||||
};
|
||||
|
||||
for (const task of taskList) {
|
||||
if (task.sub_tasks && task.sub_tasks.length > 0) {
|
||||
// Parent task with loaded subtasks - only count subtasks recursively
|
||||
const subtaskTotals = calculateTaskTotals(task.sub_tasks);
|
||||
totals.hours += subtaskTotals.hours;
|
||||
totals.man_days += subtaskTotals.man_days;
|
||||
totals.total_time_logged += subtaskTotals.total_time_logged;
|
||||
totals.estimated_cost += subtaskTotals.estimated_cost;
|
||||
totals.actual_cost_from_logs += subtaskTotals.actual_cost_from_logs;
|
||||
totals.fixed_cost += subtaskTotals.fixed_cost;
|
||||
totals.total_budget += subtaskTotals.total_budget;
|
||||
totals.total_actual += subtaskTotals.total_actual;
|
||||
totals.variance += subtaskTotals.variance;
|
||||
} else {
|
||||
// Leaf task or parent task without loaded subtasks - use backend aggregated values
|
||||
const leafTotalActual = task.total_actual || 0;
|
||||
const leafTotalBudget = task.total_budget || 0;
|
||||
totals.hours += task.estimated_seconds || 0;
|
||||
// Use same calculation as individual task display - backend provides correct values
|
||||
const taskManDays =
|
||||
task.total_minutes > 0
|
||||
? task.total_minutes / 60 / (hoursPerDay || 8)
|
||||
: task.estimated_seconds / 3600 / (hoursPerDay || 8);
|
||||
totals.man_days += taskManDays;
|
||||
totals.total_time_logged += task.total_time_logged_seconds || 0;
|
||||
totals.estimated_cost += task.estimated_cost || 0;
|
||||
totals.actual_cost_from_logs += task.actual_cost_from_logs || 0;
|
||||
totals.fixed_cost += task.fixed_cost || 0;
|
||||
totals.total_budget += leafTotalBudget;
|
||||
totals.total_actual += leafTotalActual;
|
||||
totals.variance += leafTotalBudget - leafTotalActual;
|
||||
}
|
||||
}
|
||||
|
||||
return totals;
|
||||
};
|
||||
|
||||
return calculateTaskTotals(tasks);
|
||||
}, [tasks, hoursPerDay]);
|
||||
|
||||
// Format the totals for display
|
||||
const formattedTotals = useMemo(
|
||||
() => ({
|
||||
hours: formatSecondsToTimeString(totals.hours),
|
||||
man_days: totals.man_days,
|
||||
total_time_logged: formatSecondsToTimeString(totals.total_time_logged),
|
||||
estimated_cost: totals.estimated_cost,
|
||||
actual_cost_from_logs: totals.actual_cost_from_logs,
|
||||
fixed_cost: totals.fixed_cost,
|
||||
total_budget: totals.total_budget,
|
||||
total_actual: totals.total_actual,
|
||||
variance: totals.variance,
|
||||
}),
|
||||
[totals]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={activeColumns.length}>
|
||||
<Skeleton active />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* header row */}
|
||||
<tr
|
||||
style={{
|
||||
height: 40,
|
||||
backgroundColor: themeWiseColor(table.color_code, table.color_code_dark, themeMode),
|
||||
fontWeight: 600,
|
||||
}}
|
||||
className={`group ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
>
|
||||
{activeColumns.map((col, index) => (
|
||||
<td
|
||||
key={`header-${col.key}`}
|
||||
style={{
|
||||
width: col.width,
|
||||
paddingInline: 16,
|
||||
textAlign:
|
||||
col.key === FinanceTableColumnKeys.TASK ||
|
||||
col.key === FinanceTableColumnKeys.MEMBERS
|
||||
? 'left'
|
||||
: 'right',
|
||||
backgroundColor: themeWiseColor(table.color_code, table.color_code_dark, themeMode),
|
||||
cursor: col.key === FinanceTableColumnKeys.TASK ? 'pointer' : 'default',
|
||||
textTransform: col.key === FinanceTableColumnKeys.TASK ? 'capitalize' : 'none',
|
||||
}}
|
||||
className={customHeaderColumnStyles(col.key)}
|
||||
onClick={
|
||||
col.key === FinanceTableColumnKeys.TASK
|
||||
? () => setIsCollapse(prev => !prev)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{col.key === FinanceTableColumnKeys.TASK ? (
|
||||
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
|
||||
{isCollapse ? <RightOutlined /> : <DownOutlined />}
|
||||
{table.group_name} ({tasks.length})
|
||||
</Flex>
|
||||
) : col.key === FinanceTableColumnKeys.MEMBERS ? null : (
|
||||
renderFinancialTableHeaderContent(col.key)
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* task rows with recursive hierarchy */}
|
||||
{!isCollapse && flattenedTasks}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinanceTable;
|
||||
@@ -0,0 +1,63 @@
|
||||
/* Finance Table Styles */
|
||||
|
||||
/* Enhanced hierarchy visual indicators */
|
||||
.finance-table-task-row {
|
||||
transition: all 0.2s ease-in-out;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.dark .finance-table-task-row {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Hover effect is now handled by inline styles in the component for consistency */
|
||||
|
||||
/* Nested task styling */
|
||||
.finance-table-nested-task {
|
||||
/* No visual connectors, just clean indentation */
|
||||
}
|
||||
|
||||
/* Expand/collapse button styling */
|
||||
.finance-table-expand-btn {
|
||||
transition: all 0.2s ease-in-out;
|
||||
border-radius: 2px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.finance-table-expand-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.dark .finance-table-expand-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Task name styling for different levels */
|
||||
.finance-table-task-name {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.finance-table-task-name:hover {
|
||||
color: #40a9ff !important;
|
||||
}
|
||||
|
||||
/* Fixed cost input styling */
|
||||
.fixed-cost-input {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fixed-cost-input:focus {
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Responsive adjustments for nested content */
|
||||
@media (max-width: 768px) {
|
||||
.finance-table-nested-task {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.finance-table-task-name {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import {
|
||||
Drawer,
|
||||
Select,
|
||||
Typography,
|
||||
Flex,
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
Tooltip,
|
||||
Alert,
|
||||
Space,
|
||||
message,
|
||||
Popconfirm,
|
||||
DeleteOutlined,
|
||||
ExclamationCircleFilled,
|
||||
PlusOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
deleteRateCard,
|
||||
fetchRateCardById,
|
||||
fetchRateCards,
|
||||
toggleRatecardDrawer,
|
||||
updateRateCard,
|
||||
} from '@/features/finance/finance-slice';
|
||||
import { RatecardType, IJobType } from '@/types/project/ratecard.types';
|
||||
import { IJobTitlesViewModel } from '@/types/job.types';
|
||||
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import { colors } from '@/styles/colors';
|
||||
import CreateJobTitlesDrawer from '@/features/settings/job/CreateJobTitlesDrawer';
|
||||
import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/currencies';
|
||||
import { IOrganization } from '@/types/admin-center/admin-center.types';
|
||||
|
||||
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[]>([]);
|
||||
const [roles, setRoles] = useState<IJobType[]>([]);
|
||||
const [initialRoles, setInitialRoles] = useState<IJobType[]>([]);
|
||||
const [initialName, setInitialName] = useState<string>('Untitled Rate Card');
|
||||
const [initialCurrency, setInitialCurrency] = useState<string>(DEFAULT_CURRENCY);
|
||||
const [addingRowIndex, setAddingRowIndex] = useState<number | null>(null);
|
||||
const [organization, setOrganization] = useState<IOrganization | null>(null);
|
||||
const { t } = useTranslation('settings/ratecard-settings');
|
||||
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(DEFAULT_CURRENCY);
|
||||
const [name, setName] = useState<string>('Untitled Rate Card');
|
||||
const [jobTitles, setJobTitles] = useState<IJobTitlesViewModel>({});
|
||||
const [pagination, setPagination] = useState<PaginationType>({
|
||||
current: 1,
|
||||
pageSize: 10000,
|
||||
field: 'name',
|
||||
order: 'desc',
|
||||
total: 0,
|
||||
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
|
||||
size: 'small',
|
||||
});
|
||||
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
||||
const [showUnsavedAlert, setShowUnsavedAlert] = useState(false);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [isCreatingJobTitle, setIsCreatingJobTitle] = useState(false);
|
||||
const [newJobTitleName, setNewJobTitleName] = useState('');
|
||||
|
||||
// Determine if we're using man days calculation method
|
||||
const isManDaysMethod = organization?.calculation_method === 'man_days';
|
||||
|
||||
// Detect changes
|
||||
const hasChanges = useMemo(() => {
|
||||
const rolesChanged = JSON.stringify(roles) !== JSON.stringify(initialRoles);
|
||||
const nameChanged = name !== initialName;
|
||||
const currencyChanged = currency !== initialCurrency;
|
||||
return rolesChanged || nameChanged || currencyChanged;
|
||||
}, [roles, name, currency, initialRoles, initialName, initialCurrency]);
|
||||
|
||||
// Fetch organization details
|
||||
useEffect(() => {
|
||||
const fetchOrganization = async () => {
|
||||
try {
|
||||
const response = await adminCenterApiService.getOrganizationDetails();
|
||||
if (response.done) {
|
||||
setOrganization(response.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch organization details:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isDrawerOpen) {
|
||||
fetchOrganization();
|
||||
}
|
||||
}, [isDrawerOpen]);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
getJobTitles();
|
||||
}, []);
|
||||
|
||||
const selectedRatecard = ratecardsList.find(ratecard => ratecard.id === ratecardId);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'update' && ratecardId) {
|
||||
dispatch(fetchRateCardById(ratecardId));
|
||||
}
|
||||
}, [type, ratecardId, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'update' && drawerRatecard) {
|
||||
setRoles(drawerRatecard.jobRolesList || []);
|
||||
setInitialRoles(drawerRatecard.jobRolesList || []);
|
||||
setName(drawerRatecard.name || '');
|
||||
setInitialName(drawerRatecard.name || '');
|
||||
setCurrency(drawerRatecard.currency || DEFAULT_CURRENCY);
|
||||
setInitialCurrency(drawerRatecard.currency || DEFAULT_CURRENCY);
|
||||
}
|
||||
}, [drawerRatecard, type]);
|
||||
|
||||
const handleAddAllRoles = () => {
|
||||
if (!jobTitles.data) return;
|
||||
const existingIds = new Set(roles.map(r => r.job_title_id));
|
||||
const newRoles = jobTitles.data
|
||||
.filter(jt => jt.id && !existingIds.has(jt.id))
|
||||
.map(jt => ({
|
||||
jobtitle: jt.name,
|
||||
rate_card_id: ratecardId,
|
||||
job_title_id: jt.id || '',
|
||||
rate: 0,
|
||||
man_day_rate: 0,
|
||||
}));
|
||||
const mergedRoles = [...roles, ...newRoles].filter(
|
||||
(role, idx, arr) => arr.findIndex(r => r.job_title_id === role.job_title_id) === idx
|
||||
);
|
||||
setRoles(mergedRoles);
|
||||
};
|
||||
|
||||
const handleAddRole = () => {
|
||||
if (Object.keys(jobTitles).length === 0) {
|
||||
// Allow inline job title creation
|
||||
setIsCreatingJobTitle(true);
|
||||
} else {
|
||||
// Add a new empty role to the table
|
||||
const newRole = {
|
||||
jobtitle: '',
|
||||
rate_card_id: ratecardId,
|
||||
job_title_id: '',
|
||||
rate: 0,
|
||||
man_day_rate: 0,
|
||||
};
|
||||
setRoles([...roles, newRole]);
|
||||
setAddingRowIndex(roles.length);
|
||||
setIsAddingRole(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateJobTitle = async () => {
|
||||
if (!newJobTitleName.trim()) {
|
||||
messageApi.warning(t('jobTitleNameRequired') || 'Job title name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the job title using the API
|
||||
const response = await jobTitlesApiService.createJobTitle({
|
||||
name: newJobTitleName.trim(),
|
||||
});
|
||||
|
||||
if (response.done) {
|
||||
// Refresh job titles
|
||||
await getJobTitles();
|
||||
|
||||
// Create a new role with the newly created job title
|
||||
const newRole = {
|
||||
jobtitle: newJobTitleName.trim(),
|
||||
rate_card_id: ratecardId,
|
||||
job_title_id: response.body.id,
|
||||
rate: 0,
|
||||
man_day_rate: 0,
|
||||
};
|
||||
setRoles([...roles, newRole]);
|
||||
|
||||
// Reset creation state
|
||||
setIsCreatingJobTitle(false);
|
||||
setNewJobTitleName('');
|
||||
|
||||
messageApi.success(t('jobTitleCreatedSuccess') || 'Job title created successfully');
|
||||
} else {
|
||||
messageApi.error(t('jobTitleCreateError') || 'Failed to create job title');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create job title:', error);
|
||||
messageApi.error(t('jobTitleCreateError') || 'Failed to create job title');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelJobTitleCreation = () => {
|
||||
setIsCreatingJobTitle(false);
|
||||
setNewJobTitleName('');
|
||||
};
|
||||
|
||||
const handleDeleteRole = (index: number) => {
|
||||
const updatedRoles = [...roles];
|
||||
updatedRoles.splice(index, 1);
|
||||
setRoles(updatedRoles);
|
||||
};
|
||||
|
||||
const handleSelectJobTitle = (jobTitleId: string) => {
|
||||
if (roles.some(role => role.job_title_id === jobTitleId)) {
|
||||
setIsAddingRole(false);
|
||||
setSelectedJobTitleId(undefined);
|
||||
return;
|
||||
}
|
||||
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,
|
||||
man_day_rate: 0,
|
||||
};
|
||||
setRoles([...roles, newRole]);
|
||||
}
|
||||
setIsAddingRole(false);
|
||||
setSelectedJobTitleId(undefined);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (type === 'update' && ratecardId) {
|
||||
try {
|
||||
const filteredRoles = roles.filter(role => role.jobtitle && role.jobtitle.trim() !== '');
|
||||
await dispatch(
|
||||
updateRateCard({
|
||||
id: ratecardId,
|
||||
body: {
|
||||
name,
|
||||
currency,
|
||||
jobRolesList: filteredRoles,
|
||||
},
|
||||
}) as any
|
||||
);
|
||||
await dispatch(
|
||||
fetchRateCards({
|
||||
index: 1,
|
||||
size: 10,
|
||||
field: 'name',
|
||||
order: 'desc',
|
||||
search: '',
|
||||
}) as any
|
||||
);
|
||||
if (onSaved) onSaved();
|
||||
dispatch(toggleRatecardDrawer());
|
||||
// Reset initial states after save
|
||||
setInitialRoles(filteredRoles);
|
||||
setInitialName(name);
|
||||
setInitialCurrency(currency);
|
||||
setShowUnsavedAlert(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to update rate card', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('jobTitleColumn'),
|
||||
dataIndex: 'jobtitle',
|
||||
render: (text: string, record: any, index: number) => {
|
||||
if (index === addingRowIndex || index === editingRowIndex) {
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
autoFocus
|
||||
placeholder={t('selectJobTitle')}
|
||||
style={{ minWidth: 150 }}
|
||||
value={record.job_title_id || undefined}
|
||||
onChange={value => {
|
||||
if (roles.some((role, idx) => role.job_title_id === value && idx !== index)) {
|
||||
return;
|
||||
}
|
||||
const updatedRoles = [...roles];
|
||||
const selectedJob = jobTitles.data?.find(jt => jt.id === value);
|
||||
updatedRoles[index].job_title_id = value;
|
||||
updatedRoles[index].jobtitle = selectedJob?.name || '';
|
||||
setRoles(updatedRoles);
|
||||
setEditingRowIndex(null);
|
||||
setAddingRowIndex(null);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (roles[index].job_title_id === '') {
|
||||
handleDeleteRole(index);
|
||||
}
|
||||
setEditingRowIndex(null);
|
||||
setAddingRowIndex(null);
|
||||
}}
|
||||
filterOption={(input, option) =>
|
||||
String(option?.children || '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
>
|
||||
{jobTitles.data
|
||||
?.filter(
|
||||
jt => !roles.some((role, idx) => role.job_title_id === jt.id && idx !== index)
|
||||
)
|
||||
.map(jt => (
|
||||
<Select.Option key={jt.id} value={jt.id}>
|
||||
{jt.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
return <span style={{ cursor: 'pointer' }}>{record.jobtitle}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: isManDaysMethod
|
||||
? `${t('ratePerManDayColumn', { ns: 'project-view-finance' }) || 'Rate per day'} (${currency})`
|
||||
: `${t('ratePerHourColumn')} (${currency})`,
|
||||
dataIndex: isManDaysMethod ? 'man_day_rate' : 'rate',
|
||||
align: 'right' as const,
|
||||
render: (text: number, record: any, index: number) => (
|
||||
<Input
|
||||
type="number"
|
||||
value={isManDaysMethod ? (roles[index]?.man_day_rate ?? 0) : (roles[index]?.rate ?? 0)}
|
||||
min={0}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textAlign: 'right',
|
||||
padding: 0,
|
||||
}}
|
||||
onChange={e => {
|
||||
const newValue = parseInt(e.target.value, 10) || 0;
|
||||
const updatedRoles = roles.map((role, idx) =>
|
||||
idx === index
|
||||
? {
|
||||
...role,
|
||||
...(isManDaysMethod ? { man_day_rate: newValue } : { rate: newValue }),
|
||||
}
|
||||
: role
|
||||
);
|
||||
setRoles(updatedRoles);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('actionsColumn') || 'Actions',
|
||||
dataIndex: 'actions',
|
||||
render: (_: any, __: any, index: number) => (
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={async () => {
|
||||
handleDeleteRole(index);
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Delete">
|
||||
<Button size="small" icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleDrawerClose = async () => {
|
||||
if (!name || name.trim() === '') {
|
||||
messageApi.open({
|
||||
type: 'warning',
|
||||
content: t('ratecardNameRequired') || 'Rate card name is required.',
|
||||
});
|
||||
return;
|
||||
} else if (hasChanges) {
|
||||
setShowUnsavedAlert(true);
|
||||
} else if (name === 'Untitled Rate Card' && roles.length === 0) {
|
||||
await dispatch(deleteRateCard(ratecardId));
|
||||
dispatch(toggleRatecardDrawer());
|
||||
} else {
|
||||
dispatch(toggleRatecardDrawer());
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmSave = async () => {
|
||||
await handleSave();
|
||||
setShowUnsavedAlert(false);
|
||||
};
|
||||
|
||||
const handleConfirmDiscard = () => {
|
||||
dispatch(toggleRatecardDrawer());
|
||||
setRoles([]);
|
||||
setName('Untitled Rate Card');
|
||||
setCurrency(DEFAULT_CURRENCY);
|
||||
setInitialRoles([]);
|
||||
setInitialName('Untitled Rate Card');
|
||||
setInitialCurrency(DEFAULT_CURRENCY);
|
||||
setShowUnsavedAlert(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Drawer
|
||||
loading={drawerLoading}
|
||||
onClose={handleDrawerClose}
|
||||
title={
|
||||
<Flex align="center" justify="space-between">
|
||||
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder={t('ratecardNamePlaceholder')}
|
||||
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={CURRENCY_OPTIONS}
|
||||
onChange={value => setCurrency(value)}
|
||||
/>
|
||||
<Button onClick={handleAddAllRoles} type="default">
|
||||
{t('addAllButton')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
}
|
||||
open={isDrawerOpen}
|
||||
width={700}
|
||||
footer={
|
||||
<Flex justify="end" gap={16} style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
style={{ marginBottom: 24 }}
|
||||
onClick={handleSave}
|
||||
type="primary"
|
||||
disabled={name === '' || (name === 'Untitled Rate Card' && roles.length === 0)}
|
||||
>
|
||||
{t('saveButton')}
|
||||
</Button>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
{showUnsavedAlert && (
|
||||
<Alert
|
||||
message={t('unsavedChangesTitle') || 'Unsaved Changes'}
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setShowUnsavedAlert(false)}
|
||||
action={
|
||||
<Space direction="horizontal">
|
||||
<Button size="small" type="primary" onClick={handleConfirmSave}>
|
||||
Save
|
||||
</Button>
|
||||
<Button size="small" danger onClick={handleConfirmDiscard}>
|
||||
Discard
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
<Flex vertical gap={16}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{t('jobRolesTitle') || 'Job Roles'}
|
||||
</Typography.Title>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddRole}>
|
||||
{t('addRoleButton')}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Table
|
||||
dataSource={roles}
|
||||
columns={columns}
|
||||
rowKey="job_title_id"
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: isCreatingJobTitle ? (
|
||||
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
|
||||
<Typography.Text strong>
|
||||
{t('createNewJobTitle') || 'Create New Job Title'}
|
||||
</Typography.Text>
|
||||
<Flex gap={8} align="center">
|
||||
<Input
|
||||
placeholder={t('jobTitleNamePlaceholder') || 'Enter job title name'}
|
||||
value={newJobTitleName}
|
||||
onChange={e => setNewJobTitleName(e.target.value)}
|
||||
onPressEnter={handleCreateJobTitle}
|
||||
autoFocus
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Button type="primary" onClick={handleCreateJobTitle}>
|
||||
{t('createButton') || 'Create'}
|
||||
</Button>
|
||||
<Button onClick={handleCancelJobTitleCreation}>
|
||||
{t('cancelButton') || 'Cancel'}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
|
||||
<Typography.Text type="secondary">
|
||||
{Object.keys(jobTitles).length === 0
|
||||
? t('noJobTitlesAvailable')
|
||||
: t('noRolesAdded')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{organization && (
|
||||
<Alert
|
||||
message={
|
||||
isManDaysMethod
|
||||
? `Organization is using man days calculation (${organization.hours_per_day || 8}h/day). Rates above represent daily rates.`
|
||||
: 'Organization is using hourly calculation. Rates above represent hourly rates.'
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Drawer>
|
||||
<CreateJobTitlesDrawer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RateCardDrawer;
|
||||
@@ -0,0 +1,425 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Table,
|
||||
TableProps,
|
||||
Select,
|
||||
Flex,
|
||||
InputRef,
|
||||
DeleteOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CustomAvatar from '@/components/CustomAvatar';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { JobRoleType, RatecardType } from '@/types/project/ratecard.types';
|
||||
import {
|
||||
assignMemberToRateCardRole,
|
||||
deleteProjectRateCardRoleById,
|
||||
fetchProjectRateCardRoles,
|
||||
insertProjectRateCardRole,
|
||||
updateProjectRateCardRoleById,
|
||||
updateProjectRateCardRolesByProjectId,
|
||||
} from '@/features/finance/project-finance-slice';
|
||||
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
|
||||
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||
import { IProjectMemberViewModel } from '@/types/projectMember.types';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { canEditRateCard, canAddMembersToRateCard } from '@/utils/finance-permissions';
|
||||
import RateCardAssigneeSelector from '../../project-ratecard/RateCardAssigneeSelector';
|
||||
|
||||
const RateCardTable: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('project-view-finance');
|
||||
const { projectId } = useParams();
|
||||
|
||||
// Redux state
|
||||
const rolesRedux =
|
||||
useAppSelector(state => state.projectFinanceRateCardReducer.rateCardRoles) || [];
|
||||
const isLoading = useAppSelector(state => state.projectFinanceRateCardReducer.isLoading);
|
||||
const currency = useAppSelector(
|
||||
state => state.projectFinancesReducer.project?.currency || 'USD'
|
||||
).toUpperCase();
|
||||
const financeProject = useAppSelector(state => state.projectFinancesReducer.project);
|
||||
|
||||
// Get calculation method from project finance data
|
||||
const calculationMethod = financeProject?.calculation_method || 'hourly';
|
||||
const rateInputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
// Auth and permissions
|
||||
const auth = useAuthService();
|
||||
const currentSession = auth.getCurrentSession();
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
const hasEditPermission = canEditRateCard(currentSession, project);
|
||||
const canAddMembers = canAddMembersToRateCard(currentSession, project);
|
||||
|
||||
// Local state for editing
|
||||
const [roles, setRoles] = useState<JobRoleType[]>(rolesRedux);
|
||||
const [addingRow, setAddingRow] = useState<boolean>(false);
|
||||
const [jobTitles, setJobTitles] = useState<RatecardType[]>([]);
|
||||
const [members, setMembers] = useState<IProjectMemberViewModel[]>([]);
|
||||
const [isLoadingMembers, setIsLoading] = useState(false);
|
||||
const [focusRateIndex, setFocusRateIndex] = useState<number | null>(null);
|
||||
|
||||
const pagination = {
|
||||
current: 1,
|
||||
pageSize: 1000,
|
||||
field: 'name',
|
||||
order: 'asc',
|
||||
};
|
||||
|
||||
const getProjectMembers = async () => {
|
||||
if (!projectId) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await projectsApiService.getMembers(
|
||||
projectId,
|
||||
pagination.current,
|
||||
pagination.pageSize,
|
||||
pagination.field,
|
||||
pagination.order,
|
||||
null
|
||||
);
|
||||
if (res.done) {
|
||||
setMembers(res.body?.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching members:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getProjectMembers();
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch job titles for selection
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await jobTitlesApiService.getJobTitles(1, 1000, 'name', 'asc', '');
|
||||
setJobTitles(res.body?.data || []);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Sync local roles with redux roles
|
||||
useEffect(() => {
|
||||
setRoles(rolesRedux);
|
||||
}, [rolesRedux]);
|
||||
|
||||
// Fetch roles on mount
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
dispatch(fetchProjectRateCardRoles(projectId));
|
||||
}
|
||||
}, [dispatch, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRateIndex !== null && rateInputRefs.current[focusRateIndex]) {
|
||||
rateInputRefs.current[focusRateIndex]?.focus();
|
||||
setFocusRateIndex(null);
|
||||
}
|
||||
}, [roles, focusRateIndex]);
|
||||
|
||||
// Add new role row
|
||||
const handleAddRole = () => {
|
||||
setAddingRow(true);
|
||||
};
|
||||
|
||||
// Save all roles (bulk update)
|
||||
const handleSaveAll = () => {
|
||||
if (projectId) {
|
||||
const filteredRoles = roles
|
||||
.filter(
|
||||
r => typeof r.job_title_id === 'string' && r.job_title_id && typeof r.rate !== 'undefined'
|
||||
)
|
||||
.map(r => ({
|
||||
job_title_id: r.job_title_id as string,
|
||||
jobtitle: r.jobtitle || r.name || '',
|
||||
rate: Number(r.rate ?? 0),
|
||||
man_day_rate: Number(r.man_day_rate ?? 0),
|
||||
}));
|
||||
dispatch(
|
||||
updateProjectRateCardRolesByProjectId({ project_id: projectId, roles: filteredRoles })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// In handleSelectJobTitle, after successful insert, update the rate if needed
|
||||
const handleSelectJobTitle = async (jobTitleId: string) => {
|
||||
const jobTitle = jobTitles.find(jt => jt.id === jobTitleId);
|
||||
if (!jobTitle || !projectId) return;
|
||||
if (roles.some(r => r.job_title_id === jobTitleId)) return;
|
||||
|
||||
// Set the appropriate rate based on calculation method
|
||||
const isManDays = calculationMethod === 'man_days';
|
||||
const resultAction = await dispatch(
|
||||
insertProjectRateCardRole({
|
||||
project_id: projectId,
|
||||
job_title_id: jobTitleId,
|
||||
rate: 0, // Always initialize rate as 0
|
||||
man_day_rate: isManDays ? 0 : undefined, // Only set man_day_rate for man_days mode
|
||||
})
|
||||
);
|
||||
|
||||
if (insertProjectRateCardRole.fulfilled.match(resultAction)) {
|
||||
// Re-fetch roles and focus the last one (newly added)
|
||||
dispatch(fetchProjectRateCardRoles(projectId)).then(() => {
|
||||
setFocusRateIndex(roles.length); // The new row will be at the end
|
||||
});
|
||||
}
|
||||
setAddingRow(false);
|
||||
};
|
||||
|
||||
// Update handleRateChange to update the correct field
|
||||
const handleRateChange = (value: string | number, index: number) => {
|
||||
setRoles(prev =>
|
||||
prev.map((role, idx) =>
|
||||
idx === index
|
||||
? {
|
||||
...role,
|
||||
...(calculationMethod === 'man_days'
|
||||
? { man_day_rate: Number(value) }
|
||||
: { rate: Number(value) }),
|
||||
}
|
||||
: role
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = (record: JobRoleType, index: number) => {
|
||||
if (record.id) {
|
||||
dispatch(deleteProjectRateCardRoleById(record.id));
|
||||
} else {
|
||||
setRoles(roles.filter((_, idx) => idx !== index));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle member change
|
||||
const handleMemberChange = async (memberId: string, rowIndex: number, record: JobRoleType) => {
|
||||
if (!projectId || !record.id) return; // Ensure required IDs are present
|
||||
try {
|
||||
const resultAction = await dispatch(
|
||||
assignMemberToRateCardRole({
|
||||
project_id: projectId,
|
||||
member_id: memberId,
|
||||
project_rate_card_role_id: record.id,
|
||||
})
|
||||
);
|
||||
if (assignMemberToRateCardRole.fulfilled.match(resultAction)) {
|
||||
const updatedMembers = resultAction.payload; // Array of member IDs
|
||||
setRoles(prev =>
|
||||
prev.map((role, idx) => {
|
||||
if (idx !== rowIndex) return role;
|
||||
return { ...role, members: updatedMembers?.members || [] };
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error assigning member:', error);
|
||||
}
|
||||
};
|
||||
// Separate function for updating rate if changed
|
||||
const handleRateBlur = (value: string, index: number) => {
|
||||
const isManDays = calculationMethod === 'man_days';
|
||||
// Compare with Redux value, not local state
|
||||
const reduxRole = rolesRedux[index];
|
||||
const reduxValue = isManDays
|
||||
? String(reduxRole?.man_day_rate ?? 0)
|
||||
: String(reduxRole?.rate ?? 0);
|
||||
if (value !== reduxValue) {
|
||||
const payload = {
|
||||
id: roles[index].id!,
|
||||
body: {
|
||||
job_title_id: String(roles[index].job_title_id),
|
||||
// Only update the field that corresponds to the current calculation method
|
||||
...(isManDays
|
||||
? {
|
||||
rate: String(reduxRole?.rate ?? 0), // Keep existing rate value
|
||||
man_day_rate: String(value), // Update man_day_rate with new value
|
||||
}
|
||||
: {
|
||||
rate: String(value), // Update rate with new value
|
||||
man_day_rate: String(reduxRole?.man_day_rate ?? 0), // Keep existing man_day_rate value
|
||||
}),
|
||||
},
|
||||
};
|
||||
dispatch(updateProjectRateCardRoleById(payload));
|
||||
}
|
||||
};
|
||||
|
||||
const assignedMembers = roles
|
||||
.flatMap(role => role.members || [])
|
||||
.filter((memberId, index, self) => self.indexOf(memberId) === index);
|
||||
|
||||
// Columns
|
||||
const columns: TableProps<JobRoleType>['columns'] = [
|
||||
{
|
||||
title: t('jobTitleColumn'),
|
||||
dataIndex: 'jobtitle',
|
||||
render: (text: string, record: JobRoleType, index: number) => {
|
||||
if (addingRow && index === roles.length) {
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
autoFocus
|
||||
placeholder={t('selectJobTitle')}
|
||||
style={{ minWidth: 150 }}
|
||||
value={record.job_title_id || undefined}
|
||||
onChange={handleSelectJobTitle}
|
||||
onBlur={() => setAddingRow(false)}
|
||||
filterOption={(input, option) =>
|
||||
((option?.children as unknown as string) || '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
>
|
||||
{jobTitles
|
||||
.filter(jt => !roles.some(role => role.job_title_id === jt.id))
|
||||
.map(jt => (
|
||||
<Select.Option key={jt.id} value={jt.id!}>
|
||||
{jt.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
return <span>{text || record.name}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${calculationMethod === 'man_days' ? t('ratePerManDayColumn') : t('ratePerHourColumn')} (${currency})`,
|
||||
dataIndex: 'rate',
|
||||
align: 'right',
|
||||
render: (value: number, record: JobRoleType, index: number) => (
|
||||
<Input
|
||||
ref={(el: InputRef | null) => {
|
||||
if (el) rateInputRefs.current[index] = el as unknown as HTMLInputElement;
|
||||
}}
|
||||
type="number"
|
||||
value={
|
||||
calculationMethod === 'man_days'
|
||||
? (roles[index]?.man_day_rate ?? 0)
|
||||
: (roles[index]?.rate ?? 0)
|
||||
}
|
||||
min={0}
|
||||
disabled={!hasEditPermission}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
padding: 0,
|
||||
width: 80,
|
||||
textAlign: 'right',
|
||||
opacity: hasEditPermission ? 1 : 0.7,
|
||||
cursor: hasEditPermission ? 'text' : 'not-allowed',
|
||||
}}
|
||||
onChange={
|
||||
hasEditPermission
|
||||
? e => handleRateChange((e.target as HTMLInputElement).value, index)
|
||||
: undefined
|
||||
}
|
||||
onBlur={
|
||||
hasEditPermission
|
||||
? e => handleRateBlur((e.target as HTMLInputElement).value, index)
|
||||
: undefined
|
||||
}
|
||||
onPressEnter={
|
||||
hasEditPermission
|
||||
? e => handleRateBlur((e.target as HTMLInputElement).value, index)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('membersColumn'),
|
||||
dataIndex: 'members',
|
||||
render: (memberscol: string[] | null | undefined, record: JobRoleType, index: number) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, position: 'relative' }}>
|
||||
<Avatar.Group>
|
||||
{memberscol?.map((memberId, i) => {
|
||||
const member = members.find(m => m.id === memberId);
|
||||
return member ? (
|
||||
<CustomAvatar key={i} avatarName={member.name || ''} size={26} />
|
||||
) : null;
|
||||
})}
|
||||
</Avatar.Group>
|
||||
{canAddMembers && (
|
||||
<div>
|
||||
<RateCardAssigneeSelector
|
||||
projectId={projectId as string}
|
||||
selectedMemberIds={memberscol || []}
|
||||
onChange={(memberId: string) => handleMemberChange(memberId, index, record)}
|
||||
memberlist={members}
|
||||
assignedMembers={assignedMembers} // Pass assigned members here
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
align: 'center',
|
||||
render: (_: any, record: JobRoleType, index: number) =>
|
||||
hasEditPermission ? (
|
||||
<Popconfirm
|
||||
title={t('deleteConfirm')}
|
||||
onConfirm={() => handleDelete(record, index)}
|
||||
okText={t('yes')}
|
||||
cancelText={t('no')}
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={
|
||||
addingRow
|
||||
? [
|
||||
...roles,
|
||||
{
|
||||
job_title_id: '',
|
||||
jobtitle: '',
|
||||
rate: 0,
|
||||
members: [],
|
||||
},
|
||||
]
|
||||
: roles
|
||||
}
|
||||
columns={columns}
|
||||
rowKey={(record, idx) => record.id || record.job_title_id || String(idx)}
|
||||
pagination={false}
|
||||
loading={isLoading || isLoadingMembers}
|
||||
footer={() => (
|
||||
<Flex gap={0}>
|
||||
{hasEditPermission && (
|
||||
<Button type="dashed" onClick={handleAddRole} style={{ width: 'fit-content' }}>
|
||||
{t('addRoleButton')}
|
||||
</Button>
|
||||
)}
|
||||
{/* <Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSaveAll}
|
||||
disabled={roles.length === 0}
|
||||
>
|
||||
{t('saveButton') || 'Save'}
|
||||
</Button> */}
|
||||
</Flex>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RateCardTable;
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { InputRef } from 'antd/es/input';
|
||||
import Dropdown from 'antd/es/dropdown';
|
||||
import Card from 'antd/es/card';
|
||||
import List from 'antd/es/list';
|
||||
import Input from 'antd/es/input';
|
||||
import Checkbox from 'antd/es/checkbox';
|
||||
import Button from 'antd/es/button';
|
||||
import Empty from 'antd/es/empty';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
import { IProjectMemberViewModel } from '@/types/projectMember.types';
|
||||
|
||||
interface RateCardAssigneeSelectorProps {
|
||||
projectId: string;
|
||||
onChange?: (memberId: string) => void;
|
||||
selectedMemberIds?: string[];
|
||||
memberlist?: IProjectMemberViewModel[];
|
||||
}
|
||||
|
||||
const RateCardAssigneeSelector = ({
|
||||
projectId,
|
||||
onChange,
|
||||
selectedMemberIds = [],
|
||||
memberlist = [],
|
||||
assignedMembers = [], // New prop: List of all assigned member IDs across all job titles
|
||||
}: RateCardAssigneeSelectorProps & { assignedMembers: string[] }) => {
|
||||
const membersInputRef = useRef<InputRef>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [members, setMembers] = useState<IProjectMemberViewModel[]>(memberlist);
|
||||
|
||||
useEffect(() => {
|
||||
setMembers(memberlist);
|
||||
}, [memberlist]);
|
||||
|
||||
const filteredMembers = members.filter(member =>
|
||||
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const dropdownContent = (
|
||||
<Card styles={{ body: { padding: 8 } }}>
|
||||
<Input
|
||||
ref={membersInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder="Search members"
|
||||
/>
|
||||
<List style={{ padding: 0, maxHeight: 200, overflow: 'auto' }}>
|
||||
{filteredMembers.length ? (
|
||||
filteredMembers.map(member => {
|
||||
const isAssignedToAnotherJobTitle =
|
||||
assignedMembers.includes(member.id || '') &&
|
||||
!selectedMemberIds.includes(member.id || ''); // Check if the member is assigned elsewhere
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={member.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
opacity: member.pending_invitation || isAssignedToAnotherJobTitle ? 0.5 : 1,
|
||||
justifyContent: 'flex-start',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedMemberIds.includes(member.id || '')}
|
||||
disabled={member.pending_invitation || isAssignedToAnotherJobTitle}
|
||||
onChange={() => onChange?.(member.id || '')}
|
||||
/>
|
||||
<SingleAvatar
|
||||
avatarUrl={member.avatar_url}
|
||||
name={member.name}
|
||||
email={member.email}
|
||||
/>
|
||||
<span>{member.name}</span>
|
||||
</List.Item>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Empty description="No members found" />
|
||||
)}
|
||||
</List>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-dropdown"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => dropdownContent}
|
||||
onOpenChange={open => {
|
||||
if (open) setTimeout(() => membersInputRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="dashed"
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<PlusOutlined style={{ fontSize: 12 }} />}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default RateCardAssigneeSelector;
|
||||
252
worklenz-frontend/src/features/finance/finance-slice.ts
Normal file
252
worklenz-frontend/src/features/finance/finance-slice.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
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;
|
||||
isRatecardsLoading?: boolean;
|
||||
isFinanceDrawerloading?: boolean;
|
||||
drawerRatecard?: RatecardType | null;
|
||||
ratecardsList?: RatecardType[] | null;
|
||||
selectedTask?: any | null;
|
||||
};
|
||||
|
||||
const initialState: financeState = {
|
||||
isRatecardDrawerOpen: false,
|
||||
isFinanceDrawerOpen: false,
|
||||
isImportRatecardsDrawerOpen: false,
|
||||
currency: 'USD',
|
||||
isRatecardsLoading: false,
|
||||
isFinanceDrawerloading: false,
|
||||
drawerRatecard: null,
|
||||
ratecardsList: null,
|
||||
selectedTask: 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;
|
||||
},
|
||||
openFinanceDrawer: (state, action: PayloadAction<any>) => {
|
||||
state.isFinanceDrawerOpen = true;
|
||||
state.selectedTask = action.payload;
|
||||
},
|
||||
closeFinanceDrawer: (state) => {
|
||||
state.isFinanceDrawerOpen = false;
|
||||
state.selectedTask = null;
|
||||
},
|
||||
setSelectedTask: (state, action: PayloadAction<any>) => {
|
||||
state.selectedTask = action.payload;
|
||||
},
|
||||
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
|
||||
.addCase(fetchRateCards.pending, (state) => {
|
||||
state.isRatecardsLoading = true;
|
||||
})
|
||||
.addCase(fetchRateCards.fulfilled, (state, action) => {
|
||||
state.isRatecardsLoading = false;
|
||||
state.ratecardsList = Array.isArray(action.payload.data)
|
||||
? action.payload.data
|
||||
: Array.isArray(action.payload)
|
||||
? action.payload
|
||||
: [];
|
||||
})
|
||||
.addCase(fetchRateCards.rejected, (state) => {
|
||||
state.isRatecardsLoading = false;
|
||||
state.ratecardsList = [];
|
||||
})
|
||||
.addCase(fetchRateCardById.pending, (state) => {
|
||||
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;
|
||||
})
|
||||
// Create rate card
|
||||
.addCase(createRateCard.pending, (state) => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(createRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
if (state.ratecardsList) {
|
||||
state.ratecardsList.push(action.payload);
|
||||
} else {
|
||||
state.ratecardsList = [action.payload];
|
||||
}
|
||||
})
|
||||
.addCase(createRateCard.rejected, (state) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
})
|
||||
// Update rate card
|
||||
.addCase(updateRateCard.pending, (state) => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(updateRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
// Update the drawerRatecard with the new data
|
||||
state.drawerRatecard = action.payload;
|
||||
// Update the rate card in the list if it exists
|
||||
if (state.ratecardsList && action.payload?.id) {
|
||||
const index = state.ratecardsList.findIndex(rc => rc.id === action.payload.id);
|
||||
if (index !== -1) {
|
||||
state.ratecardsList[index] = action.payload;
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateRateCard.rejected, (state) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
})
|
||||
// Delete rate card
|
||||
.addCase(deleteRateCard.pending, (state) => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(deleteRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
// Remove the deleted rate card from the list
|
||||
if (state.ratecardsList) {
|
||||
state.ratecardsList = state.ratecardsList.filter(rc => rc.id !== action.payload);
|
||||
}
|
||||
// Clear drawer rate card if it was the deleted one
|
||||
if (state.drawerRatecard?.id === action.payload) {
|
||||
state.drawerRatecard = null;
|
||||
}
|
||||
})
|
||||
.addCase(deleteRateCard.rejected, (state) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
toggleRatecardDrawer,
|
||||
toggleFinanceDrawer,
|
||||
openFinanceDrawer,
|
||||
closeFinanceDrawer,
|
||||
setSelectedTask,
|
||||
toggleImportRatecardsDrawer,
|
||||
changeCurrency,
|
||||
ratecardDrawerLoading,
|
||||
clearDrawerRatecard,
|
||||
} = financeSlice.actions;
|
||||
export default financeSlice.reducer;
|
||||
310
worklenz-frontend/src/features/finance/project-finance-slice.ts
Normal file
310
worklenz-frontend/src/features/finance/project-finance-slice.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import {
|
||||
projectRateCardApiService,
|
||||
IProjectRateCardRole,
|
||||
} from '@/api/project-finance-ratecard/project-finance-rate-cards.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { JobRoleType } from '@/types/project/ratecard.types';
|
||||
|
||||
type ProjectFinanceRateCardState = {
|
||||
isDrawerOpen: boolean;
|
||||
isLoading: boolean;
|
||||
rateCardRoles: JobRoleType[] | null;
|
||||
drawerRole: IProjectRateCardRole | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
const initialState: ProjectFinanceRateCardState = {
|
||||
isDrawerOpen: false,
|
||||
isLoading: false,
|
||||
rateCardRoles: null,
|
||||
drawerRole: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Async thunks
|
||||
export const fetchProjectRateCardRoles = createAsyncThunk(
|
||||
'projectFinance/fetchAll',
|
||||
async (project_id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.getFromProjectId(project_id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Project RateCard Roles', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to fetch project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchProjectRateCardRoleById = createAsyncThunk(
|
||||
'projectFinance/fetchById',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.getFromId(id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Project RateCard Role By Id', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to fetch project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const insertProjectRateCardRoles = createAsyncThunk(
|
||||
'projectFinance/insertMany',
|
||||
async (
|
||||
{
|
||||
project_id,
|
||||
roles,
|
||||
}: { project_id: string; roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[] },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.insertMany(project_id, roles);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Insert Project RateCard Roles', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to insert project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const insertProjectRateCardRole = createAsyncThunk(
|
||||
'projectFinance/insertOne',
|
||||
async (
|
||||
{
|
||||
project_id,
|
||||
job_title_id,
|
||||
rate,
|
||||
man_day_rate,
|
||||
}: { project_id: string; job_title_id: string; rate: number; man_day_rate?: number },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.insertOne({
|
||||
project_id,
|
||||
job_title_id,
|
||||
rate,
|
||||
man_day_rate,
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Insert Project RateCard Role', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to insert project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateProjectRateCardRoleById = createAsyncThunk(
|
||||
'projectFinance/updateById',
|
||||
async (
|
||||
{
|
||||
id,
|
||||
body,
|
||||
}: { id: string; body: { job_title_id: string; rate?: string; man_day_rate?: string } },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.updateFromId(id, body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Update Project RateCard Role By Id', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to update project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateProjectRateCardRolesByProjectId = createAsyncThunk(
|
||||
'projectFinance/updateByProjectId',
|
||||
async (
|
||||
{
|
||||
project_id,
|
||||
roles,
|
||||
}: { project_id: string; roles: Omit<IProjectRateCardRole, 'id' | 'project_id'>[] },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.updateFromProjectId(project_id, roles);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Update Project RateCard Roles By ProjectId', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to update project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteProjectRateCardRoleById = createAsyncThunk(
|
||||
'projectFinance/deleteById',
|
||||
async (id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.deleteFromId(id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Delete Project RateCard Role By Id', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to delete project rate card role');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const assignMemberToRateCardRole = createAsyncThunk(
|
||||
'projectFinance/assignMemberToRateCardRole',
|
||||
async ({
|
||||
project_id,
|
||||
member_id,
|
||||
project_rate_card_role_id,
|
||||
}: {
|
||||
project_id: string;
|
||||
member_id: string;
|
||||
project_rate_card_role_id: string;
|
||||
}) => {
|
||||
const response = await projectRateCardApiService.updateMemberRateCardRole(
|
||||
project_id,
|
||||
member_id,
|
||||
project_rate_card_role_id
|
||||
);
|
||||
return response.body;
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteProjectRateCardRolesByProjectId = createAsyncThunk(
|
||||
'projectFinance/deleteByProjectId',
|
||||
async (project_id: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await projectRateCardApiService.deleteFromProjectId(project_id);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Delete Project RateCard Roles By ProjectId', error);
|
||||
if (error instanceof Error) return rejectWithValue(error.message);
|
||||
return rejectWithValue('Failed to delete project rate card roles');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const projectFinanceSlice = createSlice({
|
||||
name: 'projectFinanceRateCard',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleDrawer: state => {
|
||||
state.isDrawerOpen = !state.isDrawerOpen;
|
||||
},
|
||||
clearDrawerRole: state => {
|
||||
state.drawerRole = null;
|
||||
},
|
||||
clearError: state => {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
// Fetch all
|
||||
.addCase(fetchProjectRateCardRoles.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoles.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = action.payload || [];
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoles.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
state.rateCardRoles = [];
|
||||
})
|
||||
// Fetch by id
|
||||
.addCase(fetchProjectRateCardRoleById.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.drawerRole = null;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.drawerRole = action.payload || null;
|
||||
})
|
||||
.addCase(fetchProjectRateCardRoleById.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
state.drawerRole = null;
|
||||
})
|
||||
// Insert many
|
||||
.addCase(insertProjectRateCardRoles.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(insertProjectRateCardRoles.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = action.payload || [];
|
||||
})
|
||||
.addCase(insertProjectRateCardRoles.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Update by id
|
||||
.addCase(updateProjectRateCardRoleById.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(updateProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
if (state.rateCardRoles && action.payload) {
|
||||
state.rateCardRoles = state.rateCardRoles.map(role =>
|
||||
role.id === action.payload.id ? action.payload : role
|
||||
);
|
||||
}
|
||||
})
|
||||
.addCase(updateProjectRateCardRoleById.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Update by project id
|
||||
.addCase(updateProjectRateCardRolesByProjectId.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(updateProjectRateCardRolesByProjectId.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = action.payload || [];
|
||||
})
|
||||
.addCase(updateProjectRateCardRolesByProjectId.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Delete by id
|
||||
.addCase(deleteProjectRateCardRoleById.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(deleteProjectRateCardRoleById.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
if (state.rateCardRoles && action.payload) {
|
||||
state.rateCardRoles = state.rateCardRoles.filter(role => role.id !== action.payload.id);
|
||||
}
|
||||
})
|
||||
.addCase(deleteProjectRateCardRoleById.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// Delete by project id
|
||||
.addCase(deleteProjectRateCardRolesByProjectId.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(deleteProjectRateCardRolesByProjectId.fulfilled, state => {
|
||||
state.isLoading = false;
|
||||
state.rateCardRoles = [];
|
||||
})
|
||||
.addCase(deleteProjectRateCardRolesByProjectId.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { toggleDrawer, clearDrawerRole, clearError } = projectFinanceSlice.actions;
|
||||
|
||||
export default projectFinanceSlice.reducer;
|
||||
@@ -116,6 +116,11 @@ const projectSlice = createSlice({
|
||||
state.project.phase_label = action.payload;
|
||||
}
|
||||
},
|
||||
updateProjectCurrency: (state, action: PayloadAction<string>) => {
|
||||
if (state.project) {
|
||||
state.project.currency = action.payload;
|
||||
}
|
||||
},
|
||||
addTask: (
|
||||
state,
|
||||
action: PayloadAction<{ task: IProjectTask; groupId: string; insert?: boolean }>
|
||||
@@ -143,7 +148,6 @@ const projectSlice = createSlice({
|
||||
} else {
|
||||
insert ? group.tasks.unshift(task) : group.tasks.push(task);
|
||||
}
|
||||
console.log('addTask', group.tasks);
|
||||
},
|
||||
deleteTask: (state, action: PayloadAction<{ taskId: string; index?: number }>) => {
|
||||
const { taskId, index } = action.payload;
|
||||
@@ -215,6 +219,7 @@ export const {
|
||||
setProjectView,
|
||||
updatePhaseLabel,
|
||||
setRefreshTimestamp,
|
||||
updateProjectCurrency
|
||||
} = projectSlice.actions;
|
||||
|
||||
export default projectSlice.reducer;
|
||||
|
||||
252
worklenz-frontend/src/features/projects/finance/finance-slice.ts
Normal file
252
worklenz-frontend/src/features/projects/finance/finance-slice.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
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;
|
||||
isRatecardsLoading?: boolean;
|
||||
isFinanceDrawerloading?: boolean;
|
||||
drawerRatecard?: RatecardType | null;
|
||||
ratecardsList?: RatecardType[] | null;
|
||||
selectedTask?: any | null;
|
||||
};
|
||||
|
||||
const initialState: financeState = {
|
||||
isRatecardDrawerOpen: false,
|
||||
isFinanceDrawerOpen: false,
|
||||
isImportRatecardsDrawerOpen: false,
|
||||
currency: 'USD',
|
||||
isRatecardsLoading: false,
|
||||
isFinanceDrawerloading: false,
|
||||
drawerRatecard: null,
|
||||
ratecardsList: null,
|
||||
selectedTask: 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;
|
||||
},
|
||||
openFinanceDrawer: (state, action: PayloadAction<any>) => {
|
||||
state.isFinanceDrawerOpen = true;
|
||||
state.selectedTask = action.payload;
|
||||
},
|
||||
closeFinanceDrawer: state => {
|
||||
state.isFinanceDrawerOpen = false;
|
||||
state.selectedTask = null;
|
||||
},
|
||||
setSelectedTask: (state, action: PayloadAction<any>) => {
|
||||
state.selectedTask = action.payload;
|
||||
},
|
||||
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
|
||||
.addCase(fetchRateCards.pending, state => {
|
||||
state.isRatecardsLoading = true;
|
||||
})
|
||||
.addCase(fetchRateCards.fulfilled, (state, action) => {
|
||||
state.isRatecardsLoading = false;
|
||||
state.ratecardsList = Array.isArray(action.payload.data)
|
||||
? action.payload.data
|
||||
: Array.isArray(action.payload)
|
||||
? action.payload
|
||||
: [];
|
||||
})
|
||||
.addCase(fetchRateCards.rejected, state => {
|
||||
state.isRatecardsLoading = false;
|
||||
state.ratecardsList = [];
|
||||
})
|
||||
.addCase(fetchRateCardById.pending, state => {
|
||||
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;
|
||||
})
|
||||
// Create rate card
|
||||
.addCase(createRateCard.pending, state => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(createRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
if (state.ratecardsList) {
|
||||
state.ratecardsList.push(action.payload);
|
||||
} else {
|
||||
state.ratecardsList = [action.payload];
|
||||
}
|
||||
})
|
||||
.addCase(createRateCard.rejected, state => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
})
|
||||
// Update rate card
|
||||
.addCase(updateRateCard.pending, state => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(updateRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
// Update the drawerRatecard with the new data
|
||||
state.drawerRatecard = action.payload;
|
||||
// Update the rate card in the list if it exists
|
||||
if (state.ratecardsList && action.payload?.id) {
|
||||
const index = state.ratecardsList.findIndex(rc => rc.id === action.payload.id);
|
||||
if (index !== -1) {
|
||||
state.ratecardsList[index] = action.payload;
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateRateCard.rejected, state => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
})
|
||||
// Delete rate card
|
||||
.addCase(deleteRateCard.pending, state => {
|
||||
state.isFinanceDrawerloading = true;
|
||||
})
|
||||
.addCase(deleteRateCard.fulfilled, (state, action) => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
// Remove the deleted rate card from the list
|
||||
if (state.ratecardsList) {
|
||||
state.ratecardsList = state.ratecardsList.filter(rc => rc.id !== action.payload);
|
||||
}
|
||||
// Clear drawer rate card if it was the deleted one
|
||||
if (state.drawerRatecard?.id === action.payload) {
|
||||
state.drawerRatecard = null;
|
||||
}
|
||||
})
|
||||
.addCase(deleteRateCard.rejected, state => {
|
||||
state.isFinanceDrawerloading = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
toggleRatecardDrawer,
|
||||
toggleFinanceDrawer,
|
||||
openFinanceDrawer,
|
||||
closeFinanceDrawer,
|
||||
setSelectedTask,
|
||||
toggleImportRatecardsDrawer,
|
||||
changeCurrency,
|
||||
ratecardDrawerLoading,
|
||||
clearDrawerRatecard,
|
||||
} = financeSlice.actions;
|
||||
export default financeSlice.reducer;
|
||||
@@ -0,0 +1,691 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
import {
|
||||
IProjectFinanceGroup,
|
||||
IProjectFinanceTask,
|
||||
IProjectRateCard,
|
||||
IProjectFinanceProject,
|
||||
} from '@/types/project/project-finance.types';
|
||||
|
||||
type FinanceTabType = 'finance' | 'ratecard';
|
||||
type GroupTypes = 'status' | 'priority' | 'phases';
|
||||
type BillableFilterType = 'all' | 'billable' | 'non-billable';
|
||||
|
||||
interface ProjectFinanceState {
|
||||
activeTab: FinanceTabType;
|
||||
activeGroup: GroupTypes;
|
||||
billableFilter: BillableFilterType;
|
||||
loading: boolean;
|
||||
taskGroups: IProjectFinanceGroup[];
|
||||
projectRateCards: IProjectRateCard[];
|
||||
project: IProjectFinanceProject | null;
|
||||
}
|
||||
|
||||
// Enhanced utility functions for efficient frontend calculations
|
||||
const secondsToHours = (seconds: number) => seconds / 3600;
|
||||
|
||||
const calculateTaskCosts = (task: IProjectFinanceTask) => {
|
||||
const hours = secondsToHours(task.estimated_seconds || 0);
|
||||
const timeLoggedHours = secondsToHours(task.total_time_logged_seconds || 0);
|
||||
|
||||
const totalBudget = (task.estimated_cost || 0) + (task.fixed_cost || 0);
|
||||
// task.total_actual already includes actual_cost_from_logs + fixed_cost from backend
|
||||
const totalActual = task.total_actual || 0;
|
||||
const variance = totalActual - totalBudget;
|
||||
|
||||
return {
|
||||
hours,
|
||||
timeLoggedHours,
|
||||
totalBudget,
|
||||
totalActual,
|
||||
variance,
|
||||
};
|
||||
};
|
||||
|
||||
// Memoization cache for task calculations to improve performance
|
||||
const taskCalculationCache = new Map<
|
||||
string,
|
||||
{
|
||||
task: IProjectFinanceTask;
|
||||
result: IProjectFinanceTask;
|
||||
timestamp: number;
|
||||
}
|
||||
>();
|
||||
|
||||
// Cache cleanup interval (5 minutes)
|
||||
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000;
|
||||
const CACHE_MAX_AGE = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
// Periodic cache cleanup
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
Array.from(taskCalculationCache.entries()).forEach(([key, value]) => {
|
||||
if (now - value.timestamp > CACHE_MAX_AGE) {
|
||||
taskCalculationCache.delete(key);
|
||||
}
|
||||
});
|
||||
}, CACHE_CLEANUP_INTERVAL);
|
||||
|
||||
// Generate cache key for task
|
||||
const generateTaskCacheKey = (task: IProjectFinanceTask): string => {
|
||||
return `${task.id}-${task.estimated_cost}-${task.fixed_cost}-${task.total_actual}-${task.estimated_seconds}-${task.total_time_logged_seconds}`;
|
||||
};
|
||||
|
||||
// Check if task has changed significantly to warrant recalculation
|
||||
const hasTaskChanged = (oldTask: IProjectFinanceTask, newTask: IProjectFinanceTask): boolean => {
|
||||
return (
|
||||
oldTask.estimated_cost !== newTask.estimated_cost ||
|
||||
oldTask.fixed_cost !== newTask.fixed_cost ||
|
||||
oldTask.total_actual !== newTask.total_actual ||
|
||||
oldTask.estimated_seconds !== newTask.estimated_seconds ||
|
||||
oldTask.total_time_logged_seconds !== newTask.total_time_logged_seconds
|
||||
);
|
||||
};
|
||||
|
||||
// Optimized recursive calculation for task hierarchy with memoization
|
||||
const recalculateTaskHierarchy = (tasks: IProjectFinanceTask[]): IProjectFinanceTask[] => {
|
||||
return tasks.map(task => {
|
||||
// If task has loaded subtasks, recalculate from subtasks
|
||||
if (task.sub_tasks && task.sub_tasks.length > 0) {
|
||||
const updatedSubTasks = recalculateTaskHierarchy(task.sub_tasks);
|
||||
|
||||
// Calculate totals from subtasks only (for time and costs from logs)
|
||||
const subtaskTotals = updatedSubTasks.reduce(
|
||||
(acc, subtask) => ({
|
||||
estimated_cost: acc.estimated_cost + (subtask.estimated_cost || 0),
|
||||
fixed_cost: acc.fixed_cost + (subtask.fixed_cost || 0),
|
||||
actual_cost_from_logs: acc.actual_cost_from_logs + (subtask.actual_cost_from_logs || 0),
|
||||
estimated_seconds: acc.estimated_seconds + (subtask.estimated_seconds || 0),
|
||||
total_time_logged_seconds:
|
||||
acc.total_time_logged_seconds + (subtask.total_time_logged_seconds || 0),
|
||||
}),
|
||||
{
|
||||
estimated_cost: 0,
|
||||
fixed_cost: 0,
|
||||
actual_cost_from_logs: 0,
|
||||
estimated_seconds: 0,
|
||||
total_time_logged_seconds: 0,
|
||||
}
|
||||
);
|
||||
|
||||
// For parent tasks with loaded subtasks: use ONLY the subtask totals
|
||||
// The parent's original values were backend-aggregated, now we use frontend subtask aggregation
|
||||
const totalFixedCost = subtaskTotals.fixed_cost; // Only subtask fixed costs
|
||||
const totalEstimatedCost = subtaskTotals.estimated_cost; // Only subtask estimated costs
|
||||
const totalActualCostFromLogs = subtaskTotals.actual_cost_from_logs; // Only subtask logged costs
|
||||
const totalActual = totalActualCostFromLogs + totalFixedCost;
|
||||
|
||||
// Update parent task with aggregated values
|
||||
const updatedTask = {
|
||||
...task,
|
||||
sub_tasks: updatedSubTasks,
|
||||
estimated_cost: totalEstimatedCost,
|
||||
fixed_cost: totalFixedCost,
|
||||
actual_cost_from_logs: totalActualCostFromLogs,
|
||||
total_actual: totalActual,
|
||||
estimated_seconds: subtaskTotals.estimated_seconds,
|
||||
total_time_logged_seconds: subtaskTotals.total_time_logged_seconds,
|
||||
total_budget: totalEstimatedCost + totalFixedCost,
|
||||
variance: totalActual - (totalEstimatedCost + totalFixedCost),
|
||||
};
|
||||
|
||||
return updatedTask;
|
||||
}
|
||||
|
||||
// For parent tasks without loaded subtasks, trust backend-calculated values
|
||||
if (task.sub_tasks_count > 0 && (!task.sub_tasks || task.sub_tasks.length === 0)) {
|
||||
// Parent task with unloaded subtasks - backend has already calculated aggregated values
|
||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||
return {
|
||||
...task,
|
||||
total_budget: totalBudget,
|
||||
total_actual: totalActual,
|
||||
variance: variance,
|
||||
};
|
||||
}
|
||||
|
||||
// For leaf tasks, check cache first
|
||||
const cacheKey = generateTaskCacheKey(task);
|
||||
const cached = taskCalculationCache.get(cacheKey);
|
||||
|
||||
if (cached && !hasTaskChanged(cached.task, task)) {
|
||||
return { ...cached.result, ...task }; // Merge with current task to preserve other properties
|
||||
}
|
||||
|
||||
// For leaf tasks, just recalculate their own values
|
||||
const { totalBudget, totalActual, variance } = calculateTaskCosts(task);
|
||||
const updatedTask = {
|
||||
...task,
|
||||
total_budget: totalBudget,
|
||||
total_actual: totalActual,
|
||||
variance: variance,
|
||||
};
|
||||
|
||||
// Cache the result only for leaf tasks
|
||||
taskCalculationCache.set(cacheKey, {
|
||||
task: { ...task },
|
||||
result: updatedTask,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return updatedTask;
|
||||
});
|
||||
};
|
||||
|
||||
// Optimized function to find and update a specific task, then recalculate hierarchy
|
||||
const updateTaskAndRecalculateHierarchy = (
|
||||
tasks: IProjectFinanceTask[],
|
||||
targetId: string,
|
||||
updateFn: (task: IProjectFinanceTask) => IProjectFinanceTask
|
||||
): { updated: boolean; tasks: IProjectFinanceTask[] } => {
|
||||
let updated = false;
|
||||
|
||||
const updatedTasks = tasks.map(task => {
|
||||
if (task.id === targetId) {
|
||||
updated = true;
|
||||
return updateFn(task);
|
||||
}
|
||||
|
||||
// Search in subtasks recursively
|
||||
if (task.sub_tasks && task.sub_tasks.length > 0) {
|
||||
const result = updateTaskAndRecalculateHierarchy(task.sub_tasks, targetId, updateFn);
|
||||
if (result.updated) {
|
||||
updated = true;
|
||||
return {
|
||||
...task,
|
||||
sub_tasks: result.tasks,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return task;
|
||||
});
|
||||
|
||||
// If a task was updated, recalculate the entire hierarchy to ensure parent totals are correct
|
||||
return {
|
||||
updated,
|
||||
tasks: updated ? recalculateTaskHierarchy(updatedTasks) : updatedTasks,
|
||||
};
|
||||
};
|
||||
|
||||
const initialState: ProjectFinanceState = {
|
||||
activeTab: 'finance',
|
||||
activeGroup: 'status',
|
||||
billableFilter: 'billable',
|
||||
loading: false,
|
||||
taskGroups: [],
|
||||
projectRateCards: [],
|
||||
project: null,
|
||||
};
|
||||
|
||||
export const fetchProjectFinances = createAsyncThunk(
|
||||
'projectFinances/fetchProjectFinances',
|
||||
async ({
|
||||
projectId,
|
||||
groupBy,
|
||||
billableFilter,
|
||||
}: {
|
||||
projectId: string;
|
||||
groupBy: GroupTypes;
|
||||
billableFilter?: BillableFilterType;
|
||||
}) => {
|
||||
const response = await projectFinanceApiService.getProjectTasks(
|
||||
projectId,
|
||||
groupBy,
|
||||
billableFilter
|
||||
);
|
||||
return response.body;
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchProjectFinancesSilent = createAsyncThunk(
|
||||
'projectFinances/fetchProjectFinancesSilent',
|
||||
async ({
|
||||
projectId,
|
||||
groupBy,
|
||||
billableFilter,
|
||||
resetExpansions = false,
|
||||
}: {
|
||||
projectId: string;
|
||||
groupBy: GroupTypes;
|
||||
billableFilter?: BillableFilterType;
|
||||
resetExpansions?: boolean;
|
||||
}) => {
|
||||
const response = await projectFinanceApiService.getProjectTasks(
|
||||
projectId,
|
||||
groupBy,
|
||||
billableFilter
|
||||
);
|
||||
return { ...response.body, resetExpansions };
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchSubTasks = createAsyncThunk(
|
||||
'projectFinances/fetchSubTasks',
|
||||
async ({
|
||||
projectId,
|
||||
parentTaskId,
|
||||
billableFilter,
|
||||
}: {
|
||||
projectId: string;
|
||||
parentTaskId: string;
|
||||
billableFilter?: BillableFilterType;
|
||||
}) => {
|
||||
const response = await projectFinanceApiService.getSubTasks(
|
||||
projectId,
|
||||
parentTaskId,
|
||||
billableFilter
|
||||
);
|
||||
return { parentTaskId, subTasks: response.body };
|
||||
}
|
||||
);
|
||||
|
||||
export const updateTaskFixedCostAsync = createAsyncThunk(
|
||||
'projectFinances/updateTaskFixedCostAsync',
|
||||
async ({
|
||||
taskId,
|
||||
groupId,
|
||||
fixedCost,
|
||||
}: {
|
||||
taskId: string;
|
||||
groupId: string;
|
||||
fixedCost: number;
|
||||
}) => {
|
||||
await projectFinanceApiService.updateTaskFixedCost(taskId, fixedCost);
|
||||
return { taskId, groupId, fixedCost };
|
||||
}
|
||||
);
|
||||
|
||||
export const updateProjectCalculationMethodAsync = createAsyncThunk(
|
||||
'projectFinances/updateProjectCalculationMethodAsync',
|
||||
async ({
|
||||
projectId,
|
||||
calculationMethod,
|
||||
hoursPerDay,
|
||||
}: {
|
||||
projectId: string;
|
||||
calculationMethod: 'hourly' | 'man_days';
|
||||
hoursPerDay?: number;
|
||||
}) => {
|
||||
await projectFinanceApiService.updateProjectCalculationMethod(
|
||||
projectId,
|
||||
calculationMethod,
|
||||
hoursPerDay
|
||||
);
|
||||
return { calculationMethod, hoursPerDay };
|
||||
}
|
||||
);
|
||||
|
||||
export const updateTaskEstimatedManDaysAsync = createAsyncThunk(
|
||||
'projectFinances/updateTaskEstimatedManDaysAsync',
|
||||
async ({
|
||||
taskId,
|
||||
groupId,
|
||||
estimatedManDays,
|
||||
}: {
|
||||
taskId: string;
|
||||
groupId: string;
|
||||
estimatedManDays: number;
|
||||
}) => {
|
||||
await projectFinanceApiService.updateTaskEstimatedManDays(taskId, estimatedManDays);
|
||||
return { taskId, groupId, estimatedManDays };
|
||||
}
|
||||
);
|
||||
|
||||
export const updateRateCardManDayRateAsync = createAsyncThunk(
|
||||
'projectFinances/updateRateCardManDayRateAsync',
|
||||
async ({ rateCardRoleId, manDayRate }: { rateCardRoleId: string; manDayRate: number }) => {
|
||||
await projectFinanceApiService.updateRateCardManDayRate(rateCardRoleId, manDayRate);
|
||||
return { rateCardRoleId, manDayRate };
|
||||
}
|
||||
);
|
||||
|
||||
// Function to clear calculation cache (useful for testing or when data is refreshed)
|
||||
const clearCalculationCache = () => {
|
||||
taskCalculationCache.clear();
|
||||
};
|
||||
|
||||
export const projectFinancesSlice = createSlice({
|
||||
name: 'projectFinances',
|
||||
initialState,
|
||||
reducers: {
|
||||
setActiveTab: (state, action: PayloadAction<FinanceTabType>) => {
|
||||
state.activeTab = action.payload;
|
||||
},
|
||||
setActiveGroup: (state, action: PayloadAction<GroupTypes>) => {
|
||||
state.activeGroup = action.payload;
|
||||
},
|
||||
setBillableFilter: (state, action: PayloadAction<BillableFilterType>) => {
|
||||
state.billableFilter = action.payload;
|
||||
},
|
||||
resetAllTaskExpansions: state => {
|
||||
// Recursive function to reset all expansion states
|
||||
const resetExpansionStates = (tasks: IProjectFinanceTask[]): IProjectFinanceTask[] => {
|
||||
return tasks.map(task => ({
|
||||
...task,
|
||||
show_sub_tasks: false,
|
||||
sub_tasks: task.sub_tasks ? resetExpansionStates(task.sub_tasks) : task.sub_tasks,
|
||||
}));
|
||||
};
|
||||
|
||||
// Reset expansion states for all groups
|
||||
state.taskGroups = state.taskGroups.map(group => ({
|
||||
...group,
|
||||
tasks: resetExpansionStates(group.tasks),
|
||||
}));
|
||||
},
|
||||
updateTaskFixedCost: (
|
||||
state,
|
||||
action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>
|
||||
) => {
|
||||
const { taskId, groupId, fixedCost } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
fixed_cost: fixedCost,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateTaskEstimatedCost: (
|
||||
state,
|
||||
action: PayloadAction<{ taskId: string; groupId: string; estimatedCost: number }>
|
||||
) => {
|
||||
const { taskId, groupId, estimatedCost } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
estimated_cost: estimatedCost,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateTaskTimeLogged: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
taskId: string;
|
||||
groupId: string;
|
||||
timeLoggedSeconds: number;
|
||||
timeLoggedString: string;
|
||||
totalActual: number;
|
||||
}>
|
||||
) => {
|
||||
const { taskId, groupId, timeLoggedSeconds, timeLoggedString, totalActual } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
total_time_logged_seconds: timeLoggedSeconds,
|
||||
total_time_logged: timeLoggedString,
|
||||
total_actual: totalActual,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleTaskExpansion: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => {
|
||||
const { taskId, groupId } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
// Recursive function to find and toggle a task in the hierarchy
|
||||
const findAndToggleTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => {
|
||||
for (const task of tasks) {
|
||||
if (task.id === targetId) {
|
||||
task.show_sub_tasks = !task.show_sub_tasks;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in subtasks recursively
|
||||
if (task.sub_tasks && findAndToggleTask(task.sub_tasks, targetId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
findAndToggleTask(group.tasks, taskId);
|
||||
}
|
||||
},
|
||||
updateProjectFinanceCurrency: (state, action: PayloadAction<string>) => {
|
||||
if (state.project) {
|
||||
state.project.currency = action.payload;
|
||||
}
|
||||
},
|
||||
updateProjectCalculationMethod: (
|
||||
state,
|
||||
action: PayloadAction<{ calculationMethod: 'hourly' | 'man_days'; hoursPerDay?: number }>
|
||||
) => {
|
||||
if (state.project) {
|
||||
state.project.calculation_method = action.payload.calculationMethod;
|
||||
if (action.payload.hoursPerDay !== undefined) {
|
||||
state.project.hours_per_day = action.payload.hoursPerDay;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateTaskEstimatedManDays: (
|
||||
state,
|
||||
action: PayloadAction<{ taskId: string; groupId: string; estimatedManDays: number }>
|
||||
) => {
|
||||
const { taskId, groupId, estimatedManDays } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
estimated_man_days: estimatedManDays,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateRateCardManDayRate: (
|
||||
state,
|
||||
action: PayloadAction<{ rateCardRoleId: string; manDayRate: number }>
|
||||
) => {
|
||||
const { rateCardRoleId, manDayRate } = action.payload;
|
||||
const rateCard = state.projectRateCards.find(rc => rc.id === rateCardRoleId);
|
||||
|
||||
if (rateCard) {
|
||||
rateCard.man_day_rate = manDayRate.toString();
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
.addCase(fetchProjectFinances.pending, state => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(fetchProjectFinances.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
// Apply hierarchy recalculation to ensure parent tasks show correct aggregated values
|
||||
const recalculatedGroups = action.payload.groups.map((group: IProjectFinanceGroup) => ({
|
||||
...group,
|
||||
tasks: recalculateTaskHierarchy(group.tasks),
|
||||
}));
|
||||
state.taskGroups = recalculatedGroups;
|
||||
state.projectRateCards = action.payload.project_rate_cards;
|
||||
state.project = action.payload.project;
|
||||
// Clear cache when fresh data is loaded
|
||||
clearCalculationCache();
|
||||
})
|
||||
.addCase(fetchProjectFinances.rejected, state => {
|
||||
state.loading = false;
|
||||
})
|
||||
.addCase(fetchProjectFinancesSilent.fulfilled, (state, action) => {
|
||||
const { resetExpansions, ...payload } = action.payload;
|
||||
|
||||
if (resetExpansions) {
|
||||
// Reset all expansions and load fresh data
|
||||
const recalculatedGroups = payload.groups.map((group: IProjectFinanceGroup) => ({
|
||||
...group,
|
||||
tasks: recalculateTaskHierarchy(group.tasks),
|
||||
}));
|
||||
state.taskGroups = recalculatedGroups;
|
||||
} else {
|
||||
// Helper function to preserve expansion state and sub_tasks during updates
|
||||
const preserveExpansionState = (
|
||||
existingTasks: IProjectFinanceTask[],
|
||||
newTasks: IProjectFinanceTask[]
|
||||
): IProjectFinanceTask[] => {
|
||||
return newTasks.map(newTask => {
|
||||
const existingTask = existingTasks.find(t => t.id === newTask.id);
|
||||
if (existingTask) {
|
||||
// Preserve expansion state and subtasks
|
||||
const updatedTask = {
|
||||
...newTask,
|
||||
show_sub_tasks: existingTask.show_sub_tasks,
|
||||
sub_tasks: existingTask.sub_tasks
|
||||
? preserveExpansionState(existingTask.sub_tasks, newTask.sub_tasks || [])
|
||||
: newTask.sub_tasks,
|
||||
};
|
||||
return updatedTask;
|
||||
}
|
||||
return newTask;
|
||||
});
|
||||
};
|
||||
|
||||
// Update groups while preserving expansion state and applying hierarchy recalculation
|
||||
const updatedTaskGroups = payload.groups.map((newGroup: IProjectFinanceGroup) => {
|
||||
const existingGroup = state.taskGroups.find(g => g.group_id === newGroup.group_id);
|
||||
if (existingGroup) {
|
||||
const tasksWithExpansion = preserveExpansionState(
|
||||
existingGroup.tasks,
|
||||
newGroup.tasks
|
||||
);
|
||||
return {
|
||||
...newGroup,
|
||||
tasks: recalculateTaskHierarchy(tasksWithExpansion),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...newGroup,
|
||||
tasks: recalculateTaskHierarchy(newGroup.tasks),
|
||||
};
|
||||
});
|
||||
state.taskGroups = updatedTaskGroups;
|
||||
}
|
||||
|
||||
// Update data without changing loading state for silent refresh
|
||||
state.projectRateCards = payload.project_rate_cards;
|
||||
state.project = payload.project;
|
||||
// Clear cache when data is refreshed from backend
|
||||
clearCalculationCache();
|
||||
})
|
||||
.addCase(updateTaskFixedCostAsync.fulfilled, (state, action) => {
|
||||
const { taskId, groupId, fixedCost } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
// Update the specific task's fixed cost and recalculate the entire hierarchy
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
fixed_cost: fixedCost,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
clearCalculationCache();
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(fetchSubTasks.fulfilled, (state, action) => {
|
||||
const { parentTaskId, subTasks } = action.payload;
|
||||
|
||||
// Recursive function to find and update a task in the hierarchy
|
||||
const findAndUpdateTask = (tasks: IProjectFinanceTask[], targetId: string): boolean => {
|
||||
for (const task of tasks) {
|
||||
if (task.id === targetId) {
|
||||
// Found the parent task, add subtasks
|
||||
task.sub_tasks = subTasks.map(subTask => ({
|
||||
...subTask,
|
||||
is_sub_task: true,
|
||||
parent_task_id: targetId,
|
||||
}));
|
||||
task.show_sub_tasks = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in subtasks recursively
|
||||
if (task.sub_tasks && findAndUpdateTask(task.sub_tasks, targetId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Find the parent task in any group and add the subtasks
|
||||
for (const group of state.taskGroups) {
|
||||
if (findAndUpdateTask(group.tasks, parentTaskId)) {
|
||||
// Recalculate the hierarchy after adding subtasks to ensure parent values are correct
|
||||
group.tasks = recalculateTaskHierarchy(group.tasks);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateProjectCalculationMethodAsync.fulfilled, (state, action) => {
|
||||
if (state.project) {
|
||||
state.project.calculation_method = action.payload.calculationMethod;
|
||||
if (action.payload.hoursPerDay !== undefined) {
|
||||
state.project.hours_per_day = action.payload.hoursPerDay;
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateTaskEstimatedManDaysAsync.fulfilled, (state, action) => {
|
||||
const { taskId, groupId, estimatedManDays } = action.payload;
|
||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||
|
||||
if (group) {
|
||||
const result = updateTaskAndRecalculateHierarchy(group.tasks, taskId, task => ({
|
||||
...task,
|
||||
estimated_man_days: estimatedManDays,
|
||||
}));
|
||||
|
||||
if (result.updated) {
|
||||
group.tasks = result.tasks;
|
||||
clearCalculationCache();
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(updateRateCardManDayRateAsync.fulfilled, (state, action) => {
|
||||
const { rateCardRoleId, manDayRate } = action.payload;
|
||||
const rateCard = state.projectRateCards.find(rc => rc.id === rateCardRoleId);
|
||||
|
||||
if (rateCard) {
|
||||
rateCard.man_day_rate = manDayRate.toString();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setActiveTab,
|
||||
setActiveGroup,
|
||||
setBillableFilter,
|
||||
resetAllTaskExpansions,
|
||||
updateTaskFixedCost,
|
||||
updateTaskEstimatedCost,
|
||||
updateTaskTimeLogged,
|
||||
toggleTaskExpansion,
|
||||
updateProjectFinanceCurrency,
|
||||
updateProjectCalculationMethod,
|
||||
updateTaskEstimatedManDays,
|
||||
updateRateCardManDayRate,
|
||||
} = projectFinancesSlice.actions;
|
||||
|
||||
export default projectFinancesSlice.reducer;
|
||||
@@ -5,6 +5,7 @@ import i18n from '@/i18n';
|
||||
// Import core components synchronously to avoid suspense in main tabs
|
||||
import ProjectViewEnhancedBoard from '@/pages/projects/projectView/enhancedBoard/project-view-enhanced-board';
|
||||
import TaskListV2 from '@/components/task-list-v2/TaskListV2';
|
||||
import ProjectViewFinance from '@/pages/projects/projectView/finance/ProjectViewFinance';
|
||||
|
||||
// Lazy load less critical components
|
||||
const ProjectViewInsights = React.lazy(
|
||||
@@ -117,6 +118,16 @@ export const tabItems: TabItems[] = [
|
||||
React.createElement(ProjectViewUpdates)
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 6,
|
||||
key: 'finance',
|
||||
label: getTabLabel('finance'),
|
||||
element: React.createElement(
|
||||
Suspense,
|
||||
{ fallback: React.createElement(InlineSuspenseFallback) },
|
||||
React.createElement(ProjectViewFinance)
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Function to update tab labels when language changes
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
export enum FinanceTableColumnKeys {
|
||||
TASK = 'task',
|
||||
MEMBERS = 'members',
|
||||
HOURS = 'hours',
|
||||
MAN_DAYS = 'man_days',
|
||||
TOTAL_TIME_LOGGED = 'total_time_logged',
|
||||
ESTIMATED_COST = 'estimated_cost',
|
||||
COST = 'cost',
|
||||
FIXED_COST = 'fixedCost',
|
||||
TOTAL_BUDGET = 'totalBudget',
|
||||
TOTAL_ACTUAL = 'totalActual',
|
||||
VARIANCE = 'variance',
|
||||
}
|
||||
|
||||
type FinanceTableColumnsType = {
|
||||
key: FinanceTableColumnKeys;
|
||||
name: string;
|
||||
width: number;
|
||||
type: 'string' | 'hours' | 'currency' | 'man_days' | 'effort_variance';
|
||||
render?: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
// finance table columns
|
||||
export const financeTableColumns: FinanceTableColumnsType[] = [
|
||||
{
|
||||
key: FinanceTableColumnKeys.TASK,
|
||||
name: 'taskColumn',
|
||||
width: 240,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.MEMBERS,
|
||||
name: 'membersColumn',
|
||||
width: 120,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.HOURS,
|
||||
name: 'hoursColumn',
|
||||
width: 100,
|
||||
type: 'hours',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.MAN_DAYS,
|
||||
name: 'manDaysColumn',
|
||||
width: 100,
|
||||
type: 'man_days',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.TOTAL_TIME_LOGGED,
|
||||
name: 'totalTimeLoggedColumn',
|
||||
width: 120,
|
||||
type: 'hours',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.ESTIMATED_COST,
|
||||
name: 'estimatedCostColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.COST,
|
||||
name: 'costColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.FIXED_COST,
|
||||
name: 'fixedCostColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.TOTAL_BUDGET,
|
||||
name: 'totalBudgetedCostColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.TOTAL_ACTUAL,
|
||||
name: 'totalActualCostColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: FinanceTableColumnKeys.VARIANCE,
|
||||
name: 'varianceColumn',
|
||||
width: 120,
|
||||
type: 'currency',
|
||||
},
|
||||
];
|
||||
|
||||
// Function to get columns based on calculation method
|
||||
export const getFinanceTableColumns = (
|
||||
calculationMethod: 'hourly' | 'man_days' = 'hourly'
|
||||
): FinanceTableColumnsType[] => {
|
||||
return financeTableColumns.filter(column => {
|
||||
// Always show these columns
|
||||
if (
|
||||
[
|
||||
FinanceTableColumnKeys.TASK,
|
||||
FinanceTableColumnKeys.MEMBERS,
|
||||
FinanceTableColumnKeys.TOTAL_TIME_LOGGED,
|
||||
FinanceTableColumnKeys.ESTIMATED_COST,
|
||||
FinanceTableColumnKeys.COST,
|
||||
FinanceTableColumnKeys.FIXED_COST,
|
||||
FinanceTableColumnKeys.TOTAL_BUDGET,
|
||||
FinanceTableColumnKeys.TOTAL_ACTUAL,
|
||||
FinanceTableColumnKeys.VARIANCE,
|
||||
].includes(column.key)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Show hours column only for hourly calculation
|
||||
if (column.key === FinanceTableColumnKeys.HOURS) {
|
||||
return calculationMethod === 'hourly';
|
||||
}
|
||||
|
||||
// Show man days columns only for man days calculation
|
||||
if ([FinanceTableColumnKeys.MAN_DAYS].includes(column.key)) {
|
||||
return calculationMethod === 'man_days';
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import RateCardSettings from '@/pages/settings/rate-card-settings/RateCardSettings';
|
||||
import {
|
||||
BankOutlined,
|
||||
FileZipOutlined,
|
||||
@@ -12,20 +13,33 @@ import {
|
||||
UserOutlined,
|
||||
UserSwitchOutlined,
|
||||
BulbOutlined,
|
||||
DollarCircleOutlined
|
||||
} from '@/shared/antd-imports';
|
||||
import React, { ReactNode, lazy } from 'react';
|
||||
const ProfileSettings = lazy(() => import('../../pages/settings/profile/profile-settings'));
|
||||
const NotificationsSettings = lazy(() => import('../../pages/settings/notifications/notifications-settings'));
|
||||
const NotificationsSettings = lazy(
|
||||
() => import('../../pages/settings/notifications/notifications-settings')
|
||||
);
|
||||
const ClientsSettings = lazy(() => import('../../pages/settings/clients/clients-settings'));
|
||||
const JobTitlesSettings = lazy(() => import('@/pages/settings/job-titles/job-titles-settings'));
|
||||
const LabelsSettings = lazy(() => import('../../pages/settings/labels/labels-settings'));
|
||||
const CategoriesSettings = lazy(() => import('../../pages/settings/categories/categories-settings'));
|
||||
const ProjectTemplatesSettings = lazy(() => import('@/pages/settings/project-templates/project-templates-settings'));
|
||||
const TaskTemplatesSettings = lazy(() => import('@/pages/settings/task-templates/task-templates-settings'));
|
||||
const TeamMembersSettings = lazy(() => import('@/pages/settings/team-members/team-members-settings'));
|
||||
const CategoriesSettings = lazy(
|
||||
() => import('../../pages/settings/categories/categories-settings')
|
||||
);
|
||||
const ProjectTemplatesSettings = lazy(
|
||||
() => import('@/pages/settings/project-templates/project-templates-settings')
|
||||
);
|
||||
const TaskTemplatesSettings = lazy(
|
||||
() => import('@/pages/settings/task-templates/task-templates-settings')
|
||||
);
|
||||
const TeamMembersSettings = lazy(
|
||||
() => import('@/pages/settings/team-members/team-members-settings')
|
||||
);
|
||||
const TeamsSettings = lazy(() => import('../../pages/settings/teams/teams-settings'));
|
||||
const ChangePassword = lazy(() => import('@/pages/settings/change-password/change-password'));
|
||||
const LanguageAndRegionSettings = lazy(() => import('@/pages/settings/language-and-region/language-and-region-settings'));
|
||||
const LanguageAndRegionSettings = lazy(
|
||||
() => import('@/pages/settings/language-and-region/language-and-region-settings')
|
||||
);
|
||||
const AppearanceSettings = lazy(() => import('@/pages/settings/appearance/appearance-settings'));
|
||||
|
||||
// type of menu item in settings sidebar
|
||||
@@ -132,6 +146,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,739 @@
|
||||
import {
|
||||
Button,
|
||||
ConfigProvider,
|
||||
Flex,
|
||||
Select,
|
||||
Typography,
|
||||
message,
|
||||
Alert,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Tooltip,
|
||||
Input,
|
||||
Modal,
|
||||
CaretDownFilled,
|
||||
DownOutlined,
|
||||
CalculatorOutlined,
|
||||
SettingOutlined,
|
||||
EditOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import {
|
||||
fetchProjectFinances,
|
||||
setActiveTab,
|
||||
setActiveGroup,
|
||||
updateProjectFinanceCurrency,
|
||||
fetchProjectFinancesSilent,
|
||||
setBillableFilter,
|
||||
} from '@/features/projects/finance/project-finance.slice';
|
||||
import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice';
|
||||
import { updateProjectCurrency, getProject } from '@/features/project/project.slice';
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
import { RootState } from '@/app/store';
|
||||
import FinanceTableWrapper from '@/components/projects/project-finance/finance-table-wrapper/FinanceTableWrapper';
|
||||
import ImportRatecardsDrawer from '@/components/projects/import-ratecards-drawer/ImportRateCardsDrawer';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { hasFinanceEditPermission } from '@/utils/finance-permissions';
|
||||
import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/currencies';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import RateCardTable from '@/components/projects/project-finance/ratecard-table/RateCardTable';
|
||||
import ProjectBudgetSettingsDrawer from '@/components/projects/project-budget-settings-drawer/ProjectBudgetSettingsDrawer';
|
||||
|
||||
const ProjectViewFinance = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('project-view-finance');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [updatingCurrency, setUpdatingCurrency] = useState(false);
|
||||
const [updatingBudget, setUpdatingBudget] = useState(false);
|
||||
const [budgetModalVisible, setBudgetModalVisible] = useState(false);
|
||||
const [budgetValue, setBudgetValue] = useState<string>('');
|
||||
const [budgetSettingsDrawerVisible, setBudgetSettingsDrawerVisible] = useState(false);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
activeGroup,
|
||||
billableFilter,
|
||||
loading,
|
||||
taskGroups,
|
||||
project: financeProject,
|
||||
} = useAppSelector((state: RootState) => state.projectFinancesReducer);
|
||||
const { refreshTimestamp, project } = useAppSelector((state: RootState) => state.projectReducer);
|
||||
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
|
||||
|
||||
// Auth and permissions
|
||||
const auth = useAuthService();
|
||||
const currentSession = auth.getCurrentSession();
|
||||
const hasEditPermission = hasFinanceEditPermission(currentSession, project);
|
||||
|
||||
// Get project-specific currency from finance API response, fallback to project reducer, then default
|
||||
const projectCurrency = (
|
||||
financeProject?.currency ||
|
||||
project?.currency ||
|
||||
DEFAULT_CURRENCY
|
||||
).toLowerCase();
|
||||
|
||||
// Show loading state for currency selector until finance data is loaded
|
||||
const currencyLoading = loading || updatingCurrency || !financeProject;
|
||||
|
||||
// Calculate project budget statistics
|
||||
const budgetStatistics = useMemo(() => {
|
||||
if (!taskGroups || taskGroups.length === 0) {
|
||||
const manualBudget = project?.budget || 0;
|
||||
const hasManualBudget = !!(project?.budget && project.budget > 0);
|
||||
return {
|
||||
totalEstimatedHours: 0,
|
||||
totalFixedCost: 0,
|
||||
totalTimeBasedCost: 0,
|
||||
totalBudget: manualBudget,
|
||||
totalActualCost: 0,
|
||||
totalVariance: manualBudget,
|
||||
budgetUtilization: 0,
|
||||
manualBudget,
|
||||
hasManualBudget,
|
||||
};
|
||||
}
|
||||
|
||||
// Optimized calculation that avoids double counting in nested hierarchies
|
||||
const calculateTaskTotalsFlat = (tasks: any[]): any => {
|
||||
let totals = {
|
||||
totalEstimatedHours: 0,
|
||||
totalFixedCost: 0,
|
||||
totalTimeBasedCost: 0,
|
||||
};
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.sub_tasks && task.sub_tasks.length > 0) {
|
||||
totals.totalEstimatedHours += (task.estimated_seconds || 0) / 3600;
|
||||
totals.totalFixedCost += task.fixed_cost || 0;
|
||||
totals.totalTimeBasedCost += task.actual_cost_from_logs || 0;
|
||||
} else {
|
||||
totals.totalEstimatedHours += (task.estimated_seconds || 0) / 3600;
|
||||
totals.totalFixedCost += task.fixed_cost || 0;
|
||||
totals.totalTimeBasedCost += task.actual_cost_from_logs || 0;
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
};
|
||||
|
||||
const totals = taskGroups.reduce(
|
||||
(
|
||||
acc: { totalEstimatedHours: any; totalFixedCost: any; totalTimeBasedCost: any },
|
||||
group: { tasks: any[] }
|
||||
) => {
|
||||
const groupTotals = calculateTaskTotalsFlat(group.tasks);
|
||||
return {
|
||||
totalEstimatedHours: acc.totalEstimatedHours + groupTotals.totalEstimatedHours,
|
||||
totalFixedCost: acc.totalFixedCost + groupTotals.totalFixedCost,
|
||||
totalTimeBasedCost: acc.totalTimeBasedCost + groupTotals.totalTimeBasedCost,
|
||||
};
|
||||
},
|
||||
{
|
||||
totalEstimatedHours: 0,
|
||||
totalFixedCost: 0,
|
||||
totalTimeBasedCost: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const manualBudget = project?.budget || 0;
|
||||
const hasManualBudget = !!(project?.budget && project.budget > 0);
|
||||
|
||||
const totalActualCost = totals.totalTimeBasedCost + totals.totalFixedCost;
|
||||
const totalVariance = manualBudget - totalActualCost;
|
||||
const budgetUtilization = manualBudget > 0 ? (totalActualCost / manualBudget) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalEstimatedHours: totals.totalEstimatedHours,
|
||||
totalFixedCost: totals.totalFixedCost,
|
||||
totalTimeBasedCost: totals.totalTimeBasedCost,
|
||||
totalBudget: manualBudget,
|
||||
totalActualCost,
|
||||
totalVariance,
|
||||
budgetUtilization,
|
||||
manualBudget,
|
||||
hasManualBudget,
|
||||
};
|
||||
}, [taskGroups, project?.budget]);
|
||||
|
||||
// Silent refresh function for socket events
|
||||
const refreshFinanceData = useCallback(
|
||||
(resetExpansions = false) => {
|
||||
if (projectId) {
|
||||
dispatch(
|
||||
fetchProjectFinancesSilent({
|
||||
projectId,
|
||||
groupBy: activeGroup,
|
||||
billableFilter,
|
||||
resetExpansions,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[projectId, activeGroup, billableFilter, dispatch]
|
||||
);
|
||||
|
||||
// Socket event handlers
|
||||
const handleTaskEstimationChange = useCallback(() => {
|
||||
refreshFinanceData(true); // Reset expansions when task estimation changes
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
const handleTaskTimerStop = useCallback(() => {
|
||||
refreshFinanceData(true); // Reset expansions when timer stops (time logged changes)
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
const handleTaskProgressUpdate = useCallback(() => {
|
||||
refreshFinanceData(true); // Reset expansions when task progress updates
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
const handleTaskBillableChange = useCallback(() => {
|
||||
refreshFinanceData(true); // Reset expansions when billable status changes
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
// Additional socket event handlers for task drawer updates
|
||||
const handleTaskNameChange = useCallback(() => {
|
||||
refreshFinanceData(true); // Reset expansions when task name changes from drawer
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
const handleTaskStatusChange = useCallback(() => {
|
||||
refreshFinanceData(true); // Reset expansions when task status changes from drawer
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
const handleTaskPriorityChange = useCallback(() => {
|
||||
refreshFinanceData(true); // Reset expansions when task priority changes from drawer
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
const handleTaskPhaseChange = useCallback(() => {
|
||||
refreshFinanceData(true); // Reset expansions when task phase changes from drawer
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
const handleTaskAssigneesChange = useCallback(() => {
|
||||
refreshFinanceData(true); // Reset expansions when task assignees change from drawer
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
const handleTaskStartDateChange = useCallback(() => {
|
||||
refreshFinanceData(true); // Reset expansions when task start date changes from drawer
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
const handleTaskEndDateChange = useCallback(() => {
|
||||
refreshFinanceData(true); // Reset expansions when task end date changes from drawer
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
const handleProjectUpdatesAvailable = useCallback(() => {
|
||||
refreshFinanceData(true); // Reset expansions when project updates are available (includes task deletion)
|
||||
}, [refreshFinanceData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup, billableFilter }));
|
||||
}
|
||||
}, [projectId, activeGroup, billableFilter, dispatch, refreshTimestamp]);
|
||||
|
||||
// Socket event listeners for finance data refresh
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const eventHandlers = [
|
||||
{
|
||||
event: SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(),
|
||||
handler: handleTaskEstimationChange,
|
||||
},
|
||||
{ event: SocketEvents.TASK_TIMER_STOP.toString(), handler: handleTaskTimerStop },
|
||||
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdate },
|
||||
{ event: SocketEvents.TASK_BILLABLE_CHANGE.toString(), handler: handleTaskBillableChange },
|
||||
// Task drawer update events
|
||||
{ event: SocketEvents.TASK_NAME_CHANGE.toString(), handler: handleTaskNameChange },
|
||||
{ event: SocketEvents.TASK_STATUS_CHANGE.toString(), handler: handleTaskStatusChange },
|
||||
{ event: SocketEvents.TASK_PRIORITY_CHANGE.toString(), handler: handleTaskPriorityChange },
|
||||
{ event: SocketEvents.TASK_PHASE_CHANGE.toString(), handler: handleTaskPhaseChange },
|
||||
{ event: SocketEvents.TASK_ASSIGNEES_CHANGE.toString(), handler: handleTaskAssigneesChange },
|
||||
{ event: SocketEvents.TASK_START_DATE_CHANGE.toString(), handler: handleTaskStartDateChange },
|
||||
{ event: SocketEvents.TASK_END_DATE_CHANGE.toString(), handler: handleTaskEndDateChange },
|
||||
{
|
||||
event: SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(),
|
||||
handler: handleProjectUpdatesAvailable,
|
||||
},
|
||||
];
|
||||
|
||||
// Register all event listeners
|
||||
eventHandlers.forEach(({ event, handler }) => {
|
||||
socket.on(event, handler);
|
||||
});
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
eventHandlers.forEach(({ event, handler }) => {
|
||||
socket.off(event, handler);
|
||||
});
|
||||
};
|
||||
}, [
|
||||
socket,
|
||||
handleTaskEstimationChange,
|
||||
handleTaskTimerStop,
|
||||
handleTaskProgressUpdate,
|
||||
handleTaskBillableChange,
|
||||
handleTaskNameChange,
|
||||
handleTaskStatusChange,
|
||||
handleTaskPriorityChange,
|
||||
handleTaskPhaseChange,
|
||||
handleTaskAssigneesChange,
|
||||
handleTaskStartDateChange,
|
||||
handleTaskEndDateChange,
|
||||
handleProjectUpdatesAvailable,
|
||||
]);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!projectId) {
|
||||
message.error('Project ID not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setExporting(true);
|
||||
const blob = await projectFinanceApiService.exportFinanceData(
|
||||
projectId,
|
||||
activeGroup,
|
||||
billableFilter
|
||||
);
|
||||
|
||||
const projectName = project?.name || 'Unknown_Project';
|
||||
const sanitizedProjectName = projectName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_');
|
||||
const dateTime = new Date().toISOString().replace(/[:.]/g, '-').split('T');
|
||||
const date = dateTime[0];
|
||||
const time = dateTime[1].split('.')[0];
|
||||
const filename = `${sanitizedProjectName}_Finance_Data_${date}_${time}.xlsx`;
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
message.success('Finance data exported successfully');
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
message.error('Failed to export finance data');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCurrencyChange = async (currency: string) => {
|
||||
if (!projectId || !hasEditPermission) {
|
||||
message.error('You do not have permission to change the project currency');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUpdatingCurrency(true);
|
||||
const upperCaseCurrency = currency.toUpperCase();
|
||||
await projectFinanceApiService.updateProjectCurrency(projectId, upperCaseCurrency);
|
||||
|
||||
// Update both global currency state and project-specific currency
|
||||
dispatch(changeCurrency(currency));
|
||||
dispatch(updateProjectCurrency(upperCaseCurrency));
|
||||
dispatch(updateProjectFinanceCurrency(upperCaseCurrency));
|
||||
|
||||
message.success('Project currency updated successfully');
|
||||
} catch (error) {
|
||||
console.error('Currency update failed:', error);
|
||||
message.error('Failed to update project currency');
|
||||
} finally {
|
||||
setUpdatingCurrency(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBudgetUpdate = async () => {
|
||||
if (!projectId || !hasEditPermission) {
|
||||
message.error('You do not have permission to change the project budget');
|
||||
return;
|
||||
}
|
||||
|
||||
const budget = parseFloat(budgetValue);
|
||||
if (isNaN(budget) || budget < 0) {
|
||||
message.error('Please enter a valid budget amount');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUpdatingBudget(true);
|
||||
await projectFinanceApiService.updateProjectBudget(projectId, budget);
|
||||
|
||||
// Refresh the project data to get updated budget
|
||||
refreshFinanceData();
|
||||
|
||||
// Also refresh the main project data to update budget statistics
|
||||
dispatch(getProject(projectId));
|
||||
|
||||
message.success('Project budget updated successfully');
|
||||
setBudgetModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Budget update failed:', error);
|
||||
message.error('Failed to update project budget');
|
||||
} finally {
|
||||
setUpdatingBudget(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBudgetEdit = () => {
|
||||
setBudgetValue((project?.budget || 0).toString());
|
||||
setBudgetModalVisible(true);
|
||||
};
|
||||
|
||||
const handleBudgetCancel = () => {
|
||||
setBudgetModalVisible(false);
|
||||
setBudgetValue('');
|
||||
};
|
||||
|
||||
const groupDropdownMenuItems = [
|
||||
{ key: 'status', value: 'status', label: t('statusText') },
|
||||
{ key: 'priority', value: 'priority', label: t('priorityText') },
|
||||
{
|
||||
key: 'phases',
|
||||
value: 'phases',
|
||||
label: phaseList.length > 0 ? project?.phase_label || t('phaseText') : t('phaseText'),
|
||||
},
|
||||
];
|
||||
|
||||
const billableFilterOptions = [
|
||||
{ key: 'billable', value: 'billable', label: t('billableOnlyText') },
|
||||
{ key: 'non-billable', value: 'non-billable', label: t('nonBillableOnlyText') },
|
||||
{ key: 'all', value: 'all', label: t('allTasksText') },
|
||||
];
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||
{/* Finance Header */}
|
||||
<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={() => dispatch(setActiveTab('finance'))}
|
||||
>
|
||||
{t('financeText')}
|
||||
</Button>
|
||||
<Button
|
||||
className={`${activeTab === 'ratecard' && 'border-[#1890ff] text-[#1890ff]'} rounded-l-none`}
|
||||
onClick={() => dispatch(setActiveTab('ratecard'))}
|
||||
>
|
||||
{t('ratecardSingularText')}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{activeTab === 'finance' && (
|
||||
<Flex align="center" gap={16} style={{ marginInlineStart: 12 }}>
|
||||
<Flex align="center" gap={4}>
|
||||
{t('groupByText')}:
|
||||
<Select
|
||||
value={activeGroup}
|
||||
options={groupDropdownMenuItems}
|
||||
onChange={value =>
|
||||
dispatch(setActiveGroup(value as 'status' | 'priority' | 'phases'))
|
||||
}
|
||||
suffixIcon={<CaretDownFilled />}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex align="center" gap={4}>
|
||||
{t('filterText')}:
|
||||
<Select
|
||||
value={billableFilter}
|
||||
options={billableFilterOptions}
|
||||
onChange={value =>
|
||||
dispatch(setBillableFilter(value as 'all' | 'billable' | 'non-billable'))
|
||||
}
|
||||
suffixIcon={<CaretDownFilled />}
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{activeTab === 'finance' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownOutlined />}
|
||||
iconPosition="end"
|
||||
loading={exporting}
|
||||
onClick={handleExport}
|
||||
>
|
||||
{t('exportButton')}
|
||||
</Button>
|
||||
) : (
|
||||
<Flex gap={8} align="center">
|
||||
<Flex gap={8} align="center">
|
||||
<Typography.Text>{t('currencyText')}</Typography.Text>
|
||||
<Select
|
||||
value={projectCurrency}
|
||||
loading={currencyLoading}
|
||||
disabled={!hasEditPermission}
|
||||
options={CURRENCY_OPTIONS}
|
||||
onChange={handleCurrencyChange}
|
||||
/>
|
||||
</Flex>
|
||||
<Button type="primary" onClick={() => dispatch(toggleImportRatecardsDrawer())}>
|
||||
{t('importButton')}
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</ConfigProvider>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'finance' ? (
|
||||
<div>
|
||||
{!hasEditPermission && (
|
||||
<Alert
|
||||
message="Limited Access"
|
||||
description="You can view finance data but cannot edit fixed costs. Only project managers, team admins, and team owners can make changes."
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Budget Statistics */}
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex align="center" gap={8}>
|
||||
<CalculatorOutlined />
|
||||
<Typography.Text strong>{t('projectBudgetOverviewText')}</Typography.Text>
|
||||
{!budgetStatistics.hasManualBudget && (
|
||||
<Typography.Text type="warning" style={{ fontSize: '12px' }}>
|
||||
{t('budgetStatistics.noManualBudgetSet')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Flex>
|
||||
{hasEditPermission && (
|
||||
<Tooltip title="Budget & Calculation Settings">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
size="small"
|
||||
onClick={() => setBudgetSettingsDrawerVisible(true)}
|
||||
style={{ color: '#666' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
loading={loading}
|
||||
size="small"
|
||||
>
|
||||
<Row gutter={[12, 8]}>
|
||||
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
|
||||
<Tooltip title={t('budgetOverviewTooltips.manualBudget')}>
|
||||
<div style={{ textAlign: 'center', position: 'relative' }}>
|
||||
<Statistic
|
||||
title={
|
||||
<Flex align="center" justify="center" gap={4}>
|
||||
<span>{t('budgetStatistics.manualBudget')}</span>
|
||||
{hasEditPermission && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={handleBudgetEdit}
|
||||
style={{
|
||||
padding: '0 4px',
|
||||
height: '16px',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
value={budgetStatistics.totalBudget}
|
||||
precision={2}
|
||||
prefix={projectCurrency.toUpperCase()}
|
||||
valueStyle={{
|
||||
color: budgetStatistics.hasManualBudget ? '#1890ff' : '#d9d9d9',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
|
||||
<Tooltip title={t('budgetOverviewTooltips.totalActualCost')}>
|
||||
<Statistic
|
||||
title={t('budgetStatistics.totalActualCost')}
|
||||
value={budgetStatistics.totalActualCost}
|
||||
precision={2}
|
||||
prefix={projectCurrency.toUpperCase()}
|
||||
valueStyle={{ color: '#52c41a', fontSize: '16px' }}
|
||||
style={{ textAlign: 'center' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
|
||||
<Tooltip title={t('budgetOverviewTooltips.variance')}>
|
||||
<Statistic
|
||||
title={t('budgetStatistics.variance')}
|
||||
value={Math.abs(budgetStatistics.totalVariance)}
|
||||
precision={2}
|
||||
prefix={budgetStatistics.totalVariance >= 0 ? '+' : '-'}
|
||||
suffix={` ${projectCurrency.toUpperCase()}`}
|
||||
valueStyle={{
|
||||
color:
|
||||
budgetStatistics.totalVariance < 0
|
||||
? '#ff4d4f'
|
||||
: budgetStatistics.totalVariance > 0
|
||||
? '#52c41a'
|
||||
: '#666666',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
style={{ textAlign: 'center' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
|
||||
<Tooltip title={t('budgetOverviewTooltips.utilization')}>
|
||||
<Statistic
|
||||
title={t('budgetStatistics.budgetUtilization')}
|
||||
value={budgetStatistics.budgetUtilization}
|
||||
precision={1}
|
||||
suffix="%"
|
||||
valueStyle={{
|
||||
color:
|
||||
budgetStatistics.budgetUtilization > 100
|
||||
? '#ff4d4f'
|
||||
: budgetStatistics.budgetUtilization > 80
|
||||
? '#faad14'
|
||||
: '#52c41a',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
style={{ textAlign: 'center' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
|
||||
<Tooltip title={t('budgetOverviewTooltips.estimatedHours')}>
|
||||
<Statistic
|
||||
title={t('budgetStatistics.estimatedHours')}
|
||||
value={budgetStatistics.totalEstimatedHours}
|
||||
precision={1}
|
||||
suffix="h"
|
||||
valueStyle={{ color: '#722ed1', fontSize: '16px' }}
|
||||
style={{ textAlign: 'center' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
|
||||
<Tooltip title={t('budgetOverviewTooltips.fixedCosts')}>
|
||||
<Statistic
|
||||
title={t('budgetStatistics.fixedCosts')}
|
||||
value={budgetStatistics.totalFixedCost}
|
||||
precision={2}
|
||||
prefix={projectCurrency.toUpperCase()}
|
||||
valueStyle={{ color: '#fa8c16', fontSize: '16px' }}
|
||||
style={{ textAlign: 'center' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
|
||||
<Tooltip title={t('budgetOverviewTooltips.timeBasedCost')}>
|
||||
<Statistic
|
||||
title={t('budgetStatistics.timeBasedCost')}
|
||||
value={budgetStatistics.totalTimeBasedCost}
|
||||
precision={2}
|
||||
prefix={projectCurrency.toUpperCase()}
|
||||
valueStyle={{ color: '#13c2c2', fontSize: '16px' }}
|
||||
style={{ textAlign: 'center' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={4} xl={3}>
|
||||
<Tooltip title={t('budgetOverviewTooltips.remainingBudget')}>
|
||||
<Statistic
|
||||
title={t('budgetStatistics.remainingBudget')}
|
||||
value={Math.abs(budgetStatistics.totalVariance)}
|
||||
precision={2}
|
||||
prefix={budgetStatistics.totalVariance >= 0 ? '+' : '-'}
|
||||
suffix={` ${projectCurrency.toUpperCase()}`}
|
||||
valueStyle={{
|
||||
color: budgetStatistics.totalVariance >= 0 ? '#52c41a' : '#ff4d4f',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
style={{ textAlign: 'center' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<FinanceTableWrapper activeTablesList={taskGroups} loading={loading} />
|
||||
</div>
|
||||
) : (
|
||||
<Flex vertical gap={8}>
|
||||
{!hasEditPermission && (
|
||||
<Alert
|
||||
message="Limited Access"
|
||||
description="You can view rate card data but cannot edit rates or manage member assignments. Only project managers, team admins, and team owners can make changes."
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
<RateCardTable />
|
||||
<Typography.Text type="danger" style={{ display: 'block', marginTop: '10px' }}>
|
||||
{t('ratecardImportantNotice')}
|
||||
</Typography.Text>
|
||||
<ImportRatecardsDrawer />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* Budget Edit Modal */}
|
||||
<Modal
|
||||
title={t('budgetModal.title')}
|
||||
open={budgetModalVisible}
|
||||
onOk={handleBudgetUpdate}
|
||||
onCancel={handleBudgetCancel}
|
||||
confirmLoading={updatingBudget}
|
||||
okText={t('budgetModal.saveButton')}
|
||||
cancelText={t('budgetModal.cancelButton')}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">{t('budgetModal.description')}</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={budgetValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setBudgetValue(e.target.value)}
|
||||
placeholder={t('budgetModal.placeholder')}
|
||||
prefix={projectCurrency.toUpperCase()}
|
||||
size="large"
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Budget Settings Drawer */}
|
||||
<ProjectBudgetSettingsDrawer
|
||||
visible={budgetSettingsDrawerVisible}
|
||||
onClose={() => setBudgetSettingsDrawerVisible(false)}
|
||||
projectId={projectId!}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewFinance;
|
||||
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Flex,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleFilled,
|
||||
SearchOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import type { TablePaginationConfig } from 'antd/es/table';
|
||||
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||
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 { 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';
|
||||
import RateCardDrawer from '@/components/projects/project-finance/rate-card-drawer/RateCardDrawer';
|
||||
|
||||
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();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
useDocumentTitle('Manage Rate Cards');
|
||||
|
||||
// Redux state
|
||||
const isDrawerOpen = useAppSelector(state => state.financeReducer.isRatecardDrawerOpen);
|
||||
|
||||
// Local state
|
||||
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',
|
||||
});
|
||||
|
||||
// Memoized filtered data
|
||||
const filteredRatecardsData = useMemo(() => {
|
||||
return ratecardsList.filter(item =>
|
||||
item.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [ratecardsList, searchQuery]);
|
||||
|
||||
// Fetch rate cards with error handling
|
||||
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 }));
|
||||
} else {
|
||||
messageApi.error(t('fetchError') || 'Failed to fetch rate cards');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch rate cards:', error);
|
||||
messageApi.error(t('fetchError') || 'Failed to fetch rate cards');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
pagination.current,
|
||||
pagination.pageSize,
|
||||
pagination.field,
|
||||
pagination.order,
|
||||
searchQuery,
|
||||
t,
|
||||
messageApi,
|
||||
]);
|
||||
|
||||
// Fetch rate cards when drawer state changes
|
||||
useEffect(() => {
|
||||
fetchRateCards();
|
||||
}, [fetchRateCards, isDrawerOpen]);
|
||||
|
||||
// Handle rate card creation
|
||||
const handleRatecardCreate = useCallback(async () => {
|
||||
try {
|
||||
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());
|
||||
} else {
|
||||
messageApi.error(t('createError') || 'Failed to create rate card');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create rate card:', error);
|
||||
messageApi.error(t('createError') || 'Failed to create rate card');
|
||||
}
|
||||
}, [dispatch, t, messageApi]);
|
||||
|
||||
// Handle rate card update
|
||||
const handleRatecardUpdate = useCallback(
|
||||
(id: string) => {
|
||||
setRatecardDrawerType('update');
|
||||
dispatch(fetchRateCardById(id));
|
||||
setSelectedRatecardId(id);
|
||||
dispatch(toggleRatecardDrawer());
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Handle table changes
|
||||
const handleTableChange = useCallback(
|
||||
(
|
||||
newPagination: TablePaginationConfig,
|
||||
filters: Record<string, FilterValue | null>,
|
||||
sorter: SorterResult<RatecardType> | SorterResult<RatecardType>[]
|
||||
) => {
|
||||
const sorterResult = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: newPagination.current || 1,
|
||||
pageSize: newPagination.pageSize || DEFAULT_PAGE_SIZE,
|
||||
field: (sorterResult?.field as string) || 'name',
|
||||
order: sorterResult?.order === 'ascend' ? 'asc' : 'desc',
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Table columns configuration
|
||||
const columns: TableProps['columns'] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'rateName',
|
||||
title: t('nameColumn'),
|
||||
render: (record: RatecardType) => (
|
||||
<Typography.Text
|
||||
style={{ color: '#1890ff', cursor: 'pointer' }}
|
||||
onClick={() => record.id && handleRatecardUpdate(record.id)}
|
||||
>
|
||||
{record.name}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
title: t('createdColumn'),
|
||||
render: (record: RatecardType) => (
|
||||
<Typography.Text onClick={() => record.id && handleRatecardUpdate(record.id)}>
|
||||
{durationDateFormat(record.created_at)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
width: 80,
|
||||
render: (record: RatecardType) => (
|
||||
<Flex gap={8} className="hidden group-hover:flex">
|
||||
<Tooltip title={t('editTooltip') || '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);
|
||||
try {
|
||||
if (record.id) {
|
||||
await dispatch(deleteRateCard(record.id));
|
||||
await fetchRateCards();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete rate card:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip title={t('deleteTooltip') || 'Delete'}>
|
||||
<Button shape="default" icon={<DeleteOutlined />} size="small" />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, handleRatecardUpdate, fetchRateCards, dispatch, messageApi]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<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"
|
||||
locale={{
|
||||
emptyText: <Empty description={t('noRatecardsFound')} />,
|
||||
}}
|
||||
/>
|
||||
<RateCardDrawer
|
||||
type={ratecardDrawerType}
|
||||
ratecardId={selectedRatecardId || ''}
|
||||
onSaved={fetchRateCards}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatecardSettings;
|
||||
@@ -184,6 +184,9 @@ export {
|
||||
CloudDownloadOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
CalculatorOutlined,
|
||||
DollarOutlined,
|
||||
DollarCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
// Re-export all components with React
|
||||
|
||||
54
worklenz-frontend/src/shared/currencies.ts
Normal file
54
worklenz-frontend/src/shared/currencies.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface CurrencyOption {
|
||||
value: string;
|
||||
label: string;
|
||||
symbol?: string;
|
||||
}
|
||||
|
||||
export const CURRENCY_OPTIONS: CurrencyOption[] = [
|
||||
{ value: 'usd', label: 'USD - US Dollar', symbol: '$' },
|
||||
{ value: 'eur', label: 'EUR - Euro', symbol: '€' },
|
||||
{ value: 'gbp', label: 'GBP - British Pound', symbol: '£' },
|
||||
{ value: 'jpy', label: 'JPY - Japanese Yen', symbol: '¥' },
|
||||
{ value: 'cad', label: 'CAD - Canadian Dollar', symbol: 'C$' },
|
||||
{ value: 'aud', label: 'AUD - Australian Dollar', symbol: 'A$' },
|
||||
{ value: 'chf', label: 'CHF - Swiss Franc', symbol: 'CHF' },
|
||||
{ value: 'cny', label: 'CNY - Chinese Yuan', symbol: '¥' },
|
||||
{ value: 'inr', label: 'INR - Indian Rupee', symbol: '₹' },
|
||||
{ value: 'lkr', label: 'LKR - Sri Lankan Rupee', symbol: 'Rs' },
|
||||
{ value: 'sgd', label: 'SGD - Singapore Dollar', symbol: 'S$' },
|
||||
{ value: 'hkd', label: 'HKD - Hong Kong Dollar', symbol: 'HK$' },
|
||||
{ value: 'nzd', label: 'NZD - New Zealand Dollar', symbol: 'NZ$' },
|
||||
{ value: 'sek', label: 'SEK - Swedish Krona', symbol: 'kr' },
|
||||
{ value: 'nok', label: 'NOK - Norwegian Krone', symbol: 'kr' },
|
||||
{ value: 'dkk', label: 'DKK - Danish Krone', symbol: 'kr' },
|
||||
{ value: 'pln', label: 'PLN - Polish Zloty', symbol: 'zł' },
|
||||
{ value: 'czk', label: 'CZK - Czech Koruna', symbol: 'Kč' },
|
||||
{ value: 'huf', label: 'HUF - Hungarian Forint', symbol: 'Ft' },
|
||||
{ value: 'rub', label: 'RUB - Russian Ruble', symbol: '₽' },
|
||||
{ value: 'brl', label: 'BRL - Brazilian Real', symbol: 'R$' },
|
||||
{ value: 'mxn', label: 'MXN - Mexican Peso', symbol: '$' },
|
||||
{ value: 'zar', label: 'ZAR - South African Rand', symbol: 'R' },
|
||||
{ value: 'krw', label: 'KRW - South Korean Won', symbol: '₩' },
|
||||
{ value: 'thb', label: 'THB - Thai Baht', symbol: '฿' },
|
||||
{ value: 'myr', label: 'MYR - Malaysian Ringgit', symbol: 'RM' },
|
||||
{ value: 'idr', label: 'IDR - Indonesian Rupiah', symbol: 'Rp' },
|
||||
{ value: 'php', label: 'PHP - Philippine Peso', symbol: '₱' },
|
||||
{ value: 'vnd', label: 'VND - Vietnamese Dong', symbol: '₫' },
|
||||
{ value: 'aed', label: 'AED - UAE Dirham', symbol: 'د.إ' },
|
||||
{ value: 'sar', label: 'SAR - Saudi Riyal', symbol: '﷼' },
|
||||
{ value: 'egp', label: 'EGP - Egyptian Pound', symbol: '£' },
|
||||
{ value: 'try', label: 'TRY - Turkish Lira', symbol: '₺' },
|
||||
{ value: 'ils', label: 'ILS - Israeli Shekel', symbol: '₪' },
|
||||
];
|
||||
|
||||
export const DEFAULT_CURRENCY = 'usd';
|
||||
|
||||
export const getCurrencySymbol = (currencyCode: string): string => {
|
||||
const currency = CURRENCY_OPTIONS.find(c => c.value === currencyCode.toLowerCase());
|
||||
return currency?.symbol || currencyCode.toUpperCase();
|
||||
};
|
||||
|
||||
export const getCurrencyLabel = (currencyCode: string): string => {
|
||||
const currency = CURRENCY_OPTIONS.find(c => c.value === currencyCode.toLowerCase());
|
||||
return currency?.label || currencyCode.toUpperCase();
|
||||
};
|
||||
127
worklenz-frontend/src/types/project/project-finance.types.ts
Normal file
127
worklenz-frontend/src/types/project/project-finance.types.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
export interface IProjectFinanceUser {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceJobTitle {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceMember {
|
||||
team_member_id: string;
|
||||
project_member_id: string;
|
||||
name: string;
|
||||
email_notifications_enabled: boolean;
|
||||
avatar_url: string | null;
|
||||
user_id: string;
|
||||
email: string;
|
||||
socket_id: string | null;
|
||||
team_id: string;
|
||||
color_code: string;
|
||||
project_rate_card_role_id: string | null;
|
||||
rate: number;
|
||||
man_day_rate: number;
|
||||
job_title_id: string | null;
|
||||
job_title_name: string | null;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceTask {
|
||||
id: string;
|
||||
name: string;
|
||||
estimated_seconds: number;
|
||||
total_minutes: number; // Total estimated time in minutes
|
||||
estimated_hours: string; // Formatted time string like "4h 30m 12s"
|
||||
|
||||
total_time_logged_seconds: number;
|
||||
total_time_logged: string; // Formatted time string like "4h 30m 12s"
|
||||
estimated_cost: number;
|
||||
actual_cost_from_logs: number;
|
||||
members: IProjectFinanceMember[];
|
||||
billable: boolean;
|
||||
fixed_cost: number;
|
||||
variance: number; // Cost variance (currency)
|
||||
effort_variance_man_days?: number | null; // Effort variance in man days (only for man_days projects)
|
||||
actual_man_days?: number | null; // Actual man days spent (only for man_days projects)
|
||||
total_budget: number;
|
||||
total_actual: number;
|
||||
sub_tasks_count: number; // Number of subtasks
|
||||
sub_tasks?: IProjectFinanceTask[]; // Loaded subtasks
|
||||
show_sub_tasks?: boolean; // Whether subtasks are expanded
|
||||
is_sub_task?: boolean; // Whether this is a subtask
|
||||
parent_task_id?: string; // Parent task ID for subtasks
|
||||
}
|
||||
|
||||
export interface IProjectFinanceGroup {
|
||||
group_id: string;
|
||||
group_name: string;
|
||||
color_code: string;
|
||||
color_code_dark: string;
|
||||
tasks: IProjectFinanceTask[];
|
||||
}
|
||||
|
||||
export interface IProjectRateCard {
|
||||
id: string;
|
||||
project_id: string;
|
||||
job_title_id: string;
|
||||
rate: string;
|
||||
man_day_rate: string;
|
||||
job_title_name: string;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceProject {
|
||||
id: string;
|
||||
name: string;
|
||||
currency: string;
|
||||
calculation_method: 'hourly' | 'man_days';
|
||||
hours_per_day: number;
|
||||
}
|
||||
|
||||
export interface IProjectFinanceResponse {
|
||||
groups: IProjectFinanceGroup[];
|
||||
project_rate_cards: IProjectRateCard[];
|
||||
project: IProjectFinanceProject;
|
||||
}
|
||||
|
||||
export interface ITaskBreakdownMember {
|
||||
team_member_id: string;
|
||||
name: string;
|
||||
avatar_url: string;
|
||||
hourly_rate: number;
|
||||
estimated_hours: number;
|
||||
logged_hours: number;
|
||||
estimated_cost: number;
|
||||
actual_cost: number;
|
||||
}
|
||||
|
||||
export interface ITaskBreakdownJobRole {
|
||||
jobRole: string;
|
||||
estimated_hours: number;
|
||||
logged_hours: number;
|
||||
estimated_cost: number;
|
||||
actual_cost: number;
|
||||
members: ITaskBreakdownMember[];
|
||||
}
|
||||
|
||||
export interface ITaskBreakdownTask {
|
||||
id: string;
|
||||
name: string;
|
||||
project_id: string;
|
||||
billable: boolean;
|
||||
estimated_hours: number;
|
||||
logged_hours: number;
|
||||
estimated_labor_cost: number;
|
||||
actual_labor_cost: number;
|
||||
fixed_cost: number;
|
||||
total_estimated_cost: number;
|
||||
total_actual_cost: number;
|
||||
}
|
||||
|
||||
export interface ITaskBreakdownResponse {
|
||||
task: ITaskBreakdownTask;
|
||||
grouped_members: ITaskBreakdownJobRole[];
|
||||
members: Array<ITaskBreakdownMember & { job_title_name: string }>;
|
||||
}
|
||||
|
||||
export type ProjectFinanceGroupType = 'status' | 'priority' | 'phases';
|
||||
@@ -65,4 +65,7 @@ export interface IProjectViewModel extends IProject {
|
||||
use_manual_progress?: boolean;
|
||||
use_weighted_progress?: boolean;
|
||||
use_time_progress?: boolean;
|
||||
currency?: string;
|
||||
budget?: number;
|
||||
calculation_method?: 'hourly' | 'man_days';
|
||||
}
|
||||
|
||||
32
worklenz-frontend/src/types/project/ratecard.types.ts
Normal file
32
worklenz-frontend/src/types/project/ratecard.types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface IJobType {
|
||||
id?: string;
|
||||
jobId?: string;
|
||||
jobtitle?: string;
|
||||
ratePerHour?: number;
|
||||
rate_card_id?: string;
|
||||
job_title_id?: string;
|
||||
rate?: number;
|
||||
man_day_rate?: number;
|
||||
name?: string;
|
||||
}
|
||||
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[];
|
||||
}
|
||||
|
||||
export interface IProjectRateCardRole {
|
||||
project_id: string;
|
||||
roles: IJobType[];
|
||||
}
|
||||
81
worklenz-frontend/src/utils/finance-permissions.ts
Normal file
81
worklenz-frontend/src/utils/finance-permissions.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ILocalSession } from '@/types/auth/local-session.types';
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
|
||||
/**
|
||||
* Checks if the current user has permission to edit finance data
|
||||
* Only users with project admin, admin or owner roles should be able to:
|
||||
* - Change fixed cost values
|
||||
* - Add members to rate cards
|
||||
* - Change rate per hour values
|
||||
*/
|
||||
export const hasFinanceEditPermission = (
|
||||
currentSession: ILocalSession | null,
|
||||
currentProject?: IProjectViewModel | null
|
||||
): boolean => {
|
||||
if (!currentSession) return false;
|
||||
|
||||
// Team owner or admin always have permission
|
||||
if (currentSession.owner || currentSession.is_admin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Project manager has permission
|
||||
if (currentProject?.project_manager?.id === currentSession.team_member_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the current user has permission to view finance data
|
||||
* Only project managers, admins, and owners should be able to view the finance tab
|
||||
*/
|
||||
export const hasFinanceViewPermission = (
|
||||
currentSession: ILocalSession | null,
|
||||
currentProject?: IProjectViewModel | null
|
||||
): boolean => {
|
||||
if (!currentSession) return false;
|
||||
|
||||
// Team owner or admin always have permission
|
||||
if (currentSession.owner || currentSession.is_admin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Project manager has permission
|
||||
if (currentProject?.project_manager?.id === currentSession.team_member_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the current user can edit fixed costs
|
||||
*/
|
||||
export const canEditFixedCost = (
|
||||
currentSession: ILocalSession | null,
|
||||
currentProject?: IProjectViewModel | null
|
||||
): boolean => {
|
||||
return hasFinanceEditPermission(currentSession, currentProject);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the current user can edit rate card data
|
||||
*/
|
||||
export const canEditRateCard = (
|
||||
currentSession: ILocalSession | null,
|
||||
currentProject?: IProjectViewModel | null
|
||||
): boolean => {
|
||||
return hasFinanceEditPermission(currentSession, currentProject);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the current user can add members to rate cards
|
||||
*/
|
||||
export const canAddMembersToRateCard = (
|
||||
currentSession: ILocalSession | null,
|
||||
currentProject?: IProjectViewModel | null
|
||||
): boolean => {
|
||||
return hasFinanceEditPermission(currentSession, currentProject);
|
||||
};
|
||||
178
worklenz-frontend/src/utils/man-days-utils.ts
Normal file
178
worklenz-frontend/src/utils/man-days-utils.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Utility functions for converting between hours and man days
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert hours to man days
|
||||
* @param hours - Number of hours
|
||||
* @param hoursPerDay - Working hours per day (default: 8)
|
||||
* @returns Number of man days
|
||||
*/
|
||||
export const hoursToManDays = (hours: number, hoursPerDay: number = 8): number => {
|
||||
if (hours <= 0 || hoursPerDay <= 0) return 0;
|
||||
return Number((hours / hoursPerDay).toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert man days to hours
|
||||
* @param manDays - Number of man days
|
||||
* @param hoursPerDay - Working hours per day (default: 8)
|
||||
* @returns Number of hours
|
||||
*/
|
||||
export const manDaysToHours = (manDays: number, hoursPerDay: number = 8): number => {
|
||||
if (manDays <= 0 || hoursPerDay <= 0) return 0;
|
||||
return Number((manDays * hoursPerDay).toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert seconds to man days
|
||||
* @param seconds - Number of seconds
|
||||
* @param hoursPerDay - Working hours per day (default: 8)
|
||||
* @returns Number of man days
|
||||
*/
|
||||
export const secondsToManDays = (seconds: number, hoursPerDay: number = 8): number => {
|
||||
if (seconds <= 0 || hoursPerDay <= 0) return 0;
|
||||
const hours = seconds / 3600;
|
||||
return hoursToManDays(hours, hoursPerDay);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert man days to seconds
|
||||
* @param manDays - Number of man days
|
||||
* @param hoursPerDay - Working hours per day (default: 8)
|
||||
* @returns Number of seconds
|
||||
*/
|
||||
export const manDaysToSeconds = (manDays: number, hoursPerDay: number = 8): number => {
|
||||
if (manDays <= 0 || hoursPerDay <= 0) return 0;
|
||||
const hours = manDaysToHours(manDays, hoursPerDay);
|
||||
return hours * 3600;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format man days for display
|
||||
* @param manDays - Number of man days
|
||||
* @param precision - Number of decimal places (default: 1)
|
||||
* @param hoursPerDay - Working hours per day (default: 8)
|
||||
* @returns Formatted string (e.g., '2d 3h 30m')
|
||||
*/
|
||||
export const formatManDays = (
|
||||
manDays: number,
|
||||
precision: number = 1,
|
||||
hoursPerDay: number = 8
|
||||
): string => {
|
||||
if (manDays <= 0) return '0d';
|
||||
|
||||
const days = Math.floor(manDays);
|
||||
const remainder = manDays - days;
|
||||
const totalHours = remainder * hoursPerDay;
|
||||
const hours = Math.floor(totalHours);
|
||||
const minutes = Math.round((totalHours - hours) * 60);
|
||||
|
||||
let result = '';
|
||||
if (days > 0) result += `${days}d`;
|
||||
if (hours > 0) result += (result ? ' ' : '') + `${hours}h`;
|
||||
if (minutes > 0) result += (result ? ' ' : '') + `${minutes}m`;
|
||||
if (!result) result = `${manDays.toFixed(precision)}d`;
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse man days from string input
|
||||
* @param input - String input (e.g., "2.5", "2.5d", "2.5 days")
|
||||
* @returns Number of man days or null if invalid
|
||||
*/
|
||||
export const parseManDays = (input: string): number | null => {
|
||||
if (!input || typeof input !== 'string') return null;
|
||||
|
||||
// Remove common suffixes and trim
|
||||
const cleaned = input
|
||||
.toLowerCase()
|
||||
.replace(/\s*(days?|d)\s*$/g, '')
|
||||
.trim();
|
||||
|
||||
const parsed = parseFloat(cleaned);
|
||||
|
||||
if (isNaN(parsed) || parsed < 0) return null;
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate cost based on man days and rate
|
||||
* @param manDays - Number of man days
|
||||
* @param manDayRate - Rate per man day
|
||||
* @returns Total cost
|
||||
*/
|
||||
export const calculateManDaysCost = (manDays: number, manDayRate: number): number => {
|
||||
if (manDays <= 0 || manDayRate <= 0) return 0;
|
||||
return Number((manDays * manDayRate).toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert hourly rate to man day rate
|
||||
* @param hourlyRate - Rate per hour
|
||||
* @param hoursPerDay - Working hours per day (default: 8)
|
||||
* @returns Rate per man day
|
||||
*/
|
||||
export const hourlyRateToManDayRate = (hourlyRate: number, hoursPerDay: number = 8): number => {
|
||||
if (hourlyRate <= 0 || hoursPerDay <= 0) return 0;
|
||||
return Number((hourlyRate * hoursPerDay).toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert man day rate to hourly rate
|
||||
* @param manDayRate - Rate per man day
|
||||
* @param hoursPerDay - Working hours per day (default: 8)
|
||||
* @returns Rate per hour
|
||||
*/
|
||||
export const manDayRateToHourlyRate = (manDayRate: number, hoursPerDay: number = 8): number => {
|
||||
if (manDayRate <= 0 || hoursPerDay <= 0) return 0;
|
||||
return Number((manDayRate / hoursPerDay).toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate effort variance in man days
|
||||
* @param actualManDays - Actual man days spent
|
||||
* @param estimatedManDays - Estimated man days
|
||||
* @returns Effort variance in man days (positive = over estimate, negative = under estimate)
|
||||
*/
|
||||
export const calculateEffortVariance = (
|
||||
actualManDays: number,
|
||||
estimatedManDays: number
|
||||
): number => {
|
||||
if (actualManDays < 0 || estimatedManDays < 0) return 0;
|
||||
return Number((actualManDays - estimatedManDays).toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Format effort variance for display
|
||||
* @param varianceManDays - Variance in man days
|
||||
* @param precision - Number of decimal places (default: 1)
|
||||
* @returns Formatted string with sign and label
|
||||
*/
|
||||
export const formatEffortVariance = (varianceManDays: number, precision: number = 1): string => {
|
||||
if (varianceManDays === 0) return 'On track';
|
||||
|
||||
const absVariance = Math.abs(varianceManDays);
|
||||
const rounded = Number(absVariance.toFixed(precision));
|
||||
const sign = varianceManDays > 0 ? '+' : '-';
|
||||
|
||||
if (rounded === 1) {
|
||||
return `${sign}1 day`;
|
||||
} else if (rounded < 1) {
|
||||
return `${sign}${rounded}d`;
|
||||
} else {
|
||||
return `${sign}${rounded} days`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get variance status color based on effort variance
|
||||
* @param varianceManDays - Variance in man days
|
||||
* @returns Color code for UI display
|
||||
*/
|
||||
export const getVarianceColor = (varianceManDays: number): string => {
|
||||
if (varianceManDays === 0) return '#52c41a'; // Green - on track
|
||||
if (varianceManDays > 0) return '#ff4d4f'; // Red - over estimate
|
||||
return '#1890ff'; // Blue - under estimate
|
||||
};
|
||||
Reference in New Issue
Block a user