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 { getColor } from "../shared/utils";
import moment from "moment";
const Excel = require("exceljs");
import Excel from "exceljs";
// Utility function to format time in hours, minutes, seconds format
const formatTimeToHMS = (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(' ');
return parts.join(" ");
};
// Utility function to parse time string back to seconds for calculations
const parseTimeToSeconds = (timeString: string): number => {
if (!timeString || timeString === "0s") return 0;
let totalSeconds = 0;
const hourMatch = timeString.match(/(\d+)h/);
const minuteMatch = timeString.match(/(\d+)m/);
const secondMatch = timeString.match(/(\d+)s/);
if (hourMatch) totalSeconds += parseInt(hourMatch[1]) * 3600;
if (minuteMatch) totalSeconds += parseInt(minuteMatch[1]) * 60;
if (secondMatch) totalSeconds += parseInt(secondMatch[1]);
return totalSeconds;
};
@@ -50,7 +49,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
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
const rateCardQuery = `
@@ -65,7 +64,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
WHERE fprr.project_id = $1
ORDER BY jt.name;
`;
const rateCardResult = await db.query(rateCardQuery, [projectId]);
const projectRateCards = rateCardResult.rows;
@@ -205,7 +204,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
if (Array.isArray(task.assignees)) {
for (const assignee of task.assignees) {
assignee.color_code = getColor(assignee.name);
// Get the rate for this assignee using project_members.project_rate_card_role_id
const memberRateQuery = `
SELECT
@@ -218,12 +217,16 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE pm.team_member_id = $1 AND pm.project_id = $2
`;
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) {
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.job_title_id = memberRate.job_title_id;
assignee.job_title_name = memberRate.job_title_name;
@@ -235,7 +238,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
assignee.job_title_name = null;
}
} 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.rate = 0;
assignee.job_title_id = null;
@@ -246,8 +252,13 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
}
// 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") {
const q = `
SELECT
@@ -284,7 +295,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
ORDER BY sort_index;
`;
groups = (await db.query(q, [projectId])).rows;
// Add TASK_STATUS_COLOR_ALPHA to color codes
for (const group of groups) {
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
const groupedTasks = groups.map(group => {
const groupTasks = tasks.filter(task => {
const groupedTasks = groups.map((group) => {
const groupTasks = tasks.filter((task) => {
if (groupBy === "status") return task.status_id === group.id;
if (groupBy === "priority") return task.priority_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,
color_code: group.color_code,
color_code_dark: group.color_code_dark,
tasks: groupTasks.map(task => ({
tasks: groupTasks.map((task) => ({
id: task.id,
name: task.name,
estimated_seconds: 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: formatTimeToHMS(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
),
estimated_cost: Number(task.estimated_cost) || 0,
fixed_cost: Number(task.fixed_cost) || 0,
total_budget: Number(task.total_budget) || 0,
@@ -320,15 +334,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
variance: Number(task.variance) || 0,
members: task.assignees,
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
const responseData = {
groups: groupedTasks,
project_rate_cards: projectRateCards
project_rate_cards: projectRateCards,
};
return res.status(200).send(new ServerResponse(true, responseData));
@@ -343,7 +357,9 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
const { fixed_cost } = req.body;
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 = `
@@ -354,9 +370,11 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
`;
const result = await db.query(q, [fixed_cost, taskId]);
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]));
@@ -385,9 +403,11 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
`;
const taskResult = await db.query(taskQuery, [taskId]);
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;
@@ -412,12 +432,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE tm.id = $2
`;
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) {
const [member] = memberResult.rows;
// Get actual time logged by this member for this task
const timeLogQuery = `
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
WHERE twl.task_id = $1 AND tm.id = $2
`;
const timeLogResult = await db.query(timeLogQuery, [taskId, member.team_member_id]);
const loggedHours = Number(timeLogResult.rows[0]?.logged_hours || 0);
membersWithRates.push({
const timeLogResult = await db.query(timeLogQuery, [
taskId,
member.team_member_id,
]);
const loggedHours = Number(
timeLogResult.rows[0]?.logged_hours || 0
);
membersWithRates.push({
team_member_id: member.team_member_id,
name: member.name || "Unknown User",
avatar_url: member.avatar_url,
hourly_rate: Number(member.hourly_rate || 0),
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,
estimated_cost: (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)
estimated_cost:
(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) {
@@ -450,17 +484,17 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
// Group members by job title and calculate totals
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]) {
acc[jobRole] = {
jobRole,
estimated_hours: 0,
logged_hours: 0,
estimated_cost: 0,
actual_cost: 0,
members: []
};
acc[jobRole] = {
jobRole,
estimated_hours: 0,
logged_hours: 0,
estimated_cost: 0,
actual_cost: 0,
members: [],
};
}
acc[jobRole].estimated_hours += member.estimated_hours;
@@ -475,7 +509,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
estimated_hours: member.estimated_hours,
logged_hours: member.logged_hours,
estimated_cost: member.estimated_cost,
actual_cost: member.actual_cost
actual_cost: member.actual_cost,
});
return acc;
@@ -485,11 +519,23 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
const taskTotals = {
estimated_hours: Number(task.estimated_hours || 0),
logged_hours: Number(task.total_time_logged || 0),
estimated_labor_cost: membersWithRates.reduce((sum, member) => sum + member.estimated_cost, 0),
actual_labor_cost: membersWithRates.reduce((sum, member) => sum + member.actual_cost, 0),
estimated_labor_cost: membersWithRates.reduce(
(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),
total_estimated_cost: 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)
total_estimated_cost:
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 = {
@@ -498,10 +544,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
name: task.name,
project_id: task.project_id,
billable: task.billable,
...taskTotals
...taskTotals,
},
grouped_members: Object.values(groupedMembers),
members: membersWithRates
members: membersWithRates,
};
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;
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
@@ -581,7 +629,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
if (Array.isArray(task.assignees)) {
for (const assignee of task.assignees) {
assignee.color_code = getColor(assignee.name);
// Get the rate for this assignee
const memberRateQuery = `
SELECT
@@ -594,12 +642,16 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE pm.team_member_id = $1 AND pm.project_id = $2
`;
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) {
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.job_title_id = memberRate.job_title_id;
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
const formattedTasks = tasks.map(task => ({
const formattedTasks = tasks.map((task) => ({
id: task.id,
name: task.name,
estimated_seconds: 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: 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,
fixed_cost: Number(task.fixed_cost) || 0,
total_budget: Number(task.total_budget) || 0,
@@ -635,7 +689,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
variance: Number(task.variance) || 0,
members: task.assignees,
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));
@@ -647,12 +701,12 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
res: IWorkLenzResponse
): Promise<void> {
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
const projectNameQuery = `SELECT name FROM projects WHERE id = $1`;
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
const rateCardQuery = `
@@ -667,7 +721,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
WHERE fprr.project_id = $1
ORDER BY jt.name;
`;
const rateCardResult = await db.query(rateCardQuery, [projectId]);
const projectRateCards = rateCardResult.rows;
@@ -807,7 +861,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
if (Array.isArray(task.assignees)) {
for (const assignee of task.assignees) {
assignee.color_code = getColor(assignee.name);
// Get the rate for this assignee using project_members.project_rate_card_role_id
const memberRateQuery = `
SELECT
@@ -820,12 +874,16 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE pm.team_member_id = $1 AND pm.project_id = $2
`;
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) {
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.job_title_id = memberRate.job_title_id;
assignee.job_title_name = memberRate.job_title_name;
@@ -837,7 +895,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
assignee.job_title_name = null;
}
} 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.rate = 0;
assignee.job_title_id = null;
@@ -848,8 +909,13 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
}
// 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") {
const q = `
SELECT
@@ -886,7 +952,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
ORDER BY sort_index;
`;
groups = (await db.query(q, [projectId])).rows;
// Add TASK_STATUS_COLOR_ALPHA to color codes
for (const group of groups) {
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
const groupedTasks = groups.map(group => {
const groupTasks = tasks.filter(task => {
const groupedTasks = groups.map((group) => {
const groupTasks = tasks.filter((task) => {
if (groupBy === "status") return task.status_id === group.id;
if (groupBy === "priority") return task.priority_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,
color_code: group.color_code,
color_code_dark: group.color_code_dark,
tasks: groupTasks.map(task => ({
tasks: groupTasks.map((task) => ({
id: task.id,
name: task.name,
estimated_seconds: 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: formatTimeToHMS(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
),
estimated_cost: Number(task.estimated_cost) || 0,
fixed_cost: Number(task.fixed_cost) || 0,
total_budget: Number(task.total_budget) || 0,
@@ -922,15 +991,15 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
variance: Number(task.variance) || 0,
members: task.assignees,
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
const responseData = {
groups: groupedTasks,
project_rate_cards: projectRateCards
project_rate_cards: projectRateCards,
};
// Create Excel workbook and worksheet
@@ -950,21 +1019,38 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
{ header: "Variance", key: "variance", width: 15 },
{ header: "Members", key: "members", width: 30 },
{ 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
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.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 };
// Add headers on row 3
worksheet.getRow(3).values = [
"Task Name", "Group", "Estimated Hours", "Total Time Logged",
"Estimated Cost", "Fixed Cost", "Total Budget", "Total Actual",
"Variance", "Members", "Billable", "Sub Tasks Count"
"Task Name",
"Group",
"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 };
@@ -984,7 +1070,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
variance: task.variance.toFixed(2),
members: task.members.map((m: any) => m.name).join(", "),
billable: task.billable ? "Yes" : "No",
sub_tasks_count: task.sub_tasks_count
sub_tasks_count: task.sub_tasks_count,
});
currentRow++;
}
@@ -994,13 +1080,18 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
const buffer = await workbook.xlsx.writeBuffer();
// Create filename with project name, date and time
const sanitizedProjectName = projectName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_');
const dateTime = moment().format('YYYY-MM-DD_HH-mm-ss');
const sanitizedProjectName = projectName
.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`;
// Set the response headers for the Excel file
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
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 { useEffect } from 'react';
import { Button, ConfigProvider, Flex, Select, Typography, message } from 'antd';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CaretDownFilled, DownOutlined } from '@ant-design/icons';
import { useAppDispatch } from '@/hooks/useAppDispatch';
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 { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice';
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
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 { projectId } = useParams<{ projectId: string }>();
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 { refreshTimestamp } = useAppSelector((state: RootState) => state.projectReducer);
const { refreshTimestamp, project } = useAppSelector((state: RootState) => state.projectReducer);
const phaseList = useAppSelector((state) => state.phaseReducer.phaseList);
useEffect(() => {
if (projectId) {
@@ -22,23 +29,136 @@ const ProjectViewFinance = () => {
}
}, [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 (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
<ProjectViewFinanceHeader
activeTab={activeTab}
setActiveTab={(tab) => dispatch(setActiveTab(tab))}
activeGroup={activeGroup}
setActiveGroup={(group) => dispatch(setActiveGroup(group))}
/>
{/* 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={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' ? (
<FinanceTab
groupType={activeGroup}
taskGroups={taskGroups}
loading={loading}
/>
<div>
<FinanceTableWrapper activeTablesList={taskGroups} loading={loading} />
</div>
) : (
<RatecardTab />
<Flex vertical gap={8}>
<RatecardTable />
<Typography.Text
type="danger"
style={{ display: 'block', marginTop: '10px' }}
>
{t('ratecardImportantNotice')}
</Typography.Text>
<ImportRatecardsDrawer />
</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;