This commit is contained in:
Iliyan Angelov
2025-11-21 15:01:24 +02:00
parent 4ab7546de0
commit 9a6190e8ef
889 changed files with 1912 additions and 57 deletions

View File

@@ -31,6 +31,7 @@ import {
ProtectedRoute,
AdminRoute,
StaffRoute,
AccountantRoute,
CustomerRoute
} from './components/auth';
@@ -75,6 +76,10 @@ const ReceptionDashboardPage = lazy(() => import('./pages/admin/ReceptionDashboa
const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage'));
const ChatManagementPage = lazy(() => import('./pages/staff/ChatManagementPage'));
const StaffLayout = lazy(() => import('./pages/StaffLayout'));
const AccountantDashboardPage = lazy(() => import('./pages/accountant/DashboardPage'));
const AccountantLayout = lazy(() => import('./pages/AccountantLayout'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
function App() {
@@ -389,6 +394,34 @@ function App() {
/>
</Route>
{/* Accountant Routes */}
<Route
path="/accountant"
element={
<AccountantRoute>
<AccountantLayout />
</AccountantRoute>
}
>
<Route
index
element={<Navigate to="dashboard" replace />}
/>
<Route path="dashboard" element={<AccountantDashboardPage />} />
<Route
path="payments"
element={<PaymentManagementPage />}
/>
<Route
path="invoices"
element={<InvoiceManagementPage />}
/>
<Route
path="reports"
element={<AnalyticsDashboardPage />}
/>
</Route>
{}
<Route
path="*"

View File

@@ -0,0 +1,57 @@
import React, { useEffect } from 'react';
import { Navigate } from 'react-router-dom';
import useAuthStore from '../../store/useAuthStore';
import { useAuthModal } from '../../contexts/AuthModalContext';
interface AccountantRouteProps {
children: React.ReactNode;
}
const AccountantRoute: React.FC<AccountantRouteProps> = ({
children
}) => {
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
const { openModal } = useAuthModal();
// Open login modal if not authenticated
useEffect(() => {
if (!isLoading && !isAuthenticated) {
openModal('login');
}
}, [isLoading, isAuthenticated, openModal]);
if (isLoading) {
return (
<div
className="min-h-screen flex items-center
justify-center bg-gray-50"
>
<div className="text-center">
<div
className="animate-spin rounded-full h-12 w-12
border-b-2 border-indigo-600 mx-auto"
/>
<p className="mt-4 text-gray-600">
Authenticating...
</p>
</div>
</div>
);
}
// Don't render children if not authenticated (modal will be shown)
if (!isAuthenticated) {
return null; // Modal will be shown by AuthModalManager
}
// Check if user is accountant
const isAccountant = userInfo?.role === 'accountant';
if (!isAccountant) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
};
export default AccountantRoute;

View File

@@ -45,12 +45,14 @@ const CustomerRoute: React.FC<CustomerRouteProps> = ({
}
const isCustomer = userInfo?.role !== 'admin' && userInfo?.role !== 'staff';
const isCustomer = userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant';
if (!isCustomer) {
if (userInfo?.role === 'admin') {
return <Navigate to="/admin/dashboard" replace />;
} else if (userInfo?.role === 'staff') {
return <Navigate to="/staff/dashboard" replace />;
} else if (userInfo?.role === 'accountant') {
return <Navigate to="/accountant/dashboard" replace />;
}
return <Navigate to="/" replace />;
}

View File

@@ -1,4 +1,5 @@
export { default as ProtectedRoute } from './ProtectedRoute';
export { default as AdminRoute } from './AdminRoute';
export { default as StaffRoute } from './StaffRoute';
export { default as AccountantRoute } from './AccountantRoute';
export { default as CustomerRoute } from './CustomerRoute';

View File

@@ -0,0 +1,186 @@
import React, { useState, useRef, useEffect } from 'react';
import { Download, FileText, FileJson, FileSpreadsheet, File, ChevronDown, Check } from 'lucide-react';
import { exportData, formatDataForExport, ExportFormat } from '../../utils/exportUtils';
import { toast } from 'react-toastify';
interface ExportButtonProps {
data: any[];
filename: string;
title?: string;
customHeaders?: Record<string, string>;
className?: string;
disabled?: boolean;
}
const ExportButton: React.FC<ExportButtonProps> = ({
data,
filename,
title,
customHeaders,
className = '',
disabled = false
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleExport = async (format: ExportFormat) => {
try {
if (!data || data.length === 0) {
toast.error('No data available to export');
return;
}
const { headers, formattedData } = formatDataForExport(data, customHeaders);
exportData({
format,
filename,
title: title || filename,
headers,
data: formattedData
});
toast.success(`Data exported successfully as ${format.toUpperCase()}!`);
setIsOpen(false);
} catch (error: any) {
toast.error(error.message || 'Failed to export data');
}
};
const exportOptions = [
{
format: 'csv' as ExportFormat,
label: 'CSV',
icon: FileText,
description: 'Comma-separated values',
color: 'from-blue-500 to-blue-600'
},
{
format: 'xlsx' as ExportFormat,
label: 'Excel',
icon: FileSpreadsheet,
description: 'Microsoft Excel format',
color: 'from-emerald-500 to-emerald-600'
},
{
format: 'pdf' as ExportFormat,
label: 'PDF',
icon: File,
description: 'Portable Document Format',
color: 'from-rose-500 to-rose-600'
},
{
format: 'json' as ExportFormat,
label: 'JSON',
icon: FileJson,
description: 'JavaScript Object Notation',
color: 'from-purple-500 to-purple-600'
}
];
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<button
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled || !data || data.length === 0}
className={`
group relative flex items-center gap-2 px-5 py-2.5
bg-gradient-to-r from-emerald-600 via-emerald-600 to-emerald-700
text-white rounded-xl font-semibold
shadow-lg hover:shadow-xl
transition-all duration-300
disabled:opacity-50 disabled:cursor-not-allowed
${isOpen ? 'ring-4 ring-emerald-200' : ''}
`}
>
<Download className="w-5 h-5 transition-transform duration-300 group-hover:scale-110" />
<span>Export Data</span>
<ChevronDown className={`w-4 h-4 transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`} />
<div className="absolute inset-0 bg-gradient-to-r from-emerald-400 to-emerald-500 rounded-xl opacity-0 group-hover:opacity-20 transition-opacity duration-300"></div>
</button>
{isOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
<div className="absolute right-0 mt-2 w-72 z-50 bg-white rounded-2xl shadow-2xl border border-gray-100 overflow-hidden animate-fade-in">
<div className="p-4 bg-gradient-to-r from-emerald-50 to-emerald-100 border-b border-emerald-200">
<h3 className="text-sm font-bold text-emerald-900 uppercase tracking-wider">
Export Format
</h3>
<p className="text-xs text-emerald-700 mt-1">
Choose your preferred format
</p>
</div>
<div className="py-2">
{exportOptions.map((option, index) => {
const Icon = option.icon;
return (
<button
key={option.format}
onClick={() => handleExport(option.format)}
className={`
w-full flex items-center gap-4 px-4 py-3.5
text-left transition-all duration-200
hover:bg-gradient-to-r hover:from-gray-50 hover:to-emerald-50
group relative
${index !== exportOptions.length - 1 ? 'border-b border-gray-100' : ''}
`}
>
<div className={`
p-2.5 rounded-xl
bg-gradient-to-br ${option.color}
text-white shadow-lg
group-hover:scale-110 transition-transform duration-200
`}>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900 group-hover:text-emerald-700 transition-colors">
{option.label}
</span>
</div>
<p className="text-xs text-gray-500 mt-0.5">
{option.description}
</p>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<div className="w-2 h-2 bg-emerald-500 rounded-full"></div>
</div>
</button>
);
})}
</div>
<div className="px-4 py-3 bg-gradient-to-r from-gray-50 to-emerald-50 border-t border-gray-100">
<p className="text-xs text-gray-600 text-center">
<span className="font-semibold">{data?.length || 0}</span> records available
</p>
</div>
</div>
</>
)}
</div>
);
};
export default ExportButton;

View File

@@ -10,4 +10,5 @@ export { default as GlobalLoading } from './GlobalLoading';
export { default as OfflineIndicator } from './OfflineIndicator';
export { default as Skeleton } from './Skeleton';
export { default as ScrollToTop } from './ScrollToTop';
export { default as ExportButton } from './ExportButton';

View File

@@ -254,7 +254,7 @@ const Header: React.FC<HeaderProps> = ({
<User className="w-4 h-4" />
<span className="font-light tracking-wide">Profile</span>
</Link>
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && (
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && (
<>
<Link
to="/favorites"
@@ -314,6 +314,22 @@ const Header: React.FC<HeaderProps> = ({
<span className="font-light tracking-wide">Staff Dashboard</span>
</Link>
)}
{userInfo?.role === 'accountant' && (
<Link
to="/accountant"
onClick={() =>
setIsUserMenuOpen(false)
}
className="flex items-center
space-x-3 px-4 py-2.5 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
transition-all duration-300 border-l-2 border-transparent
hover:border-[#d4af37]"
>
<User className="w-4 h-4" />
<span className="font-light tracking-wide">Accountant Dashboard</span>
</Link>
)}
<div className="border-t border-[#d4af37]/20 my-1"></div>
<button
onClick={handleLogout}

View File

@@ -0,0 +1,284 @@
import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
FileText,
BarChart3,
ChevronLeft,
ChevronRight,
LogOut,
Menu,
X,
CreditCard,
Receipt
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
interface SidebarAccountantProps {
isCollapsed?: boolean;
onToggle?: () => void;
}
const SidebarAccountant: React.FC<SidebarAccountantProps> = ({
isCollapsed: controlledCollapsed,
onToggle
}) => {
const [internalCollapsed, setInternalCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { logout } = useAuthStore();
const handleLogout = async () => {
try {
await logout();
navigate('/');
if (isMobile) {
setIsMobileOpen(false);
}
} catch (error) {
console.error('Logout error:', error);
}
};
// Handle mobile responsiveness
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024);
if (window.innerWidth >= 1024) {
setIsMobileOpen(false);
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const isCollapsed =
controlledCollapsed !== undefined
? controlledCollapsed
: internalCollapsed;
const handleToggle = () => {
if (onToggle) {
onToggle();
} else {
setInternalCollapsed(!internalCollapsed);
}
};
const handleMobileToggle = () => {
setIsMobileOpen(!isMobileOpen);
};
const handleLinkClick = () => {
if (isMobile) {
setIsMobileOpen(false);
}
};
// Menu items for accountant
const menuItems = [
{
path: '/accountant/dashboard',
icon: LayoutDashboard,
label: 'Dashboard'
},
{
path: '/accountant/payments',
icon: CreditCard,
label: 'Payments'
},
{
path: '/accountant/invoices',
icon: Receipt,
label: 'Invoices'
},
{
path: '/accountant/reports',
icon: BarChart3,
label: 'Financial Reports'
},
];
const isActive = (path: string) => {
// Exact match
if (location.pathname === path) return true;
// Sub-path match
return location.pathname.startsWith(`${path}/`);
};
return (
<>
{/* Mobile menu button */}
{isMobile && (
<button
onClick={handleMobileToggle}
className="fixed top-4 left-4 z-50 lg:hidden p-3 bg-gradient-to-r from-emerald-900 to-emerald-800 text-white rounded-xl shadow-2xl border border-emerald-700 hover:from-emerald-800 hover:to-emerald-700 transition-all duration-200"
aria-label="Toggle menu"
>
{isMobileOpen ? (
<X className="w-6 h-6" />
) : (
<Menu className="w-6 h-6" />
)}
</button>
)}
{/* Mobile overlay */}
{isMobile && isMobileOpen && (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden"
onClick={handleMobileToggle}
/>
)}
{/* Sidebar */}
<aside
className={`
fixed lg:static inset-y-0 left-0 z-40
bg-gradient-to-b from-emerald-900 via-emerald-800 to-emerald-900
text-white shadow-2xl
transition-all duration-300 ease-in-out flex flex-col
${isMobile
? (isMobileOpen ? 'translate-x-0' : '-translate-x-full')
: ''
}
${!isMobile && (isCollapsed ? 'w-20' : 'w-72')}
${isMobile ? 'w-72' : ''}
border-r border-emerald-700/50
`}
>
{/* Header */}
<div className="p-6 border-b border-emerald-700/50 flex items-center justify-between bg-gradient-to-r from-emerald-800/50 to-emerald-900/50 backdrop-blur-sm">
{!isCollapsed && (
<div className="flex items-center gap-3">
<div className="h-1 w-12 bg-gradient-to-r from-emerald-400 to-emerald-600 rounded-full"></div>
<h2 className="text-xl font-bold bg-gradient-to-r from-emerald-100 to-emerald-200 bg-clip-text text-transparent">
Accountant Panel
</h2>
</div>
)}
{isCollapsed && !isMobile && (
<div className="w-full flex justify-center">
<div className="h-8 w-8 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-xl flex items-center justify-center shadow-lg">
<span className="text-white font-bold text-sm">A</span>
</div>
</div>
)}
{!isMobile && (
<button
onClick={handleToggle}
className="p-2.5 rounded-xl bg-emerald-800/50 hover:bg-emerald-700/50 border border-emerald-700/50 hover:border-emerald-500/50 transition-all duration-200 ml-auto shadow-lg hover:shadow-xl"
aria-label="Toggle sidebar"
>
{isCollapsed ? (
<ChevronRight className="w-5 h-5 text-emerald-200" />
) : (
<ChevronLeft className="w-5 h-5 text-emerald-200" />
)}
</button>
)}
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3 custom-scrollbar">
<ul className="space-y-2">
{menuItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<li key={item.path}>
<Link
to={item.path}
onClick={handleLinkClick}
className={`
flex items-center
space-x-3 px-4 py-3.5 rounded-xl
transition-all duration-200 group relative
${active
? 'bg-gradient-to-r from-emerald-500/20 to-emerald-600/20 text-emerald-100 shadow-lg border border-emerald-500/30'
: 'text-emerald-300 hover:bg-emerald-800/50 hover:text-emerald-100 border border-transparent hover:border-emerald-700/50'
}
${isCollapsed && !isMobile ? 'justify-center' : ''}
`}
title={isCollapsed && !isMobile ? item.label : undefined}
>
{active && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-gradient-to-b from-emerald-400 to-emerald-600 rounded-r-full"></div>
)}
<Icon className={`
flex-shrink-0 transition-transform duration-200
${active ? 'text-emerald-400' : 'text-emerald-400 group-hover:text-emerald-300'}
${isCollapsed && !isMobile ? 'w-6 h-6' : 'w-5 h-5'}
`} />
{(!isCollapsed || isMobile) && (
<span className={`
font-semibold transition-all duration-200
${active ? 'text-emerald-100' : 'group-hover:text-emerald-100'}
`}>
{item.label}
</span>
)}
{active && !isCollapsed && (
<div className="ml-auto w-2 h-2 bg-emerald-400 rounded-full animate-pulse"></div>
)}
</Link>
</li>
);
})}
</ul>
</nav>
{/* Logout Button */}
<div className="p-4 border-t border-emerald-700/50">
<button
onClick={handleLogout}
className={`
w-full flex items-center
space-x-3 px-4 py-3.5 rounded-xl
transition-all duration-200 group relative
text-emerald-300 hover:bg-gradient-to-r hover:from-rose-600/20 hover:to-rose-700/20
hover:text-rose-100 border border-transparent hover:border-rose-500/30
${isCollapsed && !isMobile ? 'justify-center' : ''}
`}
title={isCollapsed && !isMobile ? 'Logout' : undefined}
>
<LogOut className={`
flex-shrink-0 transition-transform duration-200
text-emerald-400 group-hover:text-rose-400 group-hover:rotate-12
${isCollapsed && !isMobile ? 'w-6 h-6' : 'w-5 h-5'}
`} />
{(!isCollapsed || isMobile) && (
<span className="font-semibold transition-all duration-200 group-hover:text-rose-100">
Logout
</span>
)}
</button>
</div>
{/* Footer */}
<div className="p-4 border-t border-emerald-700/50 bg-gradient-to-r from-emerald-800/50 to-emerald-900/50 backdrop-blur-sm">
{(!isCollapsed || isMobile) ? (
<div className="text-xs text-emerald-400 text-center space-y-1">
<p className="font-semibold text-emerald-200/80">Accountant Dashboard</p>
<p className="text-emerald-500">
© {new Date().getFullYear()} Luxury Hotel
</p>
</div>
) : (
<div className="flex justify-center">
<div className="w-3 h-3 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full shadow-lg animate-pulse"></div>
</div>
)}
</div>
</aside>
</>
);
};
export default SidebarAccountant;

View File

@@ -2,4 +2,5 @@ export { default as Header } from './Header';
export { default as Footer } from './Footer';
export { default as SidebarAdmin } from './SidebarAdmin';
export { default as SidebarStaff } from './SidebarStaff';
export { default as SidebarAccountant } from './SidebarAccountant';
export { default as LayoutMain } from './LayoutMain';

View File

@@ -26,7 +26,7 @@ const FavoriteButton: React.FC<FavoriteButtonProps> = ({
const [showTooltipText, setShowTooltipText] =
useState(false);
if (userInfo?.role === 'admin' || userInfo?.role === 'staff') {
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
return null;
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { SidebarAccountant } from '../components/layout';
const AccountantLayout: React.FC = () => {
return (
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
{/* Sidebar */}
<SidebarAccountant />
{/* Main Content */}
<div className="flex-1 overflow-auto lg:ml-0">
<div className="min-h-screen pt-20 lg:pt-0">
<Outlet />
</div>
</div>
</div>
);
};
export default AccountantLayout;

View File

@@ -0,0 +1,478 @@
import React, { useEffect, useState } from 'react';
import {
BarChart3,
CreditCard,
Receipt,
TrendingUp,
RefreshCw,
DollarSign,
FileText,
Calendar,
AlertCircle
} from 'lucide-react';
import { reportService, ReportData, paymentService, invoiceService } from '../../services/api';
import type { Payment } from '../../services/api/paymentService';
import type { Invoice } from '../../services/api/invoiceService';
import { toast } from 'react-toastify';
import { Loading, EmptyState, ExportButton } from '../../components/common';
import CurrencyIcon from '../../components/common/CurrencyIcon';
import { formatDate } from '../../utils/format';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { useAsync } from '../../hooks/useAsync';
import { useNavigate } from 'react-router-dom';
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 [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(() => {
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');
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) {
console.error('Error fetching payments:', err);
} finally {
setLoadingPayments(false);
}
};
fetchPayments();
}, []);
useEffect(() => {
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,
paidInvoices: paidInvoices.length,
overdueInvoices: overdueInvoices.length,
}));
}
} catch (err: any) {
console.error('Error fetching invoices:', err);
} finally {
setLoadingInvoices(false);
}
};
fetchInvoices();
}, []);
const handleRefresh = () => {
execute();
};
const getPaymentStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200';
case 'pending':
return 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200';
case 'failed':
return 'bg-gradient-to-r from-rose-50 to-red-50 text-rose-800 border-rose-200';
case 'refunded':
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';
}
};
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="space-y-6">
<EmptyState
title="Unable to Load Dashboard"
description={error?.message || 'Something went wrong. Please try again.'}
action={{
label: 'Retry',
onClick: handleRefresh
}}
/>
</div>
);
}
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-6 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-emerald-400 to-emerald-600 rounded-full"></div>
<h1 className="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-3 text-lg font-light">Comprehensive financial overview and analytics</p>
</div>
{/* Date Range & Actions */}
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div className="flex gap-3 items-center">
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
className="px-4 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"
/>
<span className="text-slate-500 font-medium">to</span>
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
className="px-4 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"
/>
</div>
<div className="flex gap-3 items-center">
<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),
'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="px-6 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 gap-2 text-sm"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div>
</div>
{/* Financial Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Total Revenue */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-emerald-500 animate-slide-up hover:shadow-2xl transition-all duration-300">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Total Revenue</p>
<p className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
{formatCurrency(financialSummary.totalRevenue || stats?.total_revenue || 0)}
</p>
</div>
<div className="bg-gradient-to-br from-emerald-100 to-emerald-200 p-4 rounded-2xl shadow-lg">
<DollarSign className="w-7 h-7 text-emerald-600" />
</div>
</div>
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<TrendingUp className="w-4 h-4 text-emerald-500 mr-2" />
<span className="text-emerald-600 font-semibold text-sm">Active</span>
<span className="text-slate-500 ml-2 text-sm">All time revenue</span>
</div>
</div>
{/* Total Payments */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-blue-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Total Payments</p>
<p className="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-4 rounded-2xl shadow-lg">
<CreditCard className="w-7 h-7 text-blue-600" />
</div>
</div>
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<span className="text-slate-500 text-sm">
{financialSummary.pendingPayments} pending payments
</span>
</div>
</div>
{/* Total Invoices */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-purple-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Total Invoices</p>
<p className="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-4 rounded-2xl shadow-lg">
<Receipt className="w-7 h-7 text-purple-600" />
</div>
</div>
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<span className="text-slate-500 text-sm">
{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-6">
{/* Recent Payments */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
<h2 className="text-xl 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-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">
{recentPayments.slice(0, 5).map((payment) => (
<div
key={payment.id}
className="flex items-center justify-between p-4 bg-gradient-to-r from-slate-50 to-white 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-4 flex-1">
<div className="p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-xl shadow-md">
<CreditCard className="w-5 h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-slate-900 truncate text-lg">
{formatCurrency(payment.amount)}
</p>
<div className="flex items-center gap-2 mt-1">
<p className="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-3 py-1.5 text-xs font-semibold rounded-full border shadow-sm ${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/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
<h2 className="text-xl 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-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">
{recentInvoices.slice(0, 5).map((invoice) => (
<div
key={invoice.id}
className="flex items-center justify-between p-4 bg-gradient-to-r from-slate-50 to-white 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-4 flex-1">
<div className="p-3 bg-gradient-to-br from-purple-100 to-purple-200 rounded-xl shadow-md">
<Receipt className="w-5 h-5 text-purple-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-slate-900 truncate text-lg">
{invoice.invoice_number}
</p>
<div className="flex items-center gap-2 mt-1">
<p className="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-3 py-1.5 text-xs font-semibold rounded-full border shadow-sm ${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/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="flex items-center gap-3 mb-4">
<AlertCircle className="w-5 h-5 text-amber-600" />
<h2 className="text-xl font-bold text-slate-900">Financial Alerts</h2>
</div>
<div className="space-y-3">
{financialSummary.overdueInvoices > 0 && (
<div className="p-4 bg-gradient-to-r from-rose-50 to-red-50 rounded-xl border border-rose-200">
<p className="text-rose-800 font-semibold">
{financialSummary.overdueInvoices} overdue invoice{financialSummary.overdueInvoices !== 1 ? 's' : ''} require attention
</p>
</div>
)}
{financialSummary.pendingPayments > 0 && (
<div className="p-4 bg-gradient-to-r from-amber-50 to-yellow-50 rounded-xl border border-amber-200">
<p className="text-amber-800 font-semibold">
{financialSummary.pendingPayments} pending payment{financialSummary.pendingPayments !== 1 ? 's' : ''} awaiting processing
</p>
</div>
)}
</div>
</div>
)}
</div>
);
};
export default AccountantDashboardPage;

View File

@@ -23,7 +23,7 @@ import {
Star
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Loading, EmptyState } from '../../components/common';
import { Loading, EmptyState, ExportButton } from '../../components/common';
import Pagination from '../../components/common/Pagination';
import CurrencyIcon from '../../components/common/CurrencyIcon';
import { useAsync } from '../../hooks/useAsync';
@@ -478,16 +478,37 @@ const AnalyticsDashboardPage: React.FC = () => {
View comprehensive reports and statistics for bookings, revenue, and performance
</p>
</div>
<button
onClick={handleExport}
className="group relative px-8 py-4 bg-gradient-to-r from-blue-500 via-blue-500 to-indigo-600 text-white font-semibold rounded-xl shadow-xl shadow-blue-500/30 hover:shadow-2xl hover:shadow-blue-500/40 transition-all duration-300 hover:scale-105 overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000"></div>
<div className="relative flex items-center gap-3">
<Download className="w-5 h-5" />
Export CSV
</div>
</button>
<ExportButton
data={reportData ? [
{
'Total Bookings': reportData.total_bookings || 0,
'Total Revenue': reportData.total_revenue || 0,
'Total Customers': reportData.total_customers || 0,
'Available Rooms': reportData.available_rooms || 0,
'Occupied Rooms': reportData.occupied_rooms || 0,
'Date Range': `${dateRange.from || 'All'} to ${dateRange.to || 'All'}`
},
...(reportData.revenue_by_date?.map(r => ({
'Date': r.date,
'Revenue': r.revenue,
'Bookings': r.bookings
})) || []),
...(reportData.top_rooms?.map(r => ({
'Room ID': r.room_id,
'Room Number': r.room_number,
'Bookings': r.bookings,
'Revenue': r.revenue
})) || []),
...(reportData.service_usage?.map(s => ({
'Service ID': s.service_id,
'Service Name': s.service_name,
'Usage Count': s.usage_count,
'Total Revenue': s.total_revenue
})) || [])
] : []}
filename="financial-reports"
title="Financial Reports & Analytics"
/>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { invoiceService, Invoice } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import { ExportButton } from '../../components/common';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { useNavigate } from 'react-router-dom';
import { formatDate } from '../../utils/format';
@@ -130,13 +131,36 @@ const InvoiceManagementPage: React.FC = () => {
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage and track all invoices</p>
</div>
<button
onClick={() => navigate('/admin/bookings')}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="w-5 h-5" />
Create Invoice from Booking
</button>
<div className="flex gap-3 items-center">
<ExportButton
data={invoices.map(i => ({
'Invoice Number': i.invoice_number,
'Customer Name': i.customer_name,
'Customer Email': i.customer_email,
'Booking ID': i.booking_id || 'N/A',
'Subtotal': formatCurrency(i.subtotal),
'Tax Amount': formatCurrency(i.tax_amount),
'Discount Amount': formatCurrency(i.discount_amount),
'Total Amount': formatCurrency(i.total_amount),
'Amount Paid': formatCurrency(i.amount_paid),
'Balance Due': formatCurrency(i.balance_due),
'Status': i.status,
'Issue Date': i.issue_date ? formatDate(i.issue_date) : 'N/A',
'Due Date': i.due_date ? formatDate(i.due_date) : 'N/A',
'Paid Date': i.paid_date ? formatDate(i.paid_date) : 'N/A',
'Is Proforma': i.is_proforma ? 'Yes' : 'No'
}))}
filename="invoices"
title="Invoice Management Report"
/>
<button
onClick={() => navigate('/admin/bookings')}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="w-5 h-5" />
Create Invoice from Booking
</button>
</div>
</div>
{}

View File

@@ -5,7 +5,9 @@ import type { Payment } from '../../services/api/paymentService';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import { ExportButton } from '../../components/common';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { formatDate } from '../../utils/format';
const PaymentManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -134,13 +136,43 @@ const PaymentManagementPage: React.FC = () => {
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{}
<div className="animate-fade-in">
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Payment Management
</h1>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-2">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Payment Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Track payment transactions</p>
</div>
<ExportButton
data={payments.map(p => ({
'Transaction ID': p.transaction_id || `PAY-${p.id}`,
'Booking Number': p.booking?.booking_number || 'N/A',
'Customer': p.booking?.user?.full_name || p.booking?.user?.email || 'N/A',
'Payment Method': p.payment_method || 'N/A',
'Payment Type': p.payment_type || 'N/A',
'Amount': formatCurrency(p.amount || 0),
'Status': p.payment_status,
'Payment Date': p.payment_date ? formatDate(p.payment_date) : 'N/A',
'Created At': p.created_at ? formatDate(p.created_at) : 'N/A'
}))}
filename="payments"
title="Payment Transactions Report"
customHeaders={{
'Transaction ID': 'Transaction ID',
'Booking Number': 'Booking Number',
'Customer': 'Customer',
'Payment Method': 'Payment Method',
'Payment Type': 'Payment Type',
'Amount': 'Amount',
'Status': 'Status',
'Payment Date': 'Payment Date',
'Created At': 'Created At'
}}
/>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Track payment transactions</p>
</div>
{}

View File

@@ -417,6 +417,7 @@ const UserManagementPage: React.FC = () => {
>
<option value="customer">Customer</option>
<option value="staff">Staff</option>
<option value="accountant">Accountant</option>
<option value="admin">Admin</option>
</select>
</div>

View File

@@ -74,12 +74,14 @@ const BookingPage: React.FC = () => {
'Please login to make a booking'
);
openModal('login');
} else if (userInfo?.role === 'admin' || userInfo?.role === 'staff') {
toast.error('Admin and staff users cannot make bookings');
} else if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
toast.error('Admin, staff, and accountant users cannot make bookings');
if (userInfo?.role === 'admin') {
navigate('/admin/dashboard', { replace: true });
} else {
} else if (userInfo?.role === 'staff') {
navigate('/staff/dashboard', { replace: true });
} else if (userInfo?.role === 'accountant') {
navigate('/accountant/dashboard', { replace: true });
}
}
}, [isAuthenticated, userInfo, navigate, id]);

View File

@@ -0,0 +1,310 @@
/**
* Luxury Export Utilities
* Provides beautiful export functionality for financial data in multiple formats
*/
export type ExportFormat = 'csv' | 'json' | 'pdf' | 'xlsx';
interface ExportOptions {
filename?: string;
title?: string;
headers?: string[];
data: any[];
format: ExportFormat;
}
/**
* Export data to CSV format
*/
export const exportToCSV = (options: ExportOptions): void => {
const { filename = 'export', data, headers } = options;
if (!data || data.length === 0) {
throw new Error('No data to export');
}
// Get headers from first object if not provided
const csvHeaders = headers || Object.keys(data[0]);
// Create CSV content
const csvContent = [
csvHeaders.join(','),
...data.map(row =>
csvHeaders.map(header => {
const value = row[header] ?? '';
// Escape commas and quotes
if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}).join(',')
)
].join('\n');
// Create blob and download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `${filename}-${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
/**
* Export data to JSON format
*/
export const exportToJSON = (options: ExportOptions): void => {
const { filename = 'export', data } = options;
if (!data || data.length === 0) {
throw new Error('No data to export');
}
const jsonContent = JSON.stringify(data, null, 2);
const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `${filename}-${new Date().toISOString().split('T')[0]}.json`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
/**
* Export data to PDF format (using browser print functionality with styling)
*/
export const exportToPDF = (options: ExportOptions): void => {
const { filename = 'export', data, title, headers } = options;
if (!data || data.length === 0) {
throw new Error('No data to export');
}
const pdfHeaders = headers || Object.keys(data[0]);
// Create a styled HTML table for PDF
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${title || filename}</title>
<style>
@page {
margin: 20mm;
size: A4 landscape;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #1a1a1a;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 10px;
margin-bottom: 30px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.header h1 {
margin: 0;
font-size: 28px;
font-weight: 600;
letter-spacing: 1px;
}
.header p {
margin: 10px 0 0 0;
opacity: 0.9;
font-size: 14px;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
thead {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
th {
padding: 15px;
text-align: left;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
padding: 12px 15px;
border-bottom: 1px solid #e0e0e0;
font-size: 12px;
}
tbody tr:hover {
background-color: #f8f9fa;
}
tbody tr:last-child td {
border-bottom: none;
}
.footer {
margin-top: 30px;
text-align: center;
color: #666;
font-size: 12px;
padding: 20px;
}
</style>
</head>
<body>
<div class="header">
<h1>${title || 'Financial Report'}</h1>
<p>Generated on ${new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}</p>
</div>
<table>
<thead>
<tr>
${pdfHeaders.map(header => `<th>${header.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}</th>`).join('')}
</tr>
</thead>
<tbody>
${data.map(row => `
<tr>
${pdfHeaders.map(header => `<td>${row[header] ?? ''}</td>`).join('')}
</tr>
`).join('')}
</tbody>
</table>
<div class="footer">
<p>© ${new Date().getFullYear()} Luxury Hotel - Confidential Financial Document</p>
</div>
</body>
</html>
`;
// Open in new window and trigger print
const printWindow = window.open('', '_blank');
if (printWindow) {
printWindow.document.write(htmlContent);
printWindow.document.close();
printWindow.onload = () => {
setTimeout(() => {
printWindow.print();
}, 250);
};
}
};
/**
* Export data to Excel format (XLSX) - using CSV as fallback with .xlsx extension
* Note: For true Excel format, you would need a library like xlsx
*/
export const exportToXLSX = (options: ExportOptions): void => {
const { filename = 'export', data, headers } = options;
if (!data || data.length === 0) {
throw new Error('No data to export');
}
// For now, we'll use CSV format but with .xlsx extension
// Excel can open CSV files, and this works without additional dependencies
const csvHeaders = headers || Object.keys(data[0]);
const csvContent = [
csvHeaders.join(','),
...data.map(row =>
csvHeaders.map(header => {
const value = row[header] ?? '';
if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}).join(',')
)
].join('\n');
// Create blob with Excel-compatible MIME type
const blob = new Blob(['\ufeff' + csvContent], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `${filename}-${new Date().toISOString().split('T')[0]}.xlsx`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
/**
* Main export function that routes to the appropriate format
*/
export const exportData = (options: ExportOptions): void => {
try {
switch (options.format) {
case 'csv':
exportToCSV(options);
break;
case 'json':
exportToJSON(options);
break;
case 'pdf':
exportToPDF(options);
break;
case 'xlsx':
exportToXLSX(options);
break;
default:
throw new Error(`Unsupported export format: ${options.format}`);
}
} catch (error: any) {
throw new Error(`Export failed: ${error.message}`);
}
};
/**
* Helper to format data for export
*/
export const formatDataForExport = (data: any[], customHeaders?: Record<string, string>): { headers: string[], formattedData: any[] } => {
if (!data || data.length === 0) {
return { headers: [], formattedData: [] };
}
const headers = customHeaders ? Object.keys(customHeaders) : Object.keys(data[0]);
const formattedData = data.map(row => {
const formatted: any = {};
headers.forEach(header => {
const value = row[header];
// Format dates
if (value && typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
formatted[header] = new Date(value).toLocaleDateString();
} else {
formatted[header] = value ?? '';
}
});
return formatted;
});
const displayHeaders = customHeaders
? headers.map(h => customHeaders[h] || h)
: headers.map(h => h.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()));
return { headers: displayHeaders, formattedData };
};