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:
chamikaJ
2025-07-24 15:23:34 +05:30
parent 4b54f2cc17
commit 4ffc3465e3
51 changed files with 10202 additions and 169 deletions

View File

@@ -6,7 +6,8 @@
"Bash(npm run type-check:*)",
"Bash(npm run:*)",
"Bash(mkdir:*)",
"Bash(cp:*)"
"Bash(cp:*)",
"Bash(ls:*)"
],
"deny": []
}

File diff suppressed because it is too large Load Diff

View 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));
}
}

View 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));
}
}

View File

@@ -56,7 +56,11 @@ 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();
@@ -117,4 +121,10 @@ 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;

View File

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

View File

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

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

View 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."
}
}

View File

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

View 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."
}
}

View File

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

View 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."
}
}

View File

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

View 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."
}
}

View File

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

View 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."
}
}

View File

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

View 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."
}
}

View File

@@ -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": "取消"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -184,6 +184,9 @@ export {
CloudDownloadOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
CalculatorOutlined,
DollarOutlined,
DollarCircleOutlined
} from '@ant-design/icons';
// Re-export all components with React

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

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

View File

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

View 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[];
}

View 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);
};

View 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
};