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,8 +8,7 @@ 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 => {
@@ -24,7 +23,7 @@ const formatTimeToHMS = (totalSeconds: number): string => {
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
@@ -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 = `
@@ -220,10 +219,14 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
`;
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,7 +252,12 @@ 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 = `
@@ -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 = `
@@ -356,7 +372,9 @@ 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]));
@@ -387,7 +405,9 @@ 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;
@@ -414,7 +434,10 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
`;
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;
@@ -427,8 +450,13 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
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);
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,
@@ -436,10 +464,16 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
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) {
@@ -459,7 +493,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
logged_hours: 0,
estimated_cost: 0,
actual_cost: 0,
members: []
members: [],
};
}
@@ -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
@@ -596,10 +644,14 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
`;
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 = `
@@ -822,10 +876,14 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
`;
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,7 +909,12 @@ 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 = `
@@ -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' ? (
<FinanceTab
groupType={activeGroup}
taskGroups={taskGroups}
loading={loading}
/>
<Button
type="primary"
icon={<DownOutlined />}
iconPosition="end"
loading={exporting}
onClick={handleExport}
>
{t('exportButton')}
</Button>
) : (
<RatecardTab />
<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' ? (
<div>
<FinanceTableWrapper activeTablesList={taskGroups} loading={loading} />
</div>
) : (
<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;