feat(finance): enhance project finance view with export functionality and UI improvements

- Implemented export functionality for finance data, allowing users to download project finance reports in Excel format.
- Refactored project finance header to streamline UI components and improve user experience.
- Removed deprecated FinanceTab and GroupByFilterDropdown components, consolidating functionality into the main finance view.
- Updated the project finance view to utilize new components for better organization and interaction.
- Enhanced group selection for finance data, allowing users to filter by status, priority, or phases.
This commit is contained in:
chamikaJ
2025-05-30 12:42:43 +05:30
parent 8cdc8b3ad0
commit 43c6701d3a
6 changed files with 326 additions and 369 deletions

View File

@@ -8,38 +8,37 @@ import HandleExceptions from "../decorators/handle-exceptions";
import { TASK_STATUS_COLOR_ALPHA } from "../shared/constants"; import { TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
import { getColor } from "../shared/utils"; import { getColor } from "../shared/utils";
import moment from "moment"; import moment from "moment";
import Excel from "exceljs";
const Excel = require("exceljs");
// Utility function to format time in hours, minutes, seconds format // Utility function to format time in hours, minutes, seconds format
const formatTimeToHMS = (totalSeconds: number): string => { const formatTimeToHMS = (totalSeconds: number): string => {
if (!totalSeconds || totalSeconds === 0) return "0s"; if (!totalSeconds || totalSeconds === 0) return "0s";
const hours = Math.floor(totalSeconds / 3600); const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60); const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60; const seconds = totalSeconds % 60;
const parts = []; const parts = [];
if (hours > 0) parts.push(`${hours}h`); if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`); if (minutes > 0) parts.push(`${minutes}m`);
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
return parts.join(' '); return parts.join(" ");
}; };
// Utility function to parse time string back to seconds for calculations // Utility function to parse time string back to seconds for calculations
const parseTimeToSeconds = (timeString: string): number => { const parseTimeToSeconds = (timeString: string): number => {
if (!timeString || timeString === "0s") return 0; if (!timeString || timeString === "0s") return 0;
let totalSeconds = 0; let totalSeconds = 0;
const hourMatch = timeString.match(/(\d+)h/); const hourMatch = timeString.match(/(\d+)h/);
const minuteMatch = timeString.match(/(\d+)m/); const minuteMatch = timeString.match(/(\d+)m/);
const secondMatch = timeString.match(/(\d+)s/); const secondMatch = timeString.match(/(\d+)s/);
if (hourMatch) totalSeconds += parseInt(hourMatch[1]) * 3600; if (hourMatch) totalSeconds += parseInt(hourMatch[1]) * 3600;
if (minuteMatch) totalSeconds += parseInt(minuteMatch[1]) * 60; if (minuteMatch) totalSeconds += parseInt(minuteMatch[1]) * 60;
if (secondMatch) totalSeconds += parseInt(secondMatch[1]); if (secondMatch) totalSeconds += parseInt(secondMatch[1]);
return totalSeconds; return totalSeconds;
}; };
@@ -50,7 +49,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
res: IWorkLenzResponse res: IWorkLenzResponse
): Promise<IWorkLenzResponse> { ): Promise<IWorkLenzResponse> {
const projectId = req.params.project_id; const projectId = req.params.project_id;
const groupBy = req.query.group || "status"; const groupBy = req.query.group_by || "status";
// First, get the project rate cards for this project // First, get the project rate cards for this project
const rateCardQuery = ` const rateCardQuery = `
@@ -65,7 +64,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
WHERE fprr.project_id = $1 WHERE fprr.project_id = $1
ORDER BY jt.name; ORDER BY jt.name;
`; `;
const rateCardResult = await db.query(rateCardQuery, [projectId]); const rateCardResult = await db.query(rateCardQuery, [projectId]);
const projectRateCards = rateCardResult.rows; const projectRateCards = rateCardResult.rows;
@@ -205,7 +204,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
if (Array.isArray(task.assignees)) { if (Array.isArray(task.assignees)) {
for (const assignee of task.assignees) { for (const assignee of task.assignees) {
assignee.color_code = getColor(assignee.name); assignee.color_code = getColor(assignee.name);
// Get the rate for this assignee using project_members.project_rate_card_role_id // Get the rate for this assignee using project_members.project_rate_card_role_id
const memberRateQuery = ` const memberRateQuery = `
SELECT SELECT
@@ -218,12 +217,16 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE pm.team_member_id = $1 AND pm.project_id = $2 WHERE pm.team_member_id = $1 AND pm.project_id = $2
`; `;
try { try {
const memberRateResult = await db.query(memberRateQuery, [assignee.team_member_id, projectId]); const memberRateResult = await db.query(memberRateQuery, [
assignee.team_member_id,
projectId,
]);
if (memberRateResult.rows.length > 0) { if (memberRateResult.rows.length > 0) {
const memberRate = memberRateResult.rows[0]; const memberRate = memberRateResult.rows[0];
assignee.project_rate_card_role_id = memberRate.project_rate_card_role_id; assignee.project_rate_card_role_id =
memberRate.project_rate_card_role_id;
assignee.rate = memberRate.rate ? Number(memberRate.rate) : 0; assignee.rate = memberRate.rate ? Number(memberRate.rate) : 0;
assignee.job_title_id = memberRate.job_title_id; assignee.job_title_id = memberRate.job_title_id;
assignee.job_title_name = memberRate.job_title_name; assignee.job_title_name = memberRate.job_title_name;
@@ -235,7 +238,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
assignee.job_title_name = null; assignee.job_title_name = null;
} }
} catch (error) { } catch (error) {
console.error("Error fetching member rate from project_members:", error); console.error(
"Error fetching member rate from project_members:",
error
);
assignee.project_rate_card_role_id = null; assignee.project_rate_card_role_id = null;
assignee.rate = 0; assignee.rate = 0;
assignee.job_title_id = null; assignee.job_title_id = null;
@@ -246,8 +252,13 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
} }
// Get groups based on groupBy parameter // Get groups based on groupBy parameter
let groups: Array<{ id: string; group_name: string; color_code: string; color_code_dark: string }> = []; let groups: Array<{
id: string;
group_name: string;
color_code: string;
color_code_dark: string;
}> = [];
if (groupBy === "status") { if (groupBy === "status") {
const q = ` const q = `
SELECT SELECT
@@ -284,7 +295,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
ORDER BY sort_index; ORDER BY sort_index;
`; `;
groups = (await db.query(q, [projectId])).rows; groups = (await db.query(q, [projectId])).rows;
// Add TASK_STATUS_COLOR_ALPHA to color codes // Add TASK_STATUS_COLOR_ALPHA to color codes
for (const group of groups) { for (const group of groups) {
group.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA; group.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA;
@@ -293,8 +304,8 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
} }
// Group tasks by the selected criteria // Group tasks by the selected criteria
const groupedTasks = groups.map(group => { const groupedTasks = groups.map((group) => {
const groupTasks = tasks.filter(task => { const groupTasks = tasks.filter((task) => {
if (groupBy === "status") return task.status_id === group.id; if (groupBy === "status") return task.status_id === group.id;
if (groupBy === "priority") return task.priority_id === group.id; if (groupBy === "priority") return task.priority_id === group.id;
if (groupBy === "phases") return task.phase_id === group.id; if (groupBy === "phases") return task.phase_id === group.id;
@@ -306,13 +317,16 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
group_name: group.group_name, group_name: group.group_name,
color_code: group.color_code, color_code: group.color_code,
color_code_dark: group.color_code_dark, color_code_dark: group.color_code_dark,
tasks: groupTasks.map(task => ({ tasks: groupTasks.map((task) => ({
id: task.id, id: task.id,
name: task.name, name: task.name,
estimated_seconds: Number(task.estimated_seconds) || 0, estimated_seconds: Number(task.estimated_seconds) || 0,
estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0),
total_time_logged_seconds: Number(task.total_time_logged_seconds) || 0, total_time_logged_seconds:
total_time_logged: formatTimeToHMS(Number(task.total_time_logged_seconds) || 0), Number(task.total_time_logged_seconds) || 0,
total_time_logged: formatTimeToHMS(
Number(task.total_time_logged_seconds) || 0
),
estimated_cost: Number(task.estimated_cost) || 0, estimated_cost: Number(task.estimated_cost) || 0,
fixed_cost: Number(task.fixed_cost) || 0, fixed_cost: Number(task.fixed_cost) || 0,
total_budget: Number(task.total_budget) || 0, total_budget: Number(task.total_budget) || 0,
@@ -320,15 +334,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
variance: Number(task.variance) || 0, variance: Number(task.variance) || 0,
members: task.assignees, members: task.assignees,
billable: task.billable, billable: task.billable,
sub_tasks_count: Number(task.sub_tasks_count) || 0 sub_tasks_count: Number(task.sub_tasks_count) || 0,
})) })),
}; };
}); });
// Include project rate cards in the response for reference // Include project rate cards in the response for reference
const responseData = { const responseData = {
groups: groupedTasks, groups: groupedTasks,
project_rate_cards: projectRateCards project_rate_cards: projectRateCards,
}; };
return res.status(200).send(new ServerResponse(true, responseData)); return res.status(200).send(new ServerResponse(true, responseData));
@@ -343,7 +357,9 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
const { fixed_cost } = req.body; const { fixed_cost } = req.body;
if (typeof fixed_cost !== "number" || fixed_cost < 0) { if (typeof fixed_cost !== "number" || fixed_cost < 0) {
return res.status(400).send(new ServerResponse(false, null, "Invalid fixed cost value")); return res
.status(400)
.send(new ServerResponse(false, null, "Invalid fixed cost value"));
} }
const q = ` const q = `
@@ -354,9 +370,11 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
`; `;
const result = await db.query(q, [fixed_cost, taskId]); const result = await db.query(q, [fixed_cost, taskId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
return res.status(404).send(new ServerResponse(false, null, "Task not found")); return res
.status(404)
.send(new ServerResponse(false, null, "Task not found"));
} }
return res.status(200).send(new ServerResponse(true, result.rows[0])); return res.status(200).send(new ServerResponse(true, result.rows[0]));
@@ -385,9 +403,11 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
`; `;
const taskResult = await db.query(taskQuery, [taskId]); const taskResult = await db.query(taskQuery, [taskId]);
if (taskResult.rows.length === 0) { if (taskResult.rows.length === 0) {
return res.status(404).send(new ServerResponse(false, null, "Task not found")); return res
.status(404)
.send(new ServerResponse(false, null, "Task not found"));
} }
const [task] = taskResult.rows; const [task] = taskResult.rows;
@@ -412,12 +432,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE tm.id = $2 WHERE tm.id = $2
`; `;
try { try {
const memberResult = await db.query(memberRateQuery, [task.project_id, assignee.team_member_id]); const memberResult = await db.query(memberRateQuery, [
task.project_id,
assignee.team_member_id,
]);
if (memberResult.rows.length > 0) { if (memberResult.rows.length > 0) {
const [member] = memberResult.rows; const [member] = memberResult.rows;
// Get actual time logged by this member for this task // Get actual time logged by this member for this task
const timeLogQuery = ` const timeLogQuery = `
SELECT COALESCE(SUM(time_spent), 0) / 3600.0 as logged_hours SELECT COALESCE(SUM(time_spent), 0) / 3600.0 as logged_hours
@@ -426,20 +449,31 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
LEFT JOIN team_members tm ON u.id = tm.user_id LEFT JOIN team_members tm ON u.id = tm.user_id
WHERE twl.task_id = $1 AND tm.id = $2 WHERE twl.task_id = $1 AND tm.id = $2
`; `;
const timeLogResult = await db.query(timeLogQuery, [taskId, member.team_member_id]); const timeLogResult = await db.query(timeLogQuery, [
const loggedHours = Number(timeLogResult.rows[0]?.logged_hours || 0); taskId,
member.team_member_id,
membersWithRates.push({ ]);
const loggedHours = Number(
timeLogResult.rows[0]?.logged_hours || 0
);
membersWithRates.push({
team_member_id: member.team_member_id, team_member_id: member.team_member_id,
name: member.name || "Unknown User", name: member.name || "Unknown User",
avatar_url: member.avatar_url, avatar_url: member.avatar_url,
hourly_rate: Number(member.hourly_rate || 0), hourly_rate: Number(member.hourly_rate || 0),
job_title_name: member.job_title_name || "Unassigned", job_title_name: member.job_title_name || "Unassigned",
estimated_hours: task.assignees.length > 0 ? Number(task.estimated_hours) / task.assignees.length : 0, estimated_hours:
task.assignees.length > 0
? Number(task.estimated_hours) / task.assignees.length
: 0,
logged_hours: loggedHours, logged_hours: loggedHours,
estimated_cost: (task.assignees.length > 0 ? Number(task.estimated_hours) / task.assignees.length : 0) * Number(member.hourly_rate || 0), estimated_cost:
actual_cost: loggedHours * Number(member.hourly_rate || 0) (task.assignees.length > 0
? Number(task.estimated_hours) / task.assignees.length
: 0) * Number(member.hourly_rate || 0),
actual_cost: loggedHours * Number(member.hourly_rate || 0),
}); });
} }
} catch (error) { } catch (error) {
@@ -450,17 +484,17 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
// Group members by job title and calculate totals // Group members by job title and calculate totals
const groupedMembers = membersWithRates.reduce((acc: any, member: any) => { const groupedMembers = membersWithRates.reduce((acc: any, member: any) => {
const jobRole = member.job_title_name || "Unassigned"; const jobRole = member.job_title_name || "Unassigned";
if (!acc[jobRole]) { if (!acc[jobRole]) {
acc[jobRole] = { acc[jobRole] = {
jobRole, jobRole,
estimated_hours: 0, estimated_hours: 0,
logged_hours: 0, logged_hours: 0,
estimated_cost: 0, estimated_cost: 0,
actual_cost: 0, actual_cost: 0,
members: [] members: [],
}; };
} }
acc[jobRole].estimated_hours += member.estimated_hours; acc[jobRole].estimated_hours += member.estimated_hours;
@@ -475,7 +509,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
estimated_hours: member.estimated_hours, estimated_hours: member.estimated_hours,
logged_hours: member.logged_hours, logged_hours: member.logged_hours,
estimated_cost: member.estimated_cost, estimated_cost: member.estimated_cost,
actual_cost: member.actual_cost actual_cost: member.actual_cost,
}); });
return acc; return acc;
@@ -485,11 +519,23 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
const taskTotals = { const taskTotals = {
estimated_hours: Number(task.estimated_hours || 0), estimated_hours: Number(task.estimated_hours || 0),
logged_hours: Number(task.total_time_logged || 0), logged_hours: Number(task.total_time_logged || 0),
estimated_labor_cost: membersWithRates.reduce((sum, member) => sum + member.estimated_cost, 0), estimated_labor_cost: membersWithRates.reduce(
actual_labor_cost: membersWithRates.reduce((sum, member) => sum + member.actual_cost, 0), (sum, member) => sum + member.estimated_cost,
0
),
actual_labor_cost: membersWithRates.reduce(
(sum, member) => sum + member.actual_cost,
0
),
fixed_cost: Number(task.fixed_cost || 0), fixed_cost: Number(task.fixed_cost || 0),
total_estimated_cost: membersWithRates.reduce((sum, member) => sum + member.estimated_cost, 0) + Number(task.fixed_cost || 0), total_estimated_cost:
total_actual_cost: membersWithRates.reduce((sum, member) => sum + member.actual_cost, 0) + Number(task.fixed_cost || 0) membersWithRates.reduce(
(sum, member) => sum + member.estimated_cost,
0
) + Number(task.fixed_cost || 0),
total_actual_cost:
membersWithRates.reduce((sum, member) => sum + member.actual_cost, 0) +
Number(task.fixed_cost || 0),
}; };
const responseData = { const responseData = {
@@ -498,10 +544,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
name: task.name, name: task.name,
project_id: task.project_id, project_id: task.project_id,
billable: task.billable, billable: task.billable,
...taskTotals ...taskTotals,
}, },
grouped_members: Object.values(groupedMembers), grouped_members: Object.values(groupedMembers),
members: membersWithRates members: membersWithRates,
}; };
return res.status(200).send(new ServerResponse(true, responseData)); return res.status(200).send(new ServerResponse(true, responseData));
@@ -516,7 +562,9 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
const parentTaskId = req.params.parent_task_id; const parentTaskId = req.params.parent_task_id;
if (!parentTaskId) { if (!parentTaskId) {
return res.status(400).send(new ServerResponse(false, null, "Parent task ID is required")); return res
.status(400)
.send(new ServerResponse(false, null, "Parent task ID is required"));
} }
// Get subtasks with their financial data // Get subtasks with their financial data
@@ -581,7 +629,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
if (Array.isArray(task.assignees)) { if (Array.isArray(task.assignees)) {
for (const assignee of task.assignees) { for (const assignee of task.assignees) {
assignee.color_code = getColor(assignee.name); assignee.color_code = getColor(assignee.name);
// Get the rate for this assignee // Get the rate for this assignee
const memberRateQuery = ` const memberRateQuery = `
SELECT SELECT
@@ -594,12 +642,16 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE pm.team_member_id = $1 AND pm.project_id = $2 WHERE pm.team_member_id = $1 AND pm.project_id = $2
`; `;
try { try {
const memberRateResult = await db.query(memberRateQuery, [assignee.team_member_id, projectId]); const memberRateResult = await db.query(memberRateQuery, [
assignee.team_member_id,
projectId,
]);
if (memberRateResult.rows.length > 0) { if (memberRateResult.rows.length > 0) {
const memberRate = memberRateResult.rows[0]; const memberRate = memberRateResult.rows[0];
assignee.project_rate_card_role_id = memberRate.project_rate_card_role_id; assignee.project_rate_card_role_id =
memberRate.project_rate_card_role_id;
assignee.rate = memberRate.rate ? Number(memberRate.rate) : 0; assignee.rate = memberRate.rate ? Number(memberRate.rate) : 0;
assignee.job_title_id = memberRate.job_title_id; assignee.job_title_id = memberRate.job_title_id;
assignee.job_title_name = memberRate.job_title_name; assignee.job_title_name = memberRate.job_title_name;
@@ -621,13 +673,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
} }
// Format the response to match the expected structure // Format the response to match the expected structure
const formattedTasks = tasks.map(task => ({ const formattedTasks = tasks.map((task) => ({
id: task.id, id: task.id,
name: task.name, name: task.name,
estimated_seconds: Number(task.estimated_seconds) || 0, estimated_seconds: Number(task.estimated_seconds) || 0,
estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0),
total_time_logged_seconds: Number(task.total_time_logged_seconds) || 0, total_time_logged_seconds: Number(task.total_time_logged_seconds) || 0,
total_time_logged: formatTimeToHMS(Number(task.total_time_logged_seconds) || 0), total_time_logged: formatTimeToHMS(
Number(task.total_time_logged_seconds) || 0
),
estimated_cost: Number(task.estimated_cost) || 0, estimated_cost: Number(task.estimated_cost) || 0,
fixed_cost: Number(task.fixed_cost) || 0, fixed_cost: Number(task.fixed_cost) || 0,
total_budget: Number(task.total_budget) || 0, total_budget: Number(task.total_budget) || 0,
@@ -635,7 +689,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
variance: Number(task.variance) || 0, variance: Number(task.variance) || 0,
members: task.assignees, members: task.assignees,
billable: task.billable, billable: task.billable,
sub_tasks_count: Number(task.sub_tasks_count) || 0 sub_tasks_count: Number(task.sub_tasks_count) || 0,
})); }));
return res.status(200).send(new ServerResponse(true, formattedTasks)); return res.status(200).send(new ServerResponse(true, formattedTasks));
@@ -647,12 +701,12 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
res: IWorkLenzResponse res: IWorkLenzResponse
): Promise<void> { ): Promise<void> {
const projectId = req.params.project_id; const projectId = req.params.project_id;
const groupBy = (req.query.groupBy as string) || 'status'; const groupBy = (req.query.groupBy as string) || "status";
// Get project name for filename // Get project name for filename
const projectNameQuery = `SELECT name FROM projects WHERE id = $1`; const projectNameQuery = `SELECT name FROM projects WHERE id = $1`;
const projectNameResult = await db.query(projectNameQuery, [projectId]); const projectNameResult = await db.query(projectNameQuery, [projectId]);
const projectName = projectNameResult.rows[0]?.name || 'Unknown Project'; const projectName = projectNameResult.rows[0]?.name || "Unknown Project";
// First, get the project rate cards for this project // First, get the project rate cards for this project
const rateCardQuery = ` const rateCardQuery = `
@@ -667,7 +721,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
WHERE fprr.project_id = $1 WHERE fprr.project_id = $1
ORDER BY jt.name; ORDER BY jt.name;
`; `;
const rateCardResult = await db.query(rateCardQuery, [projectId]); const rateCardResult = await db.query(rateCardQuery, [projectId]);
const projectRateCards = rateCardResult.rows; const projectRateCards = rateCardResult.rows;
@@ -807,7 +861,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
if (Array.isArray(task.assignees)) { if (Array.isArray(task.assignees)) {
for (const assignee of task.assignees) { for (const assignee of task.assignees) {
assignee.color_code = getColor(assignee.name); assignee.color_code = getColor(assignee.name);
// Get the rate for this assignee using project_members.project_rate_card_role_id // Get the rate for this assignee using project_members.project_rate_card_role_id
const memberRateQuery = ` const memberRateQuery = `
SELECT SELECT
@@ -820,12 +874,16 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE pm.team_member_id = $1 AND pm.project_id = $2 WHERE pm.team_member_id = $1 AND pm.project_id = $2
`; `;
try { try {
const memberRateResult = await db.query(memberRateQuery, [assignee.team_member_id, projectId]); const memberRateResult = await db.query(memberRateQuery, [
assignee.team_member_id,
projectId,
]);
if (memberRateResult.rows.length > 0) { if (memberRateResult.rows.length > 0) {
const memberRate = memberRateResult.rows[0]; const memberRate = memberRateResult.rows[0];
assignee.project_rate_card_role_id = memberRate.project_rate_card_role_id; assignee.project_rate_card_role_id =
memberRate.project_rate_card_role_id;
assignee.rate = memberRate.rate ? Number(memberRate.rate) : 0; assignee.rate = memberRate.rate ? Number(memberRate.rate) : 0;
assignee.job_title_id = memberRate.job_title_id; assignee.job_title_id = memberRate.job_title_id;
assignee.job_title_name = memberRate.job_title_name; assignee.job_title_name = memberRate.job_title_name;
@@ -837,7 +895,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
assignee.job_title_name = null; assignee.job_title_name = null;
} }
} catch (error) { } catch (error) {
console.error("Error fetching member rate from project_members:", error); console.error(
"Error fetching member rate from project_members:",
error
);
assignee.project_rate_card_role_id = null; assignee.project_rate_card_role_id = null;
assignee.rate = 0; assignee.rate = 0;
assignee.job_title_id = null; assignee.job_title_id = null;
@@ -848,8 +909,13 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
} }
// Get groups based on groupBy parameter // Get groups based on groupBy parameter
let groups: Array<{ id: string; group_name: string; color_code: string; color_code_dark: string }> = []; let groups: Array<{
id: string;
group_name: string;
color_code: string;
color_code_dark: string;
}> = [];
if (groupBy === "status") { if (groupBy === "status") {
const q = ` const q = `
SELECT SELECT
@@ -886,7 +952,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
ORDER BY sort_index; ORDER BY sort_index;
`; `;
groups = (await db.query(q, [projectId])).rows; groups = (await db.query(q, [projectId])).rows;
// Add TASK_STATUS_COLOR_ALPHA to color codes // Add TASK_STATUS_COLOR_ALPHA to color codes
for (const group of groups) { for (const group of groups) {
group.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA; group.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA;
@@ -895,8 +961,8 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
} }
// Group tasks by the selected criteria // Group tasks by the selected criteria
const groupedTasks = groups.map(group => { const groupedTasks = groups.map((group) => {
const groupTasks = tasks.filter(task => { const groupTasks = tasks.filter((task) => {
if (groupBy === "status") return task.status_id === group.id; if (groupBy === "status") return task.status_id === group.id;
if (groupBy === "priority") return task.priority_id === group.id; if (groupBy === "priority") return task.priority_id === group.id;
if (groupBy === "phases") return task.phase_id === group.id; if (groupBy === "phases") return task.phase_id === group.id;
@@ -908,13 +974,16 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
group_name: group.group_name, group_name: group.group_name,
color_code: group.color_code, color_code: group.color_code,
color_code_dark: group.color_code_dark, color_code_dark: group.color_code_dark,
tasks: groupTasks.map(task => ({ tasks: groupTasks.map((task) => ({
id: task.id, id: task.id,
name: task.name, name: task.name,
estimated_seconds: Number(task.estimated_seconds) || 0, estimated_seconds: Number(task.estimated_seconds) || 0,
estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0), estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0),
total_time_logged_seconds: Number(task.total_time_logged_seconds) || 0, total_time_logged_seconds:
total_time_logged: formatTimeToHMS(Number(task.total_time_logged_seconds) || 0), Number(task.total_time_logged_seconds) || 0,
total_time_logged: formatTimeToHMS(
Number(task.total_time_logged_seconds) || 0
),
estimated_cost: Number(task.estimated_cost) || 0, estimated_cost: Number(task.estimated_cost) || 0,
fixed_cost: Number(task.fixed_cost) || 0, fixed_cost: Number(task.fixed_cost) || 0,
total_budget: Number(task.total_budget) || 0, total_budget: Number(task.total_budget) || 0,
@@ -922,15 +991,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
variance: Number(task.variance) || 0, variance: Number(task.variance) || 0,
members: task.assignees, members: task.assignees,
billable: task.billable, billable: task.billable,
sub_tasks_count: Number(task.sub_tasks_count) || 0 sub_tasks_count: Number(task.sub_tasks_count) || 0,
})) })),
}; };
}); });
// Include project rate cards in the response for reference // Include project rate cards in the response for reference
const responseData = { const responseData = {
groups: groupedTasks, groups: groupedTasks,
project_rate_cards: projectRateCards project_rate_cards: projectRateCards,
}; };
// Create Excel workbook and worksheet // Create Excel workbook and worksheet
@@ -950,21 +1019,38 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
{ header: "Variance", key: "variance", width: 15 }, { header: "Variance", key: "variance", width: 15 },
{ header: "Members", key: "members", width: 30 }, { header: "Members", key: "members", width: 30 },
{ header: "Billable", key: "billable", width: 10 }, { header: "Billable", key: "billable", width: 10 },
{ header: "Sub Tasks Count", key: "sub_tasks_count", width: 15 } { header: "Sub Tasks Count", key: "sub_tasks_count", width: 15 },
]; ];
// Add title row // Add title row
worksheet.getCell("A1").value = `Finance Data Export - ${projectName} - ${moment().format("MMM DD, YYYY")}`; worksheet.getCell(
"A1"
).value = `Finance Data Export - ${projectName} - ${moment().format(
"MMM DD, YYYY"
)}`;
worksheet.mergeCells("A1:L1"); worksheet.mergeCells("A1:L1");
worksheet.getCell("A1").alignment = { horizontal: "center" }; worksheet.getCell("A1").alignment = { horizontal: "center" };
worksheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } }; worksheet.getCell("A1").style.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "D9D9D9" },
};
worksheet.getCell("A1").font = { size: 16, bold: true }; worksheet.getCell("A1").font = { size: 16, bold: true };
// Add headers on row 3 // Add headers on row 3
worksheet.getRow(3).values = [ worksheet.getRow(3).values = [
"Task Name", "Group", "Estimated Hours", "Total Time Logged", "Task Name",
"Estimated Cost", "Fixed Cost", "Total Budget", "Total Actual", "Group",
"Variance", "Members", "Billable", "Sub Tasks Count" "Estimated Hours",
"Total Time Logged",
"Estimated Cost",
"Fixed Cost",
"Total Budget",
"Total Actual",
"Variance",
"Members",
"Billable",
"Sub Tasks Count",
]; ];
worksheet.getRow(3).font = { bold: true }; worksheet.getRow(3).font = { bold: true };
@@ -984,7 +1070,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
variance: task.variance.toFixed(2), variance: task.variance.toFixed(2),
members: task.members.map((m: any) => m.name).join(", "), members: task.members.map((m: any) => m.name).join(", "),
billable: task.billable ? "Yes" : "No", billable: task.billable ? "Yes" : "No",
sub_tasks_count: task.sub_tasks_count sub_tasks_count: task.sub_tasks_count,
}); });
currentRow++; currentRow++;
} }
@@ -994,13 +1080,18 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
const buffer = await workbook.xlsx.writeBuffer(); const buffer = await workbook.xlsx.writeBuffer();
// Create filename with project name, date and time // Create filename with project name, date and time
const sanitizedProjectName = projectName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_'); const sanitizedProjectName = projectName
const dateTime = moment().format('YYYY-MM-DD_HH-mm-ss'); .replace(/[^a-zA-Z0-9\s]/g, "")
.replace(/\s+/g, "_");
const dateTime = moment().format("YYYY-MM-DD_HH-mm-ss");
const filename = `${sanitizedProjectName}_Finance_Data_${dateTime}.xlsx`; const filename = `${sanitizedProjectName}_Finance_Data_${dateTime}.xlsx`;
// Set the response headers for the Excel file // Set the response headers for the Excel file
res.setHeader("Content-Disposition", `attachment; filename=${filename}`); res.setHeader("Content-Disposition", `attachment; filename=${filename}`);
res.setHeader("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); res.setHeader(
"Content-Type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
);
// Send the Excel file as a response // Send the Excel file as a response
res.end(buffer); res.end(buffer);

View File

@@ -1,23 +0,0 @@
import FinanceTableWrapper from './finance-table/finance-table-wrapper';
import { IProjectFinanceGroup } from '@/types/project/project-finance.types';
interface FinanceTabProps {
groupType: 'status' | 'priority' | 'phases';
taskGroups: IProjectFinanceGroup[];
loading: boolean;
}
const FinanceTab = ({
groupType,
taskGroups = [],
loading
}: FinanceTabProps) => {
return (
<div>
<FinanceTableWrapper activeTablesList={taskGroups} loading={loading} />
</div>
);
};
export default FinanceTab;

View File

@@ -1,56 +0,0 @@
import { CaretDownFilled } from '@ant-design/icons';
import { Flex, Select } from 'antd';
import React from 'react';
import { useSelectedProject } from '../../../../../hooks/useSelectedProject';
import { useAppSelector } from '../../../../../hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
type GroupByFilterDropdownProps = {
activeGroup: 'status' | 'priority' | 'phases';
setActiveGroup: (group: 'status' | 'priority' | 'phases') => void;
};
const GroupByFilterDropdown = ({
activeGroup,
setActiveGroup,
}: GroupByFilterDropdownProps) => {
// localization
const { t } = useTranslation('project-view-finance');
const handleChange = (value: string) => {
setActiveGroup(value as 'status' | 'priority' | 'phases');
};
// get selected project from useSelectedPro
const selectedProject = useSelectedProject();
//get phases details from phases slice
const phase =
useAppSelector((state) => state.phaseReducer.phaseList).find(
(phase) => phase?.projectId === selectedProject?.projectId
) || null;
const groupDropdownMenuItems = [
{ key: 'status', value: 'status', label: t('statusText') },
{ key: 'priority', value: 'priority', label: t('priorityText') },
{
key: 'phase',
value: 'phase',
label: phase ? phase?.phase : t('phaseText'),
},
];
return (
<Flex align="center" gap={4} style={{ marginInlineStart: 12 }}>
{t('groupByText')}:
<Select
defaultValue={'status'}
options={groupDropdownMenuItems}
onChange={handleChange}
suffixIcon={<CaretDownFilled />}
/>
</Flex>
);
};
export default GroupByFilterDropdown;

View File

@@ -1,137 +0,0 @@
import { Button, ConfigProvider, Flex, Select, Typography, message } from 'antd';
import GroupByFilterDropdown from './group-by-filter-dropdown';
import { DownOutlined } from '@ant-design/icons';
import { useAppDispatch } from '../../../../../hooks/useAppDispatch';
import { useAppSelector } from '../../../../../hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice';
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
import { useParams } from 'react-router-dom';
import { useState } from 'react';
type ProjectViewFinanceHeaderProps = {
activeTab: 'finance' | 'ratecard';
setActiveTab: (tab: 'finance' | 'ratecard') => void;
activeGroup: 'status' | 'priority' | 'phases';
setActiveGroup: (group: 'status' | 'priority' | 'phases') => void;
};
const ProjectViewFinanceHeader = ({
activeTab,
setActiveTab,
activeGroup,
setActiveGroup,
}: ProjectViewFinanceHeaderProps) => {
// localization
const { t } = useTranslation('project-view-finance');
const { projectId } = useParams<{ projectId: string }>();
const [exporting, setExporting] = useState(false);
const dispatch = useAppDispatch();
const { project } = useAppSelector(state => state.projectReducer);
const handleExport = async () => {
if (!projectId) {
message.error('Project ID not found');
return;
}
try {
setExporting(true);
const blob = await projectFinanceApiService.exportFinanceData(projectId, activeGroup);
// Get project name from Redux state
const projectName = project?.name || 'Unknown_Project';
// Create filename with project name, date and time
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`;
// Create download link
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);
}
};
return (
<ConfigProvider wave={{ disabled: true }}>
<Flex gap={16} align="center" justify="space-between">
<Flex gap={16} align="center">
<Flex>
<Button
className={`${activeTab === 'finance' && 'border-[#1890ff] text-[#1890ff]'} rounded-r-none`}
onClick={() => setActiveTab('finance')}
>
{t('financeText')}
</Button>
<Button
className={`${activeTab === 'ratecard' && 'border-[#1890ff] text-[#1890ff]'} rounded-l-none`}
onClick={() => setActiveTab('ratecard')}
>
{t('ratecardSingularText')}
</Button>
</Flex>
{activeTab === 'finance' && (
<GroupByFilterDropdown
activeGroup={activeGroup}
setActiveGroup={setActiveGroup}
/>
)}
</Flex>
{activeTab === 'finance' ? (
<Button
type="primary"
icon={<DownOutlined />}
iconPosition="end"
loading={exporting}
onClick={handleExport}
>
{t('exportButton')}
</Button>
) : (
<Flex gap={8} align="center">
<Flex gap={8} align="center">
<Typography.Text>{t('currencyText')}</Typography.Text>
<Select
defaultValue={'lkr'}
options={[
{ value: 'lkr', label: 'LKR' },
{ value: 'usd', label: 'USD' },
{ value: 'inr', label: 'INR' },
]}
onChange={(value) => dispatch(changeCurrency(value))}
/>
</Flex>
<Button
type="primary"
onClick={() => dispatch(toggleImportRatecardsDrawer())}
>
{t('importButton')}
</Button>
</Flex>
)}
</Flex>
</ConfigProvider>
);
};
export default ProjectViewFinanceHeader;

View File

@@ -1,20 +1,27 @@
import { Flex } from 'antd'; import { Button, ConfigProvider, Flex, Select, Typography, message } from 'antd';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CaretDownFilled, DownOutlined } from '@ant-design/icons';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import ProjectViewFinanceHeader from './project-view-finance-header/project-view-finance-header';
import FinanceTab from './finance-tab/finance-tab';
import RatecardTab from './ratecard-tab/ratecard-tab';
import { fetchProjectFinances, setActiveTab, setActiveGroup } from '@/features/projects/finance/project-finance.slice'; import { fetchProjectFinances, setActiveTab, setActiveGroup } from '@/features/projects/finance/project-finance.slice';
import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice';
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
import { RootState } from '@/app/store'; import { RootState } from '@/app/store';
import FinanceTableWrapper from './finance-tab/finance-table/finance-table-wrapper';
import RatecardTable from './ratecard-tab/reatecard-table/ratecard-table';
import ImportRatecardsDrawer from '@/features/finance/ratecard-drawer/import-ratecards-drawer';
const ProjectViewFinance = () => { const ProjectViewFinance = () => {
const { projectId } = useParams<{ projectId: string }>(); const { projectId } = useParams<{ projectId: string }>();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useTranslation('project-view-finance');
const [exporting, setExporting] = useState(false);
const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances); const { activeTab, activeGroup, loading, taskGroups } = useAppSelector((state: RootState) => state.projectFinances);
const { refreshTimestamp } = useAppSelector((state: RootState) => state.projectReducer); const { refreshTimestamp, project } = useAppSelector((state: RootState) => state.projectReducer);
const phaseList = useAppSelector((state) => state.phaseReducer.phaseList);
useEffect(() => { useEffect(() => {
if (projectId) { if (projectId) {
@@ -22,23 +29,136 @@ const ProjectViewFinance = () => {
} }
}, [projectId, activeGroup, dispatch, refreshTimestamp]); }, [projectId, activeGroup, dispatch, refreshTimestamp]);
const handleExport = async () => {
if (!projectId) {
message.error('Project ID not found');
return;
}
try {
setExporting(true);
const blob = await projectFinanceApiService.exportFinanceData(projectId, activeGroup);
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 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'),
},
];
return ( return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}> <Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
<ProjectViewFinanceHeader {/* Finance Header */}
activeTab={activeTab} <ConfigProvider wave={{ disabled: true }}>
setActiveTab={(tab) => dispatch(setActiveTab(tab))} <Flex gap={16} align="center" justify="space-between">
activeGroup={activeGroup} <Flex gap={16} align="center">
setActiveGroup={(group) => dispatch(setActiveGroup(group))} <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={4} style={{ marginInlineStart: 12 }}>
{t('groupByText')}:
<Select
value={activeGroup}
options={groupDropdownMenuItems}
onChange={(value) => dispatch(setActiveGroup(value as 'status' | 'priority' | 'phases'))}
suffixIcon={<CaretDownFilled />}
/>
</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
defaultValue={'lkr'}
options={[
{ value: 'lkr', label: 'LKR' },
{ value: 'usd', label: 'USD' },
{ value: 'inr', label: 'INR' },
]}
onChange={(value) => dispatch(changeCurrency(value))}
/>
</Flex>
<Button
type="primary"
onClick={() => dispatch(toggleImportRatecardsDrawer())}
>
{t('importButton')}
</Button>
</Flex>
)}
</Flex>
</ConfigProvider>
{/* Tab Content */}
{activeTab === 'finance' ? ( {activeTab === 'finance' ? (
<FinanceTab <div>
groupType={activeGroup} <FinanceTableWrapper activeTablesList={taskGroups} loading={loading} />
taskGroups={taskGroups} </div>
loading={loading}
/>
) : ( ) : (
<RatecardTab /> <Flex vertical gap={8}>
<RatecardTable />
<Typography.Text
type="danger"
style={{ display: 'block', marginTop: '10px' }}
>
{t('ratecardImportantNotice')}
</Typography.Text>
<ImportRatecardsDrawer />
</Flex>
)} )}
</Flex> </Flex>
); );

View File

@@ -1,38 +0,0 @@
import React from 'react';
import RatecardTable from './reatecard-table/ratecard-table';
import { Button, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import ImportRatecardsDrawer from '@/features/finance/ratecard-drawer/import-ratecards-drawer';
const RatecardTab = () => {
// localization
const { t } = useTranslation('project-view-finance');
return (
<Flex vertical gap={8}>
<RatecardTable />
<Typography.Text
type="danger"
style={{ display: 'block', marginTop: '10px' }}
>
{t('ratecardImportantNotice')}
</Typography.Text>
{/* <Button
type="primary"
style={{
marginTop: '10px',
width: 'fit-content',
alignSelf: 'flex-end',
}}
>
{t('saveButton')}
</Button> */}
{/* import ratecards drawer */}
<ImportRatecardsDrawer />
</Flex>
);
};
export default RatecardTab;