Files
Hotel-Booking/Frontend/src/pages/accountant/DashboardPage.tsx
Iliyan Angelov 62c1fe5951 updates
2025-12-01 06:50:10 +02:00

507 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, useRef } from 'react';
import {
CreditCard,
Receipt,
TrendingUp,
RefreshCw,
DollarSign,
AlertCircle
} from 'lucide-react';
import reportService, { ReportData } from '../../features/analytics/services/reportService';
import paymentService from '../../features/payments/services/paymentService';
import invoiceService from '../../features/payments/services/invoiceService';
import type { Payment } from '../../features/payments/services/paymentService';
import type { Invoice } from '../../features/payments/services/invoiceService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import ExportButton from '../../shared/components/ExportButton';
import { formatDate } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { useAsync } from '../../shared/hooks/useAsync';
import { useNavigate } from 'react-router-dom';
import { logger } from '../../shared/utils/logger';
import { getPaymentStatusColor } from '../../shared/utils/paymentUtils';
const AccountantDashboardPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const navigate = useNavigate();
const [dateRange, setDateRange] = useState({
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
to: new Date().toISOString().split('T')[0],
});
const [recentPayments, setRecentPayments] = useState<Payment[]>([]);
const [recentInvoices, setRecentInvoices] = useState<Invoice[]>([]);
const [loadingPayments, setLoadingPayments] = useState(false);
const [loadingInvoices, setLoadingInvoices] = useState(false);
const paymentsAbortRef = useRef<AbortController | null>(null);
const invoicesAbortRef = useRef<AbortController | null>(null);
const [financialSummary, setFinancialSummary] = useState({
totalRevenue: 0,
totalPayments: 0,
totalInvoices: 0,
pendingPayments: 0,
overdueInvoices: 0,
paidInvoices: 0,
});
const fetchDashboardData = async () => {
const response = await reportService.getReports({
from: dateRange.from,
to: dateRange.to,
});
return response.data;
};
const { data: stats, loading, error, execute } = useAsync<ReportData>(
fetchDashboardData,
{
immediate: true,
onError: (error: any) => {
toast.error(error.message || 'Unable to load dashboard data');
}
}
);
useEffect(() => {
execute();
}, [dateRange]);
useEffect(() => {
// Cancel previous request if exists
if (paymentsAbortRef.current) {
paymentsAbortRef.current.abort();
}
// Create new abort controller
paymentsAbortRef.current = new AbortController();
const fetchPayments = async () => {
try {
setLoadingPayments(true);
const response = await paymentService.getPayments({ page: 1, limit: 10 });
if (response.success && response.data?.payments) {
setRecentPayments(response.data.payments);
// Calculate financial summary
const completedPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'completed' || p.payment_status === 'paid');
const pendingPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'pending');
const totalRevenue = completedPayments.reduce((sum: number, p: Payment) => sum + (p.amount || 0), 0);
setFinancialSummary(prev => ({
...prev,
totalRevenue,
totalPayments: response.data.payments.length,
pendingPayments: pendingPayments.length,
}));
}
} catch (err: any) {
// Handle AbortError silently
if (err.name === 'AbortError') {
return;
}
logger.error('Error fetching payments', err);
} finally {
setLoadingPayments(false);
}
};
fetchPayments();
// Cleanup: abort request on unmount
return () => {
if (paymentsAbortRef.current) {
paymentsAbortRef.current.abort();
}
};
}, []);
useEffect(() => {
// Cancel previous request if exists
if (invoicesAbortRef.current) {
invoicesAbortRef.current.abort();
}
// Create new abort controller
invoicesAbortRef.current = new AbortController();
const fetchInvoices = async () => {
try {
setLoadingInvoices(true);
const response = await invoiceService.getInvoices({ page: 1, limit: 10 });
if (response.status === 'success' && response.data?.invoices) {
setRecentInvoices(response.data.invoices);
// Calculate invoice summary
const paidInvoices = response.data.invoices.filter((inv: Invoice) => inv.status === 'paid');
const overdueInvoices = response.data.invoices.filter((inv: Invoice) => inv.status === 'overdue');
setFinancialSummary(prev => ({
...prev,
totalInvoices: response.data.invoices?.length || 0,
paidInvoices: paidInvoices.length,
overdueInvoices: overdueInvoices.length,
}));
}
} catch (err: any) {
// Handle AbortError silently
if (err.name === 'AbortError') {
return;
}
logger.error('Error fetching invoices', err);
} finally {
setLoadingInvoices(false);
}
};
fetchInvoices();
// Cleanup: abort request on unmount
return () => {
if (invoicesAbortRef.current) {
invoicesAbortRef.current.abort();
}
};
}, []);
const handleRefresh = () => {
execute();
};
const getInvoiceStatusColor = (status: string) => {
switch (status) {
case 'paid':
return 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200';
case 'sent':
return 'bg-gradient-to-r from-blue-50 to-indigo-50 text-blue-800 border-blue-200';
case 'overdue':
return 'bg-gradient-to-r from-rose-50 to-red-50 text-rose-800 border-rose-200';
case 'draft':
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
default:
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
}
};
if (loading) {
return <Loading fullScreen text="Loading dashboard..." />;
}
if (error || !stats) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
<EmptyState
title="Unable to Load Dashboard"
description={error?.message || 'Something went wrong. Please try again.'}
action={{
label: 'Retry',
onClick: handleRefresh
}}
/>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 sm:gap-6 mb-6 sm:mb-8 md:mb-10 animate-fade-in">
<div className="w-full lg:w-auto">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Financial Dashboard
</h1>
</div>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">Comprehensive financial overview and analytics</p>
</div>
{/* Date Range & Actions */}
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
<div className="flex flex-col xs:flex-row gap-2 sm:gap-3 w-full sm:w-auto">
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 sm:py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-emerald-400 focus:ring-4 focus:ring-emerald-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
/>
<span className="hidden xs:inline-flex items-center text-slate-500 font-medium">to</span>
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 sm:py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-emerald-400 focus:ring-4 focus:ring-emerald-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
/>
</div>
<div className="flex gap-2 sm:gap-3 items-center w-full sm:w-auto">
<ExportButton
data={[
{
'Total Revenue': formatCurrency(stats?.total_revenue || 0),
'Total Payments': stats?.total_payments || 0,
'Total Invoices': stats?.total_invoices || 0,
'Total Customers': stats?.total_customers || 0,
'Available Rooms': stats?.available_rooms || 0,
'Occupied Rooms': stats?.occupied_rooms || 0,
'Pending Payments': financialSummary.pendingPayments,
'Overdue Invoices': financialSummary.overdueInvoices,
'Date Range': `${dateRange.from} to ${dateRange.to}`
},
...(recentPayments.map(p => ({
'Type': 'Payment',
'Transaction ID': p.transaction_id || `PAY-${p.id}`,
'Amount': formatCurrency(p.amount || 0),
'Status': p.payment_status,
'Method': p.payment_method,
'Date': p.payment_date ? formatDate(p.payment_date) : 'N/A',
'Booking': p.booking?.booking_number || 'N/A'
}))),
...(recentInvoices.map(i => ({
'Type': 'Invoice',
'Invoice Number': i.invoice_number,
'Customer': i.customer_name,
'Total Amount': formatCurrency(i.total_amount),
'Amount Due': formatCurrency(i.amount_due ?? i.balance_due),
'Status': i.status,
'Due Date': i.due_date ? formatDate(i.due_date) : 'N/A',
'Issue Date': i.issue_date ? formatDate(i.issue_date) : 'N/A'
})))
]}
filename="accountant-dashboard"
title="Accountant Financial Dashboard Report"
/>
<button
onClick={handleRefresh}
disabled={loading}
className="flex-1 sm:flex-none px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-emerald-500 to-emerald-600 text-white rounded-xl font-semibold hover:from-emerald-600 hover:to-emerald-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-xs sm:text-sm"
>
<RefreshCw className={`w-3 h-3 sm:w-4 sm:h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div>
</div>
{/* Financial Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5 md:gap-6 mb-6 sm:mb-8 md:mb-10">
{/* Total Revenue */}
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-emerald-500 animate-slide-up hover:shadow-2xl hover:scale-[1.02] transition-all duration-300">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0 pr-2">
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1 sm:mb-2">Total Revenue</p>
<p className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent truncate">
{formatCurrency(financialSummary.totalRevenue || stats?.total_revenue || 0)}
</p>
</div>
<div className="bg-gradient-to-br from-emerald-100 to-emerald-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
<DollarSign className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-emerald-600" />
</div>
</div>
<div className="flex items-center mt-4 sm:mt-5 pt-3 sm:pt-4 border-t border-slate-100">
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4 text-emerald-500 mr-1 sm:mr-2 flex-shrink-0" />
<span className="text-emerald-600 font-semibold text-xs sm:text-sm">Active</span>
<span className="text-slate-500 ml-1 sm:ml-2 text-xs sm:text-sm truncate">All time revenue</span>
</div>
</div>
{/* Total Payments */}
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-blue-500 animate-slide-up hover:shadow-2xl hover:scale-[1.02] transition-all duration-300" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0 pr-2">
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1 sm:mb-2">Total Payments</p>
<p className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold bg-gradient-to-r from-blue-600 to-blue-700 bg-clip-text text-transparent">
{financialSummary.totalPayments}
</p>
</div>
<div className="bg-gradient-to-br from-blue-100 to-blue-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
<CreditCard className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-blue-600" />
</div>
</div>
<div className="flex items-center mt-4 sm:mt-5 pt-3 sm:pt-4 border-t border-slate-100">
<span className="text-slate-500 text-xs sm:text-sm truncate">
{financialSummary.pendingPayments} pending payments
</span>
</div>
</div>
{/* Total Invoices */}
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-purple-500 animate-slide-up hover:shadow-2xl hover:scale-[1.02] transition-all duration-300" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0 pr-2">
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1 sm:mb-2">Total Invoices</p>
<p className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent">
{financialSummary.totalInvoices}
</p>
</div>
<div className="bg-gradient-to-br from-purple-100 to-purple-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
<Receipt className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-purple-600" />
</div>
</div>
<div className="flex items-center mt-4 sm:mt-5 pt-3 sm:pt-4 border-t border-slate-100">
<span className="text-slate-500 text-xs sm:text-sm truncate">
{financialSummary.paidInvoices} paid {financialSummary.overdueInvoices} overdue
</span>
</div>
</div>
</div>
{/* Recent Payments and Invoices */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 md:gap-6 mb-6 sm:mb-8 md:mb-10">
{/* Recent Payments */}
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-emerald-600" />
Recent Payments
</h2>
<button
onClick={() => navigate('/accountant/payments')}
className="text-xs sm:text-sm text-emerald-600 hover:text-emerald-700 font-semibold hover:underline transition-colors"
>
View All
</button>
</div>
{loadingPayments ? (
<div className="flex items-center justify-center py-8">
<Loading text="Loading payments..." />
</div>
) : recentPayments && recentPayments.length > 0 ? (
<div className="space-y-3 sm:space-y-4">
{recentPayments.slice(0, 5).map((payment) => (
<div
key={payment.id}
className="flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl hover:from-emerald-50 hover:to-yellow-50 border border-slate-200 hover:border-emerald-300 hover:shadow-lg cursor-pointer transition-all duration-200"
onClick={() => navigate(`/accountant/payments`)}
>
<div className="flex items-center space-x-3 sm:space-x-4 flex-1 min-w-0">
<div className="p-2 sm:p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-lg sm:rounded-xl shadow-md flex-shrink-0">
<CreditCard className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-slate-900 truncate text-sm sm:text-base md:text-lg">
{formatCurrency(payment.amount)}
</p>
<div className="flex items-center gap-1 sm:gap-2 mt-1 flex-wrap">
<p className="text-xs sm:text-sm text-slate-600 font-medium">
{payment.payment_method || 'N/A'}
</p>
{payment.payment_date && (
<span className="text-xs text-slate-400">
{formatDate(payment.payment_date, 'short')}
</span>
)}
</div>
</div>
</div>
<span className={`px-2 sm:px-3 py-1 sm:py-1.5 text-xs font-semibold rounded-full border shadow-sm flex-shrink-0 ml-2 ${getPaymentStatusColor(payment.payment_status)}`}>
{payment.payment_status.charAt(0).toUpperCase() + payment.payment_status.slice(1)}
</span>
</div>
))}
</div>
) : (
<EmptyState
title="No Recent Payments"
description="Recent payment transactions will appear here"
action={{
label: 'View All Payments',
onClick: () => navigate('/accountant/payments')
}}
/>
)}
</div>
{/* Recent Invoices */}
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900 flex items-center gap-2">
<Receipt className="w-5 h-5 text-purple-600" />
Recent Invoices
</h2>
<button
onClick={() => navigate('/accountant/invoices')}
className="text-xs sm:text-sm text-emerald-600 hover:text-emerald-700 font-semibold hover:underline transition-colors"
>
View All
</button>
</div>
{loadingInvoices ? (
<div className="flex items-center justify-center py-8">
<Loading text="Loading invoices..." />
</div>
) : recentInvoices && recentInvoices.length > 0 ? (
<div className="space-y-3 sm:space-y-4">
{recentInvoices.slice(0, 5).map((invoice) => (
<div
key={invoice.id}
className="flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl hover:from-purple-50 hover:to-indigo-50 border border-slate-200 hover:border-purple-300 hover:shadow-lg cursor-pointer transition-all duration-200"
onClick={() => navigate(`/accountant/invoices`)}
>
<div className="flex items-center space-x-3 sm:space-x-4 flex-1 min-w-0">
<div className="p-2 sm:p-3 bg-gradient-to-br from-purple-100 to-purple-200 rounded-lg sm:rounded-xl shadow-md flex-shrink-0">
<Receipt className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-slate-900 truncate text-sm sm:text-base md:text-lg">
{invoice.invoice_number}
</p>
<div className="flex items-center gap-1 sm:gap-2 mt-1 flex-wrap">
<p className="text-xs sm:text-sm text-slate-600 font-medium">
{formatCurrency(invoice.total_amount || 0)}
</p>
{invoice.issue_date && (
<span className="text-xs text-slate-400">
{formatDate(invoice.issue_date, 'short')}
</span>
)}
</div>
</div>
</div>
<span className={`px-2 sm:px-3 py-1 sm:py-1.5 text-xs font-semibold rounded-full border shadow-sm flex-shrink-0 ml-2 ${getInvoiceStatusColor(invoice.status)}`}>
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
</span>
</div>
))}
</div>
) : (
<EmptyState
title="No Recent Invoices"
description="Recent invoices will appear here"
action={{
label: 'View All Invoices',
onClick: () => navigate('/accountant/invoices')
}}
/>
)}
</div>
</div>
{/* Alerts Section */}
{(financialSummary.overdueInvoices > 0 || financialSummary.pendingPayments > 0) && (
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
<div className="flex items-center gap-2 sm:gap-3 mb-3 sm:mb-4">
<AlertCircle className="w-5 h-5 text-amber-600" />
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900">Financial Alerts</h2>
</div>
<div className="space-y-3 sm:space-y-4">
{financialSummary.overdueInvoices > 0 && (
<div className="p-3 sm:p-4 bg-gradient-to-r from-rose-50 to-red-50 rounded-lg sm:rounded-xl border border-rose-200">
<p className="text-rose-800 font-semibold text-sm sm:text-base">
{financialSummary.overdueInvoices} overdue invoice{financialSummary.overdueInvoices !== 1 ? 's' : ''} require attention
</p>
</div>
)}
{financialSummary.pendingPayments > 0 && (
<div className="p-3 sm:p-4 bg-gradient-to-r from-amber-50 to-yellow-50 rounded-lg sm:rounded-xl border border-amber-200">
<p className="text-amber-800 font-semibold text-sm sm:text-base">
{financialSummary.pendingPayments} pending payment{financialSummary.pendingPayments !== 1 ? 's' : ''} awaiting processing
</p>
</div>
)}
</div>
</div>
)}
</div>
);
};
export default AccountantDashboardPage;