This commit is contained in:
Iliyan Angelov
2025-11-23 18:59:18 +02:00
parent be07802066
commit 627959f52b
1840 changed files with 236564 additions and 3475 deletions

View File

@@ -1,8 +1,11 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { SidebarAccountant } from '../components/layout';
import { useResponsive } from '../hooks';
const AccountantLayout: React.FC = () => {
const { isMobile, isTablet, isDesktop } = useResponsive();
return (
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
{/* Sidebar */}
@@ -10,7 +13,7 @@ const AccountantLayout: React.FC = () => {
{/* Main Content */}
<div className="flex-1 overflow-auto lg:ml-0">
<div className="min-h-screen pt-20 lg:pt-0">
<div className={`min-h-screen ${isMobile ? 'pt-20' : 'pt-0'}`}>
<Outlet />
</div>
</div>

View File

@@ -1,21 +1,140 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import React, { useState, Suspense, useEffect } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { SidebarAdmin } from '../components/layout';
import { Sparkles, Zap } from 'lucide-react';
import { useResponsive } from '../hooks';
const AdminLayout: React.FC = () => {
// Luxury Loading Overlay
const LuxuryLoadingOverlay: React.FC = () => {
return (
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
{}
<SidebarAdmin />
{}
<div className="flex-1 overflow-auto lg:ml-0">
<div className="min-h-screen pt-20 lg:pt-0">
<Outlet />
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-slate-50/95 via-white/95 to-slate-50/95 backdrop-blur-sm z-50">
<div className="text-center space-y-4 sm:space-y-6 px-4">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-r from-amber-400 via-amber-500 to-amber-600 rounded-2xl sm:rounded-3xl blur-2xl opacity-60 animate-pulse"></div>
<div className="relative bg-gradient-to-br from-amber-500 via-amber-600 to-amber-700 p-6 sm:p-8 rounded-2xl sm:rounded-3xl shadow-2xl border border-amber-400/30">
<div className="flex items-center justify-center gap-3 sm:gap-4 mb-3 sm:mb-4">
<Sparkles className="w-6 h-6 sm:w-8 sm:h-8 text-white animate-pulse" />
<Zap className="w-6 h-6 sm:w-8 sm:h-8 text-white animate-pulse" style={{ animationDelay: '0.2s' }} />
</div>
<div className="w-12 sm:w-16 h-1 bg-white/30 rounded-full mx-auto overflow-hidden">
<div className="h-full bg-white rounded-full animate-shimmer" style={{
width: '60%',
animation: 'shimmer 2s infinite'
}}></div>
</div>
</div>
</div>
<p className="text-slate-600 font-medium text-base sm:text-lg tracking-wide">Loading Dashboard...</p>
</div>
</div>
);
};
const AdminLayout: React.FC = () => {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false);
const { isMobile, isTablet, isDesktop } = useResponsive();
const location = useLocation();
// Handle route transitions
useEffect(() => {
setIsTransitioning(true);
const timer = setTimeout(() => {
setIsTransitioning(false);
}, 300);
return () => clearTimeout(timer);
}, [location.pathname]);
return (
<div className={`${isMobile ? 'relative' : 'flex'} h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 relative overflow-hidden`}>
{/* Luxury Background Pattern */}
<div className="fixed inset-0 opacity-[0.02] pointer-events-none z-0">
<div
className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle at 2px 2px, rgba(251, 191, 36, 0.3) 1px, transparent 0)`,
backgroundSize: '60px 60px'
}}
></div>
</div>
{/* Animated Gradient Overlay */}
<div className="fixed inset-0 bg-gradient-to-br from-amber-50/20 via-transparent to-amber-100/10 pointer-events-none z-0"></div>
<SidebarAdmin
isCollapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
<main
className={`
overflow-x-hidden overflow-y-auto transition-all duration-500 ease-in-out
relative z-5
${isMobile ? 'w-full h-full' : 'flex-1'}
`}
style={{ WebkitOverflowScrolling: 'touch' }}
>
{/* Luxury Content Container */}
<div className="relative min-h-screen">
{/* Top Spacing for Mobile Menu Button - Minimal */}
<div className="h-12 sm:h-14 md:h-14 lg:h-0"></div>
{/* Content Wrapper with Luxury Styling */}
<div
className={`
relative transition-all duration-500 ease-in-out
${isTransitioning ? 'opacity-0 scale-[0.98]' : 'opacity-100 scale-100'}
${isMobile ? 'px-2 py-2' : 'px-2 sm:px-3 md:px-4 lg:px-6 xl:px-8 py-3 sm:py-4 md:py-5 lg:py-8'}
max-w-full
`}
>
{/* Luxury Content Area */}
<div className="relative max-w-full overflow-x-hidden">
{/* Subtle Top Border Accent */}
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-amber-300/30 to-transparent"></div>
{/* Main Content with Luxury Padding */}
<div className={`relative ${isMobile ? 'pt-1' : 'pt-3 sm:pt-4 md:pt-5 lg:pt-6'} max-w-full`}>
<Suspense fallback={<LuxuryLoadingOverlay />}>
<div className="relative z-10 max-w-full">
<Outlet />
</div>
</Suspense>
</div>
</div>
</div>
</div>
</main>
{/* Custom CSS for shimmer animation */}
<style>{`
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(200%);
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.6s ease-out;
}
`}</style>
</div>
);
};
export default AdminLayout;

View File

@@ -3,8 +3,11 @@ import { Outlet } from 'react-router-dom';
import { SidebarStaff } from '../components/layout';
import StaffChatNotification from '../components/chat/StaffChatNotification';
import { ChatNotificationProvider } from '../contexts/ChatNotificationContext';
import { useResponsive } from '../hooks';
const StaffLayout: React.FC = () => {
const { isMobile, isTablet, isDesktop } = useResponsive();
return (
<ChatNotificationProvider>
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
@@ -13,7 +16,7 @@ const StaffLayout: React.FC = () => {
{}
<div className="flex-1 overflow-auto lg:ml-0">
<div className="min-h-screen pt-20 lg:pt-0">
<div className={`min-h-screen ${isMobile ? 'pt-20' : 'pt-0'}`}>
<Outlet />
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,353 @@
import React, { useEffect, useState } from 'react';
import { Search, Plus, Edit, Trash2, Eye, FileText, Filter } from 'lucide-react';
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';
const InvoiceManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const navigate = useNavigate();
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({
search: '',
status: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 10;
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchInvoices();
}, [filters, currentPage]);
const fetchInvoices = async () => {
try {
setLoading(true);
const response = await invoiceService.getInvoices({
status: filters.status || undefined,
page: currentPage,
limit: itemsPerPage,
});
if (response.status === 'success' && response.data) {
let invoiceList = response.data.invoices || [];
if (filters.search) {
invoiceList = invoiceList.filter((inv) =>
inv.invoice_number.toLowerCase().includes(filters.search.toLowerCase()) ||
inv.customer_name.toLowerCase().includes(filters.search.toLowerCase()) ||
inv.customer_email.toLowerCase().includes(filters.search.toLowerCase()) ||
(inv.promotion_code && inv.promotion_code.toLowerCase().includes(filters.search.toLowerCase()))
);
}
setInvoices(invoiceList);
setTotalPages(response.data.total_pages || 1);
setTotalItems(response.data.total || 0);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load invoices');
} finally {
setLoading(false);
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
draft: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: 'Draft',
border: 'border-slate-200'
},
sent: {
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
text: 'text-blue-800',
label: 'Sent',
border: 'border-blue-200'
},
paid: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Paid',
border: 'border-emerald-200'
},
overdue: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: 'Overdue',
border: 'border-rose-200'
},
cancelled: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: 'Cancelled',
border: 'border-slate-200'
},
};
return badges[status] || badges.draft;
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this invoice?')) {
return;
}
try {
await invoiceService.deleteInvoice(id);
toast.success('Invoice deleted successfully');
fetchInvoices();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete invoice');
}
};
if (loading && invoices.length === 0) {
return <Loading fullScreen text="Loading invoices..." />;
}
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">
{}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<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">
Invoice Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage and track all invoices</p>
</div>
<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('/accountant/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>
{}
<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.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search invoices..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="sent">Sent</option>
<option value="paid">Paid</option>
<option value="overdue">Overdue</option>
<option value="cancelled">Cancelled</option>
</select>
<div className="flex items-center gap-3 px-4 py-3.5 bg-gradient-to-r from-slate-50 to-white border-2 border-slate-200 rounded-xl">
<Filter className="w-5 h-5 text-amber-600" />
<span className="text-sm font-semibold text-slate-700">
{totalItems} invoice{totalItems !== 1 ? 's' : ''}
</span>
</div>
</div>
</div>
{}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Invoice #
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Customer
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Booking
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Amount
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Promotion
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Status
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Due Date
</th>
<th className="px-8 py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{invoices.length > 0 ? (
invoices.map((invoice, index) => {
const statusBadge = getStatusBadge(invoice.status);
return (
<tr
key={invoice.id}
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
style={{ animationDelay: `${index * 0.05}s` }}
>
<td className="px-8 py-5 whitespace-nowrap">
<div className="flex items-center">
<FileText className="w-5 h-5 text-amber-600 mr-3" />
<span className="text-sm font-bold text-slate-900 font-mono">
{invoice.invoice_number}
</span>
</div>
</td>
<td className="px-8 py-5">
<div className="text-sm font-semibold text-slate-900">{invoice.customer_name}</div>
<div className="text-sm text-slate-500">{invoice.customer_email}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<span className="text-sm font-medium text-amber-600">#{invoice.booking_id}</span>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
{formatCurrency(invoice.total_amount)}
</div>
{invoice.balance_due > 0 && (
<div className="text-xs text-rose-600 font-medium mt-1">
Due: {formatCurrency(invoice.balance_due)}
</div>
)}
{invoice.discount_amount > 0 && (
<div className="text-xs text-green-600 font-medium mt-1">
Discount: -{formatCurrency(invoice.discount_amount)}
</div>
)}
</td>
<td className="px-8 py-5 whitespace-nowrap">
{invoice.promotion_code ? (
<span className="px-3 py-1 text-xs font-semibold rounded-full bg-gradient-to-r from-purple-50 to-pink-50 text-purple-700 border border-purple-200">
{invoice.promotion_code}
</span>
) : (
<span className="text-xs text-slate-400"></span>
)}
{invoice.is_proforma && (
<div className="text-xs text-blue-600 font-medium mt-1">
Proforma
</div>
)}
</td>
<td className="px-8 py-5 whitespace-nowrap">
<span className={`px-4 py-1.5 text-xs font-semibold rounded-full border shadow-sm ${statusBadge.bg} ${statusBadge.text} ${statusBadge.border || ''}`}>
{statusBadge.label}
</span>
</td>
<td className="px-8 py-5 whitespace-nowrap text-sm text-slate-600">
{formatDate(invoice.due_date, 'short')}
</td>
<td className="px-8 py-5 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => navigate(`/accountant/invoices/${invoice.id}`)}
className="p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all duration-200 shadow-sm hover:shadow-md border border-blue-200 hover:border-blue-300"
title="View"
>
<Eye className="w-5 h-5" />
</button>
<button
onClick={() => navigate(`/accountant/invoices/${invoice.id}/edit`)}
className="p-2 rounded-lg text-indigo-600 hover:text-indigo-700 hover:bg-indigo-50 transition-all duration-200 shadow-sm hover:shadow-md border border-indigo-200 hover:border-indigo-300"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(invoice.id)}
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
title="Delete"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={8} className="px-8 py-12 text-center">
<div className="text-slate-500">
<FileText className="w-16 h-16 mx-auto mb-4 text-slate-300" />
<p className="text-lg font-semibold">No invoices found</p>
<p className="text-sm mt-1">Create your first invoice to get started</p>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
{}
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</div>
</div>
);
};
export default InvoiceManagementPage;

View File

@@ -0,0 +1,317 @@
import React, { useEffect, useState } from 'react';
import { Search } from 'lucide-react';
import { paymentService } from '../../services/api';
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();
const [payments, setPayments] = useState<Payment[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({
search: '',
method: '',
from: '',
to: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchPayments();
}, [filters, currentPage]);
const fetchPayments = async () => {
try {
setLoading(true);
const response = await paymentService.getPayments({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setPayments(response.data.payments);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load payments list');
} finally {
setLoading(false);
}
};
const getMethodBadge = (method: string) => {
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
cash: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Cash',
border: 'border-emerald-200'
},
bank_transfer: {
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
text: 'text-blue-800',
label: 'Bank transfer',
border: 'border-blue-200'
},
stripe: {
bg: 'bg-gradient-to-r from-indigo-50 to-purple-50',
text: 'text-indigo-800',
label: 'Stripe',
border: 'border-indigo-200'
},
paypal: {
bg: 'bg-gradient-to-r from-blue-50 to-cyan-50',
text: 'text-blue-800',
label: 'PayPal',
border: 'border-blue-200'
},
credit_card: {
bg: 'bg-gradient-to-r from-purple-50 to-pink-50',
text: 'text-purple-800',
label: 'Credit card',
border: 'border-purple-200'
},
};
const badge = badges[method] || badges.cash;
return (
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
{badge.label}
</span>
);
};
const getPaymentStatusBadge = (status: string) => {
const statusConfig: Record<string, { bg: string; text: string; label: string; border: string }> = {
completed: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: '✅ Paid',
border: 'border-emerald-200'
},
pending: {
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
text: 'text-amber-800',
label: '⏳ Pending',
border: 'border-amber-200'
},
failed: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: '❌ Failed',
border: 'border-rose-200'
},
refunded: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: '💰 Refunded',
border: 'border-slate-200'
},
};
const config = statusConfig[status] || statusConfig.pending;
return (
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${config.bg} ${config.text} ${config.border}`}>
{config.label}
</span>
);
};
if (loading) {
return <Loading />;
}
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">
{}
<div className="animate-fade-in">
<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>
</div>
{}
<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.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.method}
onChange={(e) => setFilters({ ...filters, method: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All methods</option>
<option value="cash">Cash</option>
<option value="stripe">Stripe</option>
<option value="credit_card">Credit card</option>
</select>
<input
type="date"
value={filters.from}
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
placeholder="From date"
/>
<input
type="date"
value={filters.to}
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
placeholder="To date"
/>
</div>
</div>
{}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Transaction ID</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Booking Number</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Customer</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Method</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Type</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Amount</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Payment Date</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{payments.map((payment, index) => (
<tr
key={payment.id}
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
style={{ animationDelay: `${index * 0.05}s` }}
>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold text-slate-900 font-mono">{payment.transaction_id || `PAY-${payment.id}`}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-semibold text-amber-600">{payment.booking?.booking_number}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-medium text-slate-900">{payment.booking?.user?.name}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
{getMethodBadge(payment.payment_method)}
</td>
<td className="px-8 py-5 whitespace-nowrap">
{payment.payment_type === 'deposit' ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
Deposit (20%)
</span>
) : payment.payment_type === 'remaining' ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 border border-blue-200">
Remaining
</span>
) : (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
Full Payment
</span>
)}
</td>
<td className="px-8 py-5 whitespace-nowrap">
{getPaymentStatusBadge(payment.payment_status)}
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
{formatCurrency(payment.amount)}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm text-slate-600">
{new Date(payment.payment_date || payment.createdAt || '').toLocaleDateString('en-US')}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
{}
<div className="bg-gradient-to-r from-amber-500 via-amber-600 to-amber-700 rounded-2xl shadow-2xl p-8 text-white animate-fade-in" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold mb-2 text-amber-100">Total Revenue</h3>
<p className="text-4xl font-bold">
{formatCurrency(payments
.filter(p => p.payment_status === 'completed')
.reduce((sum, p) => sum + p.amount, 0))}
</p>
<p className="text-sm mt-3 text-amber-100/90">
Total {payments.filter(p => p.payment_status === 'completed').length} paid transaction{payments.filter(p => p.payment_status === 'completed').length !== 1 ? 's' : ''}
</p>
</div>
<div className="bg-white/20 backdrop-blur-sm p-6 rounded-2xl">
<div className="text-5xl font-bold text-white/80">{payments.filter(p => p.payment_status === 'completed').length}</div>
</div>
</div>
</div>
</div>
);
};
export default PaymentManagementPage;

View File

@@ -0,0 +1,11 @@
/**
* Accountant Pages
*
* All pages accessible only to accountants
*/
export { default as AccountantDashboardPage } from './DashboardPage';
export { default as PaymentManagementPage } from './PaymentManagementPage';
export { default as InvoiceManagementPage } from './InvoiceManagementPage';
export { default as AnalyticsDashboardPage } from './AnalyticsDashboardPage';

View File

@@ -0,0 +1,679 @@
import React, { useState, useEffect } from 'react';
import {
BarChart3,
TrendingUp,
Users,
DollarSign,
Calendar,
Download,
RefreshCw,
Building2,
Activity,
Star,
CreditCard,
Target,
Award,
ArrowDown,
Plus,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Loading } from '../../components/common';
import { useAsync } from '../../hooks/useAsync';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import analyticsService, {
ComprehensiveAnalyticsData,
RevPARData,
ADRData,
OccupancyRateData,
RevenueForecastData,
MarketPenetrationData,
StaffPerformanceData,
ServiceUsageData,
OperationalEfficiencyData,
GuestLTVData,
RepeatGuestRateData,
GuestSatisfactionTrendsData,
ProfitLossData,
PaymentMethodAnalyticsData,
RefundAnalysisData,
} from '../../services/api/analyticsService';
import { SimpleBarChart, SimpleLineChart, SimplePieChart, KPICard } from '../../components/analytics/SimpleChart';
import { exportData } from '../../utils/exportUtils';
import CustomReportBuilder from '../../components/analytics/CustomReportBuilder';
type AnalyticsCategory = 'revenue' | 'operational' | 'guest' | 'financial' | 'comprehensive';
const AdvancedAnalyticsPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [activeCategory, setActiveCategory] = useState<AnalyticsCategory>('comprehensive');
const [showReportBuilder, setShowReportBuilder] = useState(false);
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],
});
// Revenue Analytics
const { data: revparData, loading: revparLoading, execute: fetchRevPAR } = useAsync<RevPARData>(
() => analyticsService.getRevPAR({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: adrData, loading: adrLoading, execute: fetchADR } = useAsync<ADRData>(
() => analyticsService.getADR({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: occupancyData, loading: occupancyLoading, execute: fetchOccupancy } = useAsync<OccupancyRateData>(
() => analyticsService.getOccupancyRate({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: forecastData, execute: fetchForecast } = useAsync<RevenueForecastData>(
() => analyticsService.getRevenueForecast(30).then(r => r.data),
{ immediate: false }
);
const { data: marketPenetrationData, execute: fetchMarketPenetration } = useAsync<MarketPenetrationData>(
() => analyticsService.getMarketPenetration({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
{ immediate: false }
);
// Operational Analytics
const { data: staffPerformanceData, loading: staffLoading, execute: fetchStaffPerformance } = useAsync<StaffPerformanceData>(
() => analyticsService.getStaffPerformance({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: serviceUsageData, loading: serviceLoading, execute: fetchServiceUsage } = useAsync<ServiceUsageData>(
() => analyticsService.getServiceUsageAnalytics({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: efficiencyData, loading: efficiencyLoading, execute: fetchEfficiency } = useAsync<OperationalEfficiencyData>(
() => analyticsService.getOperationalEfficiency({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
{ immediate: false }
);
// Guest Analytics
const { data: ltvData, loading: ltvLoading, execute: fetchLTV } = useAsync<GuestLTVData>(
() => analyticsService.getGuestLifetimeValue({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: repeatRateData, loading: repeatLoading, execute: fetchRepeatRate } = useAsync<RepeatGuestRateData>(
() => analyticsService.getRepeatGuestRate({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: satisfactionData, loading: satisfactionLoading, execute: fetchSatisfaction } = useAsync<GuestSatisfactionTrendsData>(
() => analyticsService.getGuestSatisfactionTrends({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
{ immediate: false }
);
// Financial Analytics
const { data: profitLossData, loading: profitLossLoading, execute: fetchProfitLoss } = useAsync<ProfitLossData>(
() => analyticsService.getProfitLoss({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: paymentMethodData, loading: paymentMethodLoading, execute: fetchPaymentMethods } = useAsync<PaymentMethodAnalyticsData>(
() => analyticsService.getPaymentMethodAnalytics({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: refundData, loading: refundLoading, execute: fetchRefunds } = useAsync<RefundAnalysisData>(
() => analyticsService.getRefundAnalysis({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
{ immediate: false }
);
// Comprehensive Analytics
const { data: comprehensiveData, loading: comprehensiveLoading, execute: fetchComprehensive } = useAsync<ComprehensiveAnalyticsData>(
() => analyticsService.getComprehensiveAnalytics({
from: dateRange.from,
to: dateRange.to,
include_revenue: true,
include_operational: true,
include_guest: true,
include_financial: true,
}).then(r => r.data),
{ immediate: true }
);
useEffect(() => {
loadCategoryData();
}, [activeCategory, dateRange]);
const loadCategoryData = async () => {
try {
if (activeCategory === 'comprehensive') {
await fetchComprehensive();
} else if (activeCategory === 'revenue') {
await Promise.all([fetchRevPAR(), fetchADR(), fetchOccupancy(), fetchForecast(), fetchMarketPenetration()]);
} else if (activeCategory === 'operational') {
await Promise.all([fetchStaffPerformance(), fetchServiceUsage(), fetchEfficiency()]);
} else if (activeCategory === 'guest') {
await Promise.all([fetchLTV(), fetchRepeatRate(), fetchSatisfaction()]);
} else if (activeCategory === 'financial') {
await Promise.all([fetchProfitLoss(), fetchPaymentMethods(), fetchRefunds()]);
}
} catch (error: any) {
toast.error(error.message || 'Failed to load analytics data');
}
};
const handleExport = (format: 'csv' | 'xlsx' | 'pdf' | 'json') => {
try {
let exportDataArray: any[] = [];
let filename = 'analytics';
let title = 'Analytics Report';
if (activeCategory === 'revenue' && comprehensiveData?.revenue) {
const revenue = comprehensiveData.revenue;
exportDataArray = [
{ Metric: 'RevPAR', Value: revenue.revpar.revpar },
{ Metric: 'ADR', Value: revenue.adr.adr },
{ Metric: 'Occupancy Rate', Value: `${revenue.occupancy_rate.occupancy_rate}%` },
];
filename = 'revenue-analytics';
title = 'Revenue Analytics Report';
} else if (activeCategory === 'operational' && comprehensiveData?.operational) {
const operational = comprehensiveData.operational;
exportDataArray = operational.staff_performance.staff_performance.map(staff => ({
'Staff Name': staff.staff_name,
'Email': staff.email,
'Check-ins Handled': staff.check_ins_handled,
'Performance Score': staff.performance_score,
}));
filename = 'operational-analytics';
title = 'Operational Analytics Report';
} else if (activeCategory === 'guest' && comprehensiveData?.guest) {
const guest = comprehensiveData.guest;
exportDataArray = guest.lifetime_value.guests.slice(0, 50).map(g => ({
'Guest Name': g.name,
'Email': g.email,
'Total Bookings': g.total_bookings,
'Lifetime Value': g.lifetime_value,
'Average Booking Value': g.average_booking_value,
}));
filename = 'guest-analytics';
title = 'Guest Analytics Report';
} else if (activeCategory === 'financial' && comprehensiveData?.financial) {
const financial = comprehensiveData.financial;
exportDataArray = financial.payment_methods.payment_methods.map(pm => ({
'Payment Method': pm.payment_method,
'Transaction Count': pm.transaction_count,
'Total Amount': pm.total_amount,
'Average Amount': pm.average_amount,
'Percentage': `${pm.percentage}%`,
}));
filename = 'financial-analytics';
title = 'Financial Analytics Report';
} else if (comprehensiveData) {
// Export comprehensive data
exportDataArray = [comprehensiveData];
filename = 'comprehensive-analytics';
title = 'Comprehensive Analytics Report';
}
if (exportDataArray.length > 0) {
exportData({
filename,
title,
data: exportDataArray,
format,
});
toast.success(`Exported ${format.toUpperCase()} successfully`);
} else {
toast.error('No data to export');
}
} catch (error: any) {
toast.error(`Export failed: ${error.message}`);
}
};
const isLoading = comprehensiveLoading ||
(activeCategory === 'revenue' && (revparLoading || adrLoading || occupancyLoading)) ||
(activeCategory === 'operational' && (staffLoading || serviceLoading || efficiencyLoading)) ||
(activeCategory === 'guest' && (ltvLoading || repeatLoading || satisfactionLoading)) ||
(activeCategory === 'financial' && (profitLossLoading || paymentMethodLoading || refundLoading));
const categories = [
{ id: 'comprehensive' as AnalyticsCategory, label: 'Comprehensive', icon: BarChart3, color: 'blue' },
{ id: 'revenue' as AnalyticsCategory, label: 'Revenue', icon: DollarSign, color: 'green' },
{ id: 'operational' as AnalyticsCategory, label: 'Operational', icon: Activity, color: 'orange' },
{ id: 'guest' as AnalyticsCategory, label: 'Guest', icon: Users, color: 'purple' },
{ id: 'financial' as AnalyticsCategory, label: 'Financial', icon: CreditCard, color: 'red' },
];
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Header */}
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Advanced Analytics & BI</h1>
<p className="text-gray-600">Comprehensive business intelligence and analytics dashboard</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-gray-500" />
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
<span className="text-gray-500">to</span>
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
<button
onClick={loadCategoryData}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
<div className="flex items-center gap-2">
<button
onClick={() => setShowReportBuilder(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Custom Report
</button>
<button
onClick={() => handleExport('csv')}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2"
>
<Download className="w-4 h-4" />
Export
</button>
</div>
</div>
</div>
</div>
{/* Category Tabs */}
<div className="bg-white rounded-xl shadow-lg p-2 border border-gray-100">
<div className="flex flex-wrap gap-2">
{categories.map((category) => {
const Icon = category.icon;
const isActive = activeCategory === category.id;
return (
<button
key={category.id}
onClick={() => setActiveCategory(category.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all ${
isActive
? 'bg-indigo-600 text-white shadow-md'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<Icon className="w-4 h-4" />
{category.label}
</button>
);
})}
</div>
</div>
{/* Content */}
{isLoading ? (
<Loading fullScreen text="Loading analytics..." />
) : (
<div className="space-y-6">
{activeCategory === 'comprehensive' && comprehensiveData && (
<ComprehensiveView data={comprehensiveData} formatCurrency={formatCurrency} />
)}
{activeCategory === 'revenue' && (
<RevenueView
revparData={revparData ?? undefined}
adrData={adrData ?? undefined}
occupancyData={occupancyData ?? undefined}
forecastData={forecastData ?? undefined}
marketPenetrationData={marketPenetrationData ?? undefined}
formatCurrency={formatCurrency}
/>
)}
{activeCategory === 'operational' && (
<OperationalView
staffPerformanceData={staffPerformanceData ?? undefined}
serviceUsageData={serviceUsageData ?? undefined}
efficiencyData={efficiencyData ?? undefined}
formatCurrency={formatCurrency}
/>
)}
{activeCategory === 'guest' && (
<GuestView
ltvData={ltvData ?? undefined}
repeatRateData={repeatRateData ?? undefined}
satisfactionData={satisfactionData ?? undefined}
formatCurrency={formatCurrency}
/>
)}
{activeCategory === 'financial' && (
<FinancialView
profitLossData={profitLossData ?? undefined}
paymentMethodData={paymentMethodData ?? undefined}
refundData={refundData ?? undefined}
formatCurrency={formatCurrency}
/>
)}
</div>
)}
{/* Custom Report Builder Modal */}
{showReportBuilder && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<CustomReportBuilder onClose={() => setShowReportBuilder(false)} />
</div>
</div>
)}
</div>
</div>
);
};
// Comprehensive View Component
const ComprehensiveView: React.FC<{ data: ComprehensiveAnalyticsData; formatCurrency: (amount: number) => string }> = ({ data, formatCurrency }) => {
return (
<div className="space-y-6">
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{data.revenue && (
<>
<KPICard
title="RevPAR"
value={formatCurrency(data.revenue.revpar.revpar)}
icon={<TrendingUp className="w-6 h-6" />}
color="blue"
/>
<KPICard
title="ADR"
value={formatCurrency(data.revenue.adr.adr)}
icon={<DollarSign className="w-6 h-6" />}
color="green"
/>
<KPICard
title="Occupancy Rate"
value={`${data.revenue.occupancy_rate.occupancy_rate.toFixed(1)}%`}
icon={<Building2 className="w-6 h-6" />}
color="orange"
/>
<KPICard
title="Net Revenue"
value={data.financial ? formatCurrency(data.financial.profit_loss.net_revenue) : 'N/A'}
icon={<CreditCard className="w-6 h-6" />}
color="purple"
/>
</>
)}
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{data.revenue && data.revenue.market_penetration && (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Market Penetration by Room Type</h3>
<SimpleBarChart
data={data.revenue.market_penetration.penetration_by_room_type.map((item) => ({
label: item.room_type,
value: item.market_share,
}))}
height={200}
/>
</div>
)}
{data.financial && data.financial.payment_methods && (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Payment Methods Distribution</h3>
<SimplePieChart
data={data.financial.payment_methods.payment_methods.map((item) => ({
label: item.payment_method,
value: item.percentage,
}))}
size={200}
/>
</div>
)}
</div>
</div>
);
};
// Revenue View Component
const RevenueView: React.FC<{
revparData?: RevPARData;
adrData?: ADRData;
occupancyData?: OccupancyRateData;
forecastData?: RevenueForecastData;
marketPenetrationData?: MarketPenetrationData;
formatCurrency: (amount: number) => string;
}> = ({ revparData, adrData, occupancyData, forecastData, marketPenetrationData, formatCurrency }) => {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{revparData && (
<KPICard
title="RevPAR"
value={formatCurrency(revparData.revpar)}
subtitle={`${revparData.period_days} days`}
icon={<TrendingUp className="w-6 h-6" />}
color="blue"
/>
)}
{adrData && (
<KPICard
title="ADR"
value={formatCurrency(adrData.adr)}
icon={<DollarSign className="w-6 h-6" />}
color="green"
/>
)}
{occupancyData && (
<KPICard
title="Occupancy Rate"
value={`${occupancyData.occupancy_rate.toFixed(1)}%`}
subtitle={`${occupancyData.occupied_room_nights} / ${occupancyData.available_room_nights} nights`}
icon={<Building2 className="w-6 h-6" />}
color="orange"
/>
)}
</div>
{forecastData && (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Revenue Forecast (Next 30 Days)</h3>
<SimpleLineChart
data={forecastData.forecast.slice(0, 30).map(item => ({
label: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
value: item.forecasted_revenue,
}))}
height={200}
/>
</div>
)}
{marketPenetrationData && (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Market Penetration by Room Type</h3>
<SimpleBarChart
data={marketPenetrationData.penetration_by_room_type.map((item) => ({
label: item.room_type,
value: item.market_share,
}))}
height={200}
/>
</div>
)}
</div>
);
};
// Operational View Component
const OperationalView: React.FC<{
staffPerformanceData?: StaffPerformanceData;
serviceUsageData?: ServiceUsageData;
efficiencyData?: OperationalEfficiencyData;
formatCurrency: (amount: number) => string;
}> = ({ serviceUsageData, efficiencyData, formatCurrency }) => {
return (
<div className="space-y-6">
{efficiencyData && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<KPICard
title="Conversion Rate"
value={`${efficiencyData.conversion_rate.toFixed(1)}%`}
icon={<Target className="w-6 h-6" />}
color="blue"
/>
<KPICard
title="Avg Booking Value"
value={formatCurrency(efficiencyData.average_booking_value)}
icon={<DollarSign className="w-6 h-6" />}
color="green"
/>
<KPICard
title="Cancellation Rate"
value={`${efficiencyData.cancellation_rate.toFixed(1)}%`}
icon={<Activity className="w-6 h-6" />}
color="red"
/>
</div>
)}
{serviceUsageData && (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top Services by Revenue</h3>
<SimpleBarChart
data={serviceUsageData.services.slice(0, 10).map(item => ({
label: item.service_name,
value: item.total_revenue,
}))}
height={250}
/>
</div>
)}
</div>
);
};
// Guest View Component
const GuestView: React.FC<{
ltvData?: GuestLTVData;
repeatRateData?: RepeatGuestRateData;
satisfactionData?: GuestSatisfactionTrendsData;
formatCurrency: (amount: number) => string;
}> = ({ ltvData, repeatRateData, satisfactionData, formatCurrency }) => {
return (
<div className="space-y-6">
{repeatRateData && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<KPICard
title="Repeat Guest Rate"
value={`${repeatRateData.repeat_guest_rate.toFixed(1)}%`}
subtitle={`${repeatRateData.repeat_guests} repeat / ${repeatRateData.total_guests} total`}
icon={<Users className="w-6 h-6" />}
color="purple"
/>
{ltvData && (
<KPICard
title="Average LTV"
value={formatCurrency(ltvData.average_ltv)}
subtitle={`${ltvData.total_guests_analyzed} guests`}
icon={<Award className="w-6 h-6" />}
color="blue"
/>
)}
{satisfactionData && (
<KPICard
title="Avg Satisfaction"
value={`${satisfactionData.overall_average_rating.toFixed(1)}/5`}
subtitle={`${satisfactionData.total_reviews} reviews`}
icon={<Star className="w-6 h-6" />}
color="orange"
/>
)}
</div>
)}
{satisfactionData && (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Satisfaction Trends</h3>
<SimpleLineChart
data={satisfactionData.trends.map(item => ({
label: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
value: item.average_rating,
}))}
height={200}
/>
</div>
)}
</div>
);
};
// Financial View Component
const FinancialView: React.FC<{
profitLossData?: ProfitLossData;
paymentMethodData?: PaymentMethodAnalyticsData;
refundData?: RefundAnalysisData;
formatCurrency: (amount: number) => string;
}> = ({ profitLossData, paymentMethodData, formatCurrency }) => {
return (
<div className="space-y-6">
{profitLossData && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<KPICard
title="Total Revenue"
value={formatCurrency(profitLossData.total_revenue)}
icon={<DollarSign className="w-6 h-6" />}
color="green"
/>
<KPICard
title="Refunds"
value={formatCurrency(profitLossData.refunds)}
icon={<ArrowDown className="w-6 h-6" />}
color="red"
/>
<KPICard
title="Net Revenue"
value={formatCurrency(profitLossData.net_revenue)}
icon={<TrendingUp className="w-6 h-6" />}
color="blue"
/>
<KPICard
title="Gross Profit"
value={formatCurrency(profitLossData.gross_profit)}
icon={<Award className="w-6 h-6" />}
color="purple"
/>
</div>
)}
{paymentMethodData && (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Payment Methods Distribution</h3>
<SimplePieChart
data={paymentMethodData.payment_methods.map((item) => ({
label: item.payment_method,
value: item.percentage,
}))}
size={250}
/>
</div>
)}
</div>
);
};
export default AdvancedAnalyticsPage;

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,14 @@ import {
ClipboardList,
X,
ChevronRight,
Star
Star,
RefreshCw,
Plus,
DollarSign,
CreditCard,
Building2,
Target,
Award
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Loading, EmptyState, ExportButton } from '../../components/common';
@@ -31,12 +38,37 @@ import { reportService, ReportData, reviewService, Review } from '../../services
import { auditService, AuditLog, AuditLogFilters } from '../../services/api/auditService';
import { formatDate } from '../../utils/format';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import analyticsService, {
ComprehensiveAnalyticsData,
RevPARData,
ADRData,
OccupancyRateData,
RevenueForecastData,
MarketPenetrationData,
StaffPerformanceData,
ServiceUsageData,
OperationalEfficiencyData,
GuestLTVData,
RepeatGuestRateData,
GuestSatisfactionTrendsData,
ProfitLossData,
PaymentMethodAnalyticsData,
RefundAnalysisData,
} from '../../services/api/analyticsService';
import { SimpleBarChart, SimpleLineChart, SimplePieChart, KPICard } from '../../components/analytics/SimpleChart';
import { exportData } from '../../utils/exportUtils';
import CustomReportBuilder from '../../components/analytics/CustomReportBuilder';
type AnalyticsTab = 'overview' | 'reports' | 'audit-logs' | 'reviews';
type AnalyticsTab = 'overview' | 'reports' | 'revenue' | 'operational' | 'guest' | 'financial' | 'audit-logs' | 'reviews';
const AnalyticsDashboardPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [activeTab, setActiveTab] = useState<AnalyticsTab>('overview');
const [showReportBuilder, setShowReportBuilder] = useState(false);
const [analyticsDateRange, setAnalyticsDateRange] = useState({
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
to: new Date().toISOString().split('T')[0],
});
const [dateRange, setDateRange] = useState({
@@ -71,6 +103,89 @@ const AnalyticsDashboardPage: React.FC = () => {
const [reviewsTotalItems, setReviewsTotalItems] = useState(0);
const reviewsPerPage = 5;
// Advanced Analytics Data
const { data: comprehensiveData, loading: comprehensiveLoading, execute: fetchComprehensive } = useAsync<ComprehensiveAnalyticsData>(
() => analyticsService.getComprehensiveAnalytics({
from: analyticsDateRange.from,
to: analyticsDateRange.to,
include_revenue: true,
include_operational: true,
include_guest: true,
include_financial: true,
}).then(r => r.data),
{ immediate: true }
);
const { data: revparData, loading: revparLoading, execute: fetchRevPAR } = useAsync<RevPARData>(
() => analyticsService.getRevPAR({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: adrData, loading: adrLoading, execute: fetchADR } = useAsync<ADRData>(
() => analyticsService.getADR({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: occupancyData, loading: occupancyLoading, execute: fetchOccupancy } = useAsync<OccupancyRateData>(
() => analyticsService.getOccupancyRate({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: forecastData, loading: forecastLoading, execute: fetchForecast } = useAsync<RevenueForecastData>(
() => analyticsService.getRevenueForecast(30).then(r => r.data),
{ immediate: false }
);
const { data: marketPenetrationData, loading: marketLoading, execute: fetchMarketPenetration } = useAsync<MarketPenetrationData>(
() => analyticsService.getMarketPenetration({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: staffPerformanceData, loading: staffLoading, execute: fetchStaffPerformance } = useAsync<StaffPerformanceData>(
() => analyticsService.getStaffPerformance({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: serviceUsageData, loading: serviceLoading, execute: fetchServiceUsage } = useAsync<ServiceUsageData>(
() => analyticsService.getServiceUsageAnalytics({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: efficiencyData, loading: efficiencyLoading, execute: fetchEfficiency } = useAsync<OperationalEfficiencyData>(
() => analyticsService.getOperationalEfficiency({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: ltvData, loading: ltvLoading, execute: fetchLTV } = useAsync<GuestLTVData>(
() => analyticsService.getGuestLifetimeValue({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: repeatRateData, loading: repeatLoading, execute: fetchRepeatRate } = useAsync<RepeatGuestRateData>(
() => analyticsService.getRepeatGuestRate({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: satisfactionData, loading: satisfactionLoading, execute: fetchSatisfaction } = useAsync<GuestSatisfactionTrendsData>(
() => analyticsService.getGuestSatisfactionTrends({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: profitLossData, loading: profitLossLoading, execute: fetchProfitLoss } = useAsync<ProfitLossData>(
() => analyticsService.getProfitLoss({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: paymentMethodData, loading: paymentMethodLoading, execute: fetchPaymentMethods } = useAsync<PaymentMethodAnalyticsData>(
() => analyticsService.getPaymentMethodAnalytics({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
{ immediate: false }
);
const { data: refundData, loading: refundLoading, execute: fetchRefunds } = useAsync<RefundAnalysisData>(
() => analyticsService.getRefundAnalysis({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
{ immediate: false }
);
const fetchReports = async (): Promise<ReportData> => {
const params: any = {};
if (dateRange.from) params.from = dateRange.from;
@@ -98,8 +213,18 @@ const AnalyticsDashboardPage: React.FC = () => {
fetchLogs();
} else if (activeTab === 'reviews') {
fetchReviews();
} else if (activeTab === 'overview') {
fetchComprehensive();
} else if (activeTab === 'revenue') {
Promise.all([fetchRevPAR(), fetchADR(), fetchOccupancy(), fetchForecast(), fetchMarketPenetration()]);
} else if (activeTab === 'operational') {
Promise.all([fetchStaffPerformance(), fetchServiceUsage(), fetchEfficiency()]);
} else if (activeTab === 'guest') {
Promise.all([fetchLTV(), fetchRepeatRate(), fetchSatisfaction()]);
} else if (activeTab === 'financial') {
Promise.all([fetchProfitLoss(), fetchPaymentMethods(), fetchRefunds()]);
}
}, [activeTab, auditFilters, currentPage, reviewsFilters, reviewsCurrentPage]);
}, [activeTab, auditFilters, currentPage, reviewsFilters, reviewsCurrentPage, analyticsDateRange]);
useEffect(() => {
setAuditFilters(prev => ({ ...prev, page: currentPage }));
@@ -132,8 +257,81 @@ const AnalyticsDashboardPage: React.FC = () => {
}
};
const handleExport = async () => {
const handleExport = async (format: 'csv' | 'xlsx' | 'pdf' | 'json' = 'csv') => {
try {
// Handle analytics tabs export
if (activeTab === 'overview' || activeTab === 'revenue' || activeTab === 'operational' || activeTab === 'guest' || activeTab === 'financial') {
let exportDataArray: any[] = [];
let filename = 'analytics';
let title = 'Analytics Report';
if (activeTab === 'overview' && comprehensiveData) {
const revenue = comprehensiveData.revenue;
if (revenue) {
exportDataArray = [
{ Metric: 'RevPAR', Value: revenue.revpar.revpar },
{ Metric: 'ADR', Value: revenue.adr.adr },
{ Metric: 'Occupancy Rate', Value: `${revenue.occupancy_rate.occupancy_rate}%` },
];
}
filename = 'comprehensive-analytics';
title = 'Comprehensive Analytics Report';
} else if (activeTab === 'revenue' && comprehensiveData?.revenue) {
const revenue = comprehensiveData.revenue;
exportDataArray = [
{ Metric: 'RevPAR', Value: revenue.revpar.revpar },
{ Metric: 'ADR', Value: revenue.adr.adr },
{ Metric: 'Occupancy Rate', Value: `${revenue.occupancy_rate.occupancy_rate}%` },
];
filename = 'revenue-analytics';
title = 'Revenue Analytics Report';
} else if (activeTab === 'operational' && comprehensiveData?.operational) {
const operational = comprehensiveData.operational;
exportDataArray = operational.staff_performance.staff_performance.map(staff => ({
'Staff Name': staff.staff_name,
'Email': staff.email,
'Check-ins Handled': staff.check_ins_handled,
'Performance Score': staff.performance_score,
}));
filename = 'operational-analytics';
title = 'Operational Analytics Report';
} else if (activeTab === 'guest' && comprehensiveData?.guest) {
const guest = comprehensiveData.guest;
exportDataArray = guest.lifetime_value.guests.slice(0, 50).map(g => ({
'Guest Name': g.name,
'Email': g.email,
'Total Bookings': g.total_bookings,
'Lifetime Value': g.lifetime_value,
'Average Booking Value': g.average_booking_value,
}));
filename = 'guest-analytics';
title = 'Guest Analytics Report';
} else if (activeTab === 'financial' && comprehensiveData?.financial) {
const financial = comprehensiveData.financial;
exportDataArray = financial.payment_methods.payment_methods.map(pm => ({
'Payment Method': pm.payment_method,
'Transaction Count': pm.transaction_count,
'Total Amount': pm.total_amount,
'Average Amount': pm.average_amount,
'Percentage': `${pm.percentage}%`,
}));
filename = 'financial-analytics';
title = 'Financial Analytics Report';
}
if (exportDataArray.length > 0) {
exportData({
filename,
title,
data: exportDataArray,
format,
});
toast.success(`Exported ${format.toUpperCase()} successfully`);
return;
}
}
// Handle reports tab export (existing functionality)
const params: any = {};
if (dateRange.from) params.from = dateRange.from;
if (dateRange.to) params.to = dateRange.to;
@@ -271,6 +469,10 @@ const AnalyticsDashboardPage: React.FC = () => {
const tabs = [
{ id: 'overview' as AnalyticsTab, label: 'Overview', icon: BarChart3 },
{ id: 'revenue' as AnalyticsTab, label: 'Revenue', icon: DollarSign },
{ id: 'operational' as AnalyticsTab, label: 'Operational', icon: Activity },
{ id: 'guest' as AnalyticsTab, label: 'Guest', icon: Users },
{ id: 'financial' as AnalyticsTab, label: 'Financial', icon: CreditCard },
{ id: 'reports' as AnalyticsTab, label: 'Reports', icon: FileText },
{ id: 'audit-logs' as AnalyticsTab, label: 'Audit Logs', icon: ClipboardList },
{ id: 'reviews' as AnalyticsTab, label: 'Reviews', icon: Star },
@@ -278,65 +480,117 @@ const AnalyticsDashboardPage: React.FC = () => {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-10 animate-fade-in">
<div className="max-w-7xl mx-auto px-2 sm:px-3 md:px-4 lg:px-6 xl:px-8 py-2 sm:py-4 md:py-6 lg:py-8 space-y-3 sm:space-y-4 md:space-y-6 lg:space-y-8 animate-fade-in">
{}
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-r from-blue-400/5 via-transparent to-indigo-600/5 rounded-3xl blur-3xl"></div>
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-blue-200/30 p-8 md:p-10">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-8">
<div className="flex items-start gap-5">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-blue-400 to-indigo-600 rounded-2xl blur-lg opacity-50"></div>
<div className="relative p-4 rounded-2xl bg-gradient-to-br from-blue-500 via-blue-500 to-indigo-600 shadow-xl border border-blue-400/50">
<BarChart3 className="w-8 h-8 text-white" />
<div className="absolute -top-1 -right-1 w-4 h-4 bg-gradient-to-br from-cyan-300 to-blue-500 rounded-full shadow-lg animate-pulse"></div>
<div className="relative bg-white/80 backdrop-blur-xl rounded-xl sm:rounded-2xl md:rounded-3xl shadow-2xl border border-blue-200/30 p-3 sm:p-4 md:p-6 lg:p-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 sm:gap-6 md:gap-8">
<div className="flex items-start gap-3 sm:gap-4 md:gap-5">
<div className="relative flex-shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-blue-400 to-indigo-600 rounded-xl sm:rounded-2xl blur-lg opacity-50"></div>
<div className="relative p-2.5 sm:p-3 md:p-4 rounded-xl sm:rounded-2xl bg-gradient-to-br from-blue-500 via-blue-500 to-indigo-600 shadow-xl border border-blue-400/50">
<BarChart3 className="w-5 h-5 sm:w-6 sm:h-6 md:w-8 md:h-8 text-white" />
<div className="absolute -top-1 -right-1 w-3 h-3 sm:w-4 sm:h-4 bg-gradient-to-br from-cyan-300 to-blue-500 rounded-full shadow-lg animate-pulse"></div>
</div>
</div>
<div className="space-y-3 flex-1">
<div className="flex items-center gap-3">
<h1 className="text-4xl md:text-5xl font-extrabold bg-gradient-to-r from-slate-900 via-blue-700 to-slate-900 bg-clip-text text-transparent">
<div className="space-y-2 sm:space-y-3 flex-1">
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
<h1 className="text-2xl sm:text-3xl md:text-3xl font-extrabold bg-gradient-to-r from-slate-900 via-blue-700 to-slate-900 bg-clip-text text-transparent">
Analytics Dashboard
</h1>
<Sparkles className="w-6 h-6 text-blue-500 animate-pulse" />
<Sparkles className="w-4 h-4 sm:w-5 sm:h-5 text-blue-500 animate-pulse" />
</div>
<p className="text-gray-600 text-base md:text-lg max-w-2xl leading-relaxed">
<p className="text-gray-600 text-xs sm:text-sm md:text-sm max-w-2xl leading-relaxed">
Comprehensive insights, reports, and system activity tracking
</p>
</div>
</div>
{(activeTab === 'overview' || activeTab === 'revenue' || activeTab === 'operational' || activeTab === 'guest' || activeTab === 'financial') && (
<div className="mt-4 sm:mt-6 flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3 flex-wrap">
<div className="flex flex-col xs:flex-row items-stretch xs:items-center gap-2 flex-1 sm:flex-initial">
<Calendar className="w-4 h-4 sm:w-5 sm:h-5 text-gray-500 hidden xs:block" />
<input
type="date"
value={analyticsDateRange.from}
onChange={(e) => setAnalyticsDateRange({ ...analyticsDateRange, from: e.target.value })}
className="flex-1 px-2 sm:px-3 py-1.5 sm:py-2 border border-gray-300 rounded-lg text-xs sm:text-sm"
/>
<span className="text-gray-500 text-xs sm:text-sm hidden xs:inline-flex items-center">to</span>
<input
type="date"
value={analyticsDateRange.to}
onChange={(e) => setAnalyticsDateRange({ ...analyticsDateRange, to: e.target.value })}
className="flex-1 px-2 sm:px-3 py-1.5 sm:py-2 border border-gray-300 rounded-lg text-xs sm:text-sm"
/>
</div>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => {
if (activeTab === 'overview') fetchComprehensive();
else if (activeTab === 'revenue') Promise.all([fetchRevPAR(), fetchADR(), fetchOccupancy(), fetchForecast(), fetchMarketPenetration()]);
else if (activeTab === 'operational') Promise.all([fetchStaffPerformance(), fetchServiceUsage(), fetchEfficiency()]);
else if (activeTab === 'guest') Promise.all([fetchLTV(), fetchRepeatRate(), fetchSatisfaction()]);
else if (activeTab === 'financial') Promise.all([fetchProfitLoss(), fetchPaymentMethods(), fetchRefunds()]);
}}
className="flex-1 sm:flex-none px-3 sm:px-4 py-1.5 sm:py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center justify-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
>
<RefreshCw className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span>Refresh</span>
</button>
<button
onClick={() => setShowReportBuilder(true)}
className="flex-1 sm:flex-none px-3 sm:px-4 py-1.5 sm:py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
>
<Plus className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">Custom Report</span>
<span className="sm:hidden">Report</span>
</button>
<button
onClick={() => handleExport('csv')}
className="flex-1 sm:flex-none px-3 sm:px-4 py-1.5 sm:py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center justify-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
>
<Download className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span>Export</span>
</button>
</div>
</div>
)}
</div>
{}
<div className="mt-10 pt-8 border-t border-gradient-to-r from-transparent via-blue-200/30 to-transparent">
<div className="flex flex-wrap gap-3">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
group relative flex items-center gap-3 px-6 py-3.5 rounded-xl font-semibold text-sm
transition-all duration-300 overflow-hidden
${
isActive
? 'bg-gradient-to-r from-blue-500 via-blue-500 to-indigo-600 text-white shadow-xl shadow-blue-500/40 scale-105'
: 'bg-white/80 text-gray-700 border border-gray-200/60 hover:border-blue-300/60 hover:bg-gradient-to-r hover:from-blue-50/50 hover:to-indigo-50/30 hover:shadow-lg hover:scale-102'
}
`}
>
{isActive && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
)}
<Icon className={`w-5 h-5 transition-transform duration-300 ${isActive ? 'text-white' : 'text-gray-600 group-hover:text-blue-600 group-hover:scale-110'}`} />
<span className="relative z-10">{tab.label}</span>
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-cyan-300 via-blue-400 to-indigo-400"></div>
)}
</button>
);
})}
<div className="mt-4 sm:mt-6 md:mt-8 lg:mt-10 pt-4 sm:pt-6 md:pt-8 border-t border-gradient-to-r from-transparent via-blue-200/30 to-transparent">
<div className="overflow-x-auto -mx-2 sm:-mx-3 px-2 sm:px-3 scrollbar-hide">
<div className="flex gap-2 sm:gap-3 min-w-max sm:min-w-0 sm:flex-wrap">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
group relative flex items-center gap-1.5 sm:gap-2 md:gap-3 px-3 sm:px-4 md:px-6 py-2 sm:py-2.5 md:py-3.5 rounded-lg sm:rounded-xl font-semibold text-xs sm:text-sm flex-shrink-0
transition-all duration-300 overflow-hidden
${
isActive
? 'bg-gradient-to-r from-blue-500 via-blue-500 to-indigo-600 text-white shadow-xl shadow-blue-500/40 scale-105'
: 'bg-white/80 text-gray-700 border border-gray-200/60 hover:border-blue-300/60 hover:bg-gradient-to-r hover:from-blue-50/50 hover:to-indigo-50/30 hover:shadow-lg hover:scale-102'
}
`}
>
{isActive && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
)}
<Icon className={`w-3.5 h-3.5 sm:w-4 sm:h-4 md:w-5 md:h-5 transition-transform duration-300 flex-shrink-0 ${isActive ? 'text-white' : 'text-gray-600 group-hover:text-blue-600 group-hover:scale-110'}`} />
<span className="relative z-10 whitespace-nowrap">{tab.label}</span>
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 sm:h-1 bg-gradient-to-r from-cyan-300 via-blue-400 to-indigo-400"></div>
)}
</button>
);
})}
</div>
</div>
</div>
</div>
@@ -344,108 +598,199 @@ const AnalyticsDashboardPage: React.FC = () => {
{}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
<div
onClick={() => setActiveTab('reports')}
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-blue-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-blue-300/60 overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-blue-400/10 to-transparent rounded-bl-full"></div>
<div className="relative space-y-5">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-blue-400 to-blue-600 rounded-xl blur-md opacity-50 group-hover:opacity-75 transition-opacity"></div>
<div className="relative p-3.5 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg border border-blue-400/50 group-hover:scale-110 transition-transform">
<FileText className="w-6 h-6 text-white" />
</div>
</div>
<div>
<h3 className="font-bold text-gray-900 text-xl mb-1">Reports & Analytics</h3>
<div className="h-1 w-12 bg-gradient-to-r from-blue-500 to-blue-600 rounded-full"></div>
</div>
</div>
<div className="space-y-6">
{comprehensiveLoading ? (
<Loading fullScreen text="Loading analytics..." />
) : comprehensiveData ? (
<>
{/* Key Metrics */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
{comprehensiveData.revenue && (
<>
<KPICard
title="RevPAR"
value={formatCurrency(comprehensiveData.revenue.revpar.revpar)}
icon={<TrendingUp className="w-6 h-6" />}
color="blue"
/>
<KPICard
title="ADR"
value={formatCurrency(comprehensiveData.revenue.adr.adr)}
icon={<DollarSign className="w-6 h-6" />}
color="green"
/>
<KPICard
title="Occupancy Rate"
value={`${comprehensiveData.revenue.occupancy_rate.occupancy_rate.toFixed(1)}%`}
icon={<Building2 className="w-6 h-6" />}
color="orange"
/>
<KPICard
title="Net Revenue"
value={comprehensiveData.financial ? formatCurrency(comprehensiveData.financial.profit_loss.net_revenue) : 'N/A'}
icon={<CreditCard className="w-6 h-6" />}
color="purple"
/>
</>
)}
</div>
<p className="text-gray-600 text-sm leading-relaxed">
View comprehensive reports, revenue analytics, and booking statistics
</p>
<div className="pt-5 border-t border-gray-100">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500 font-medium">View Reports</span>
<ChevronRight className="w-5 h-5 text-blue-600 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</div>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-blue-50/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
</div>
<div
onClick={() => setActiveTab('audit-logs')}
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-indigo-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-indigo-300/60 overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-indigo-400/10 to-transparent rounded-bl-full"></div>
<div className="relative space-y-5">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-indigo-400 to-indigo-600 rounded-xl blur-md opacity-50 group-hover:opacity-75 transition-opacity"></div>
<div className="relative p-3.5 rounded-xl bg-gradient-to-br from-indigo-500 to-indigo-600 shadow-lg border border-indigo-400/50 group-hover:scale-110 transition-transform">
<ClipboardList className="w-6 h-6 text-white" />
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 md:gap-6">
{comprehensiveData.revenue && comprehensiveData.revenue.market_penetration && (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Market Penetration by Room Type</h3>
<SimpleBarChart
data={comprehensiveData.revenue.market_penetration.penetration_by_room_type.map((item, index) => ({
label: item.room_type,
value: item.market_share,
}))}
height={200}
/>
</div>
<div>
<h3 className="font-bold text-gray-900 text-xl mb-1">Audit Logs</h3>
<div className="h-1 w-12 bg-gradient-to-r from-indigo-500 to-indigo-600 rounded-full"></div>
</div>
</div>
</div>
<p className="text-gray-600 text-sm leading-relaxed">
Track system activity, user actions, and security events
</p>
<div className="pt-5 border-t border-gray-100">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500 font-medium">View Logs</span>
<ChevronRight className="w-5 h-5 text-indigo-600 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</div>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-indigo-50/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
</div>
)}
<div
onClick={() => setActiveTab('reviews')}
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-amber-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-amber-300/60 overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-amber-400/10 to-transparent rounded-bl-full"></div>
<div className="relative space-y-5">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-amber-400 to-amber-600 rounded-xl blur-md opacity-50 group-hover:opacity-75 transition-opacity"></div>
<div className="relative p-3.5 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 shadow-lg border border-amber-400/50 group-hover:scale-110 transition-transform">
<Star className="w-6 h-6 text-white fill-white" />
{comprehensiveData.financial && comprehensiveData.financial.payment_methods && (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Payment Methods Distribution</h3>
<SimplePieChart
data={comprehensiveData.financial.payment_methods.payment_methods.map((item) => ({
label: item.payment_method,
value: item.percentage,
}))}
size={200}
/>
</div>
)}
</div>
{/* Quick Access Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
<div
onClick={() => setActiveTab('revenue')}
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-green-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-green-300/60 overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-green-400/10 to-transparent rounded-bl-full"></div>
<div className="relative space-y-5">
<div className="flex items-center gap-4">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-green-500 to-green-600 shadow-lg">
<DollarSign className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="font-bold text-gray-900 text-xl mb-1">Revenue Analytics</h3>
<div className="h-1 w-12 bg-gradient-to-r from-green-500 to-green-600 rounded-full"></div>
</div>
</div>
<p className="text-gray-600 text-sm leading-relaxed">
RevPAR, ADR, Occupancy, Forecast, Market Penetration
</p>
</div>
<div>
<h3 className="font-bold text-gray-900 text-xl mb-1">Reviews</h3>
<div className="h-1 w-12 bg-gradient-to-r from-amber-500 to-amber-600 rounded-full"></div>
</div>
<div
onClick={() => setActiveTab('operational')}
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-orange-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-orange-300/60 overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-orange-400/10 to-transparent rounded-bl-full"></div>
<div className="relative space-y-5">
<div className="flex items-center gap-4">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 shadow-lg">
<Activity className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="font-bold text-gray-900 text-xl mb-1">Operational Analytics</h3>
<div className="h-1 w-12 bg-gradient-to-r from-orange-500 to-orange-600 rounded-full"></div>
</div>
</div>
<p className="text-gray-600 text-sm leading-relaxed">
Staff Performance, Service Usage, Efficiency Metrics
</p>
</div>
</div>
<div
onClick={() => setActiveTab('guest')}
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-purple-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-purple-300/60 overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-purple-400/10 to-transparent rounded-bl-full"></div>
<div className="relative space-y-5">
<div className="flex items-center gap-4">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 shadow-lg">
<Users className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="font-bold text-gray-900 text-xl mb-1">Guest Analytics</h3>
<div className="h-1 w-12 bg-gradient-to-r from-purple-500 to-purple-600 rounded-full"></div>
</div>
</div>
<p className="text-gray-600 text-sm leading-relaxed">
LTV, CAC, Repeat Rate, Satisfaction Trends
</p>
</div>
</div>
</div>
<p className="text-gray-600 text-sm leading-relaxed">
Manage customer reviews and ratings
</p>
<div className="pt-5 border-t border-gray-100">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500 font-medium">View Reviews</span>
<ChevronRight className="w-5 h-5 text-amber-600 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</>
) : (
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<EmptyState
title="No Analytics Data"
description="Click refresh to load analytics data"
action={{
label: 'Refresh',
onClick: fetchComprehensive
}}
/>
</div>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-amber-50/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
</div>
)}
</div>
)}
{/* Revenue Analytics Tab */}
{activeTab === 'revenue' && (
<RevenueAnalyticsView
revparData={revparData}
adrData={adrData}
occupancyData={occupancyData}
forecastData={forecastData}
marketPenetrationData={marketPenetrationData}
formatCurrency={formatCurrency}
loading={revparLoading || adrLoading || occupancyLoading || forecastLoading || marketLoading}
/>
)}
{/* Operational Analytics Tab */}
{activeTab === 'operational' && (
<OperationalAnalyticsView
staffPerformanceData={staffPerformanceData}
serviceUsageData={serviceUsageData}
efficiencyData={efficiencyData}
formatCurrency={formatCurrency}
loading={staffLoading || serviceLoading || efficiencyLoading}
/>
)}
{/* Guest Analytics Tab */}
{activeTab === 'guest' && (
<GuestAnalyticsView
ltvData={ltvData}
repeatRateData={repeatRateData}
satisfactionData={satisfactionData}
formatCurrency={formatCurrency}
loading={ltvLoading || repeatLoading || satisfactionLoading}
/>
)}
{/* Financial Analytics Tab */}
{activeTab === 'financial' && (
<FinancialAnalyticsView
profitLossData={profitLossData}
paymentMethodData={paymentMethodData}
refundData={refundData}
formatCurrency={formatCurrency}
loading={profitLossLoading || paymentMethodLoading || refundLoading}
/>
)}
{}
{activeTab === 'reports' && (
<div className="space-y-8">
@@ -1199,10 +1544,265 @@ const AnalyticsDashboardPage: React.FC = () => {
</div>
</div>
)}
{/* Custom Report Builder Modal */}
{showReportBuilder && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<CustomReportBuilder onClose={() => setShowReportBuilder(false)} />
</div>
</div>
)}
</div>
</div>
);
};
// Revenue Analytics View Component
const RevenueAnalyticsView: React.FC<{
revparData?: RevPARData;
adrData?: ADRData;
occupancyData?: OccupancyRateData;
forecastData?: RevenueForecastData;
marketPenetrationData?: MarketPenetrationData;
formatCurrency: (amount: number) => string;
loading: boolean;
}> = ({ revparData, adrData, occupancyData, forecastData, marketPenetrationData, formatCurrency, loading }) => {
if (loading) {
return <Loading fullScreen text="Loading revenue analytics..." />;
}
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{revparData && (
<KPICard
title="RevPAR"
value={formatCurrency(revparData.revpar)}
subtitle={`${revparData.period_days} days`}
icon={<TrendingUp className="w-6 h-6" />}
color="blue"
/>
)}
{adrData && (
<KPICard
title="ADR"
value={formatCurrency(adrData.adr)}
icon={<DollarSign className="w-6 h-6" />}
color="green"
/>
)}
{occupancyData && (
<KPICard
title="Occupancy Rate"
value={`${occupancyData.occupancy_rate.toFixed(1)}%`}
subtitle={`${occupancyData.occupied_room_nights} / ${occupancyData.available_room_nights} nights`}
icon={<Building2 className="w-6 h-6" />}
color="orange"
/>
)}
</div>
{forecastData && (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Revenue Forecast (Next 30 Days)</h3>
<SimpleLineChart
data={forecastData.forecast.slice(0, 30).map(item => ({
label: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
value: item.forecasted_revenue,
}))}
height={200}
/>
</div>
)}
{marketPenetrationData && (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Market Penetration by Room Type</h3>
<SimpleBarChart
data={marketPenetrationData.penetration_by_room_type.map((item) => ({
label: item.room_type,
value: item.market_share,
}))}
height={200}
/>
</div>
)}
</div>
);
};
// Operational Analytics View Component
const OperationalAnalyticsView: React.FC<{
staffPerformanceData?: StaffPerformanceData;
serviceUsageData?: ServiceUsageData;
efficiencyData?: OperationalEfficiencyData;
formatCurrency: (amount: number) => string;
loading: boolean;
}> = ({ staffPerformanceData, serviceUsageData, efficiencyData, formatCurrency, loading }) => {
if (loading) {
return <Loading fullScreen text="Loading operational analytics..." />;
}
return (
<div className="space-y-6">
{efficiencyData && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<KPICard
title="Conversion Rate"
value={`${efficiencyData.conversion_rate.toFixed(1)}%`}
icon={<Target className="w-6 h-6" />}
color="blue"
/>
<KPICard
title="Avg Booking Value"
value={formatCurrency(efficiencyData.average_booking_value)}
icon={<DollarSign className="w-6 h-6" />}
color="green"
/>
<KPICard
title="Cancellation Rate"
value={`${efficiencyData.cancellation_rate.toFixed(1)}%`}
icon={<Activity className="w-6 h-6" />}
color="red"
/>
</div>
)}
{serviceUsageData && (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top Services by Revenue</h3>
<SimpleBarChart
data={serviceUsageData.services.slice(0, 10).map(item => ({
label: item.service_name,
value: item.total_revenue,
}))}
height={250}
/>
</div>
)}
</div>
);
};
// Guest Analytics View Component
const GuestAnalyticsView: React.FC<{
ltvData?: GuestLTVData;
repeatRateData?: RepeatGuestRateData;
satisfactionData?: GuestSatisfactionTrendsData;
formatCurrency: (amount: number) => string;
loading: boolean;
}> = ({ ltvData, repeatRateData, satisfactionData, formatCurrency, loading }) => {
if (loading) {
return <Loading fullScreen text="Loading guest analytics..." />;
}
return (
<div className="space-y-6">
{repeatRateData && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<KPICard
title="Repeat Guest Rate"
value={`${repeatRateData.repeat_guest_rate.toFixed(1)}%`}
subtitle={`${repeatRateData.repeat_guests} repeat / ${repeatRateData.total_guests} total`}
icon={<Users className="w-6 h-6" />}
color="purple"
/>
{ltvData && (
<KPICard
title="Average LTV"
value={formatCurrency(ltvData.average_ltv)}
subtitle={`${ltvData.total_guests_analyzed} guests`}
icon={<Award className="w-6 h-6" />}
color="blue"
/>
)}
{satisfactionData && (
<KPICard
title="Avg Satisfaction"
value={`${satisfactionData.overall_average_rating.toFixed(1)}/5`}
subtitle={`${satisfactionData.total_reviews} reviews`}
icon={<Star className="w-6 h-6" />}
color="orange"
/>
)}
</div>
)}
{satisfactionData && (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Satisfaction Trends</h3>
<SimpleLineChart
data={satisfactionData.trends.map(item => ({
label: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
value: item.average_rating,
}))}
height={200}
/>
</div>
)}
</div>
);
};
// Financial Analytics View Component
const FinancialAnalyticsView: React.FC<{
profitLossData?: ProfitLossData;
paymentMethodData?: PaymentMethodAnalyticsData;
refundData?: RefundAnalysisData;
formatCurrency: (amount: number) => string;
loading: boolean;
}> = ({ profitLossData, paymentMethodData, refundData, formatCurrency, loading }) => {
if (loading) {
return <Loading fullScreen text="Loading financial analytics..." />;
}
return (
<div className="space-y-6">
{profitLossData && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<KPICard
title="Total Revenue"
value={formatCurrency(profitLossData.total_revenue)}
icon={<DollarSign className="w-6 h-6" />}
color="green"
/>
<KPICard
title="Refunds"
value={formatCurrency(profitLossData.refunds)}
icon={<Download className="w-6 h-6" />}
color="red"
/>
<KPICard
title="Net Revenue"
value={formatCurrency(profitLossData.net_revenue)}
icon={<TrendingUp className="w-6 h-6" />}
color="blue"
/>
<KPICard
title="Gross Profit"
value={formatCurrency(profitLossData.gross_profit)}
icon={<Award className="w-6 h-6" />}
color="purple"
/>
</div>
)}
{paymentMethodData && (
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Payment Methods Distribution</h3>
<SimplePieChart
data={paymentMethodData.payment_methods.map((item) => ({
label: item.payment_method,
value: item.percentage,
}))}
size={250}
/>
</div>
)}
</div>
);
};
export default AnalyticsDashboardPage;

View File

@@ -7,7 +7,7 @@ import Pagination from '../../components/common/Pagination';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
import { useNavigate } from 'react-router-dom';
import CreateBookingModal from '../../components/admin/CreateBookingModal';
import CreateBookingModal from '../../components/shared/CreateBookingModal';
const BookingManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -157,31 +157,31 @@ const BookingManagementPage: React.FC = () => {
}
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">
<div className="space-y-4 sm:space-y-6 md:space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-3 sm:-m-4 md:-m-6 p-4 sm:p-6 md:p-8">
{/* Header with Create Button */}
<div className="animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4 mb-4 sm:mb-6">
<div className="flex-1">
<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-3xl sm:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
<div className="flex items-center gap-2 sm:gap-3 mb-2">
<div className="h-1 w-12 sm:w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-xl sm:text-2xl md:text-2xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Booking Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-base sm:text-lg font-light">Manage and track all hotel bookings with precision</p>
<p className="text-slate-600 mt-2 sm:mt-3 text-xs sm:text-sm md:text-sm font-light">Manage and track all hotel bookings with precision</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center justify-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 whitespace-nowrap w-full sm:w-auto"
className="flex items-center justify-center gap-2 px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg sm:rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl whitespace-nowrap w-full sm:w-auto text-xs sm:text-sm"
>
<Plus className="w-5 h-5" />
<Plus className="w-4 h-4 sm:w-5 sm:h-5" />
Create Booking
</button>
</div>
</div>
{}
<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.1s' }}>
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
@@ -209,7 +209,7 @@ const BookingManagementPage: React.FC = () => {
</div>
{}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
@@ -368,7 +368,7 @@ const BookingManagementPage: React.FC = () => {
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold text-amber-100 mb-1">Booking Details</h2>
<h2 className="text-xl sm:text-2xl md:text-2xl font-bold text-amber-100 mb-1">Booking Details</h2>
<p className="text-amber-200/80 text-sm font-light">Comprehensive booking information</p>
</div>
<button
@@ -387,7 +387,7 @@ const BookingManagementPage: React.FC = () => {
<div className="grid grid-cols-2 gap-6">
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Booking Number</label>
<p className="text-xl font-bold text-slate-900 font-mono">{selectedBooking.booking_number}</p>
<p className="text-base sm:text-lg md:text-lg font-bold text-slate-900 font-mono">{selectedBooking.booking_number}</p>
</div>
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Status</label>
@@ -562,7 +562,7 @@ const BookingManagementPage: React.FC = () => {
{}
<div className="bg-gradient-to-br from-green-50 via-emerald-50 to-green-50 p-6 rounded-xl border-2 border-green-200 shadow-lg mb-4">
<label className="text-xs font-semibold text-green-700 uppercase tracking-wider mb-2 block">Amount Paid</label>
<p className="text-3xl font-bold bg-gradient-to-r from-green-600 via-emerald-700 to-green-600 bg-clip-text text-transparent">
<p className="text-xl sm:text-2xl md:text-2xl font-bold bg-gradient-to-r from-green-600 via-emerald-700 to-green-600 bg-clip-text text-transparent">
{formatCurrency(amountPaid)}
</p>
{hasPayments && completedPayments.length > 0 && (
@@ -584,7 +584,7 @@ const BookingManagementPage: React.FC = () => {
{remainingDue > 0 && (
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg mb-4">
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Remaining Due (To be paid)</label>
<p className="text-3xl font-bold text-amber-600">
<p className="text-xl sm:text-2xl md:text-2xl font-bold text-amber-600">
{formatCurrency(remainingDue)}
</p>
{selectedBooking.total_price > 0 && (
@@ -598,7 +598,7 @@ const BookingManagementPage: React.FC = () => {
{}
<div className="bg-gradient-to-br from-slate-50 to-gray-50 p-6 rounded-xl border-2 border-slate-200 shadow-lg">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2 block">Total Booking Price</label>
<p className="text-2xl font-bold text-slate-700">
<p className="text-lg sm:text-xl md:text-xl font-bold text-slate-700">
{formatCurrency(selectedBooking.total_price)}
</p>
<p className="text-xs text-slate-500 mt-2">

View File

@@ -129,6 +129,9 @@ const BusinessDashboardPage: React.FC = () => {
if (response.status === 'success' && response.data) {
let invoiceList = response.data.invoices || [];
// Client-side filtering for search (only on current page results)
// Note: This is a limitation - search only works on current page
// For full search functionality, backend needs to support search parameter
if (invoiceFilters.search) {
invoiceList = invoiceList.filter((inv) =>
inv.invoice_number.toLowerCase().includes(invoiceFilters.search.toLowerCase()) ||
@@ -138,8 +141,15 @@ const BusinessDashboardPage: React.FC = () => {
}
setInvoices(invoiceList);
setInvoicesTotalPages(response.data.total_pages || 1);
setInvoicesTotalItems(response.data.total || 0);
// Only update pagination if not searching (to avoid incorrect counts)
if (!invoiceFilters.search) {
setInvoicesTotalPages(response.data.total_pages || 1);
setInvoicesTotalItems(response.data.total || 0);
} else {
// When searching, keep original pagination but show filtered count
setInvoicesTotalPages(response.data.total_pages || 1);
setInvoicesTotalItems(response.data.total || 0);
}
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load invoices');
@@ -354,28 +364,28 @@ const BusinessDashboardPage: React.FC = () => {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-10 animate-fade-in">
<div className="max-w-7xl mx-auto px-2 sm:px-3 md:px-4 lg:px-6 xl:px-8 py-2 sm:py-4 md:py-6 lg:py-8 space-y-3 sm:space-y-4 md:space-y-6 lg:space-y-8 animate-fade-in">
{}
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-r from-emerald-400/5 via-transparent to-purple-600/5 rounded-3xl blur-3xl"></div>
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-emerald-200/30 p-8 md:p-10">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-8">
<div className="flex items-start gap-5">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-emerald-400 to-purple-600 rounded-2xl blur-lg opacity-50"></div>
<div className="relative p-4 rounded-2xl bg-gradient-to-br from-emerald-500 via-emerald-500 to-purple-600 shadow-xl border border-emerald-400/50">
<FileText className="w-8 h-8 text-white" />
<div className="absolute -top-1 -right-1 w-4 h-4 bg-gradient-to-br from-green-300 to-emerald-500 rounded-full shadow-lg animate-pulse"></div>
<div className="relative bg-white/80 backdrop-blur-xl rounded-xl sm:rounded-2xl md:rounded-3xl shadow-2xl border border-emerald-200/30 p-3 sm:p-4 md:p-6 lg:p-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 sm:gap-6 md:gap-8">
<div className="flex items-start gap-3 sm:gap-4 md:gap-5">
<div className="relative flex-shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-emerald-400 to-purple-600 rounded-xl sm:rounded-2xl blur-lg opacity-50"></div>
<div className="relative p-2.5 sm:p-3 md:p-4 rounded-xl sm:rounded-2xl bg-gradient-to-br from-emerald-500 via-emerald-500 to-purple-600 shadow-xl border border-emerald-400/50">
<FileText className="w-5 h-5 sm:w-6 sm:h-6 md:w-8 md:h-8 text-white" />
<div className="absolute -top-1 -right-1 w-3 h-3 sm:w-4 sm:h-4 bg-gradient-to-br from-green-300 to-emerald-500 rounded-full shadow-lg animate-pulse"></div>
</div>
</div>
<div className="space-y-3 flex-1">
<div className="flex items-center gap-3">
<h1 className="text-4xl md:text-5xl font-extrabold bg-gradient-to-r from-slate-900 via-emerald-700 to-slate-900 bg-clip-text text-transparent">
<div className="space-y-2 sm:space-y-3 flex-1">
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
<h1 className="text-2xl sm:text-3xl md:text-3xl font-extrabold bg-gradient-to-r from-slate-900 via-emerald-700 to-slate-900 bg-clip-text text-transparent">
Business Dashboard
</h1>
<Sparkles className="w-6 h-6 text-emerald-500 animate-pulse" />
<Sparkles className="w-4 h-4 sm:w-5 sm:h-5 text-emerald-500 animate-pulse" />
</div>
<p className="text-gray-600 text-base md:text-lg max-w-2xl leading-relaxed">
<p className="text-gray-600 text-xs sm:text-sm md:text-sm max-w-2xl leading-relaxed">
Manage invoices, payments, and promotional campaigns
</p>
</div>
@@ -383,36 +393,38 @@ const BusinessDashboardPage: React.FC = () => {
</div>
{}
<div className="mt-10 pt-8 border-t border-gradient-to-r from-transparent via-emerald-200/30 to-transparent">
<div className="flex flex-wrap gap-3">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
group relative flex items-center gap-3 px-6 py-3.5 rounded-xl font-semibold text-sm
transition-all duration-300 overflow-hidden
${
isActive
? 'bg-gradient-to-r from-emerald-500 via-emerald-500 to-purple-600 text-white shadow-xl shadow-emerald-500/40 scale-105'
: 'bg-white/80 text-gray-700 border border-gray-200/60 hover:border-emerald-300/60 hover:bg-gradient-to-r hover:from-emerald-50/50 hover:to-purple-50/30 hover:shadow-lg hover:scale-102'
}
`}
>
{isActive && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
)}
<Icon className={`w-5 h-5 transition-transform duration-300 ${isActive ? 'text-white' : 'text-gray-600 group-hover:text-emerald-600 group-hover:scale-110'}`} />
<span className="relative z-10">{tab.label}</span>
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-300 via-emerald-400 to-purple-400"></div>
)}
</button>
);
})}
<div className="mt-4 sm:mt-6 md:mt-8 lg:mt-10 pt-4 sm:pt-6 md:pt-8 border-t border-gradient-to-r from-transparent via-emerald-200/30 to-transparent">
<div className="overflow-x-auto -mx-2 sm:-mx-3 px-2 sm:px-3 scrollbar-hide">
<div className="flex gap-2 sm:gap-3 min-w-max sm:min-w-0 sm:flex-wrap">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
group relative flex items-center gap-1.5 sm:gap-2 md:gap-3 px-3 sm:px-4 md:px-6 py-2 sm:py-2.5 md:py-3.5 rounded-lg sm:rounded-xl font-semibold text-xs sm:text-sm flex-shrink-0
transition-all duration-300 overflow-hidden
${
isActive
? 'bg-gradient-to-r from-emerald-500 via-emerald-500 to-purple-600 text-white shadow-xl shadow-emerald-500/40 scale-105'
: 'bg-white/80 text-gray-700 border border-gray-200/60 hover:border-emerald-300/60 hover:bg-gradient-to-r hover:from-emerald-50/50 hover:to-purple-50/30 hover:shadow-lg hover:scale-102'
}
`}
>
{isActive && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
)}
<Icon className={`w-3.5 h-3.5 sm:w-4 sm:h-4 md:w-5 md:h-5 transition-transform duration-300 flex-shrink-0 ${isActive ? 'text-white' : 'text-gray-600 group-hover:text-emerald-600 group-hover:scale-110'}`} />
<span className="relative z-10 whitespace-nowrap">{tab.label}</span>
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 sm:h-1 bg-gradient-to-r from-green-300 via-emerald-400 to-purple-400"></div>
)}
</button>
);
})}
</div>
</div>
</div>
</div>
@@ -420,7 +432,7 @@ const BusinessDashboardPage: React.FC = () => {
{}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5 md:gap-6 lg:gap-8">
<div
onClick={() => setActiveTab('invoices')}
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-blue-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-blue-300/60 overflow-hidden"

View File

@@ -134,51 +134,51 @@ const DashboardPage: React.FC = () => {
}
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">
<div className="space-y-3 sm:space-y-4 md:space-y-6 lg:space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen max-w-full overflow-x-hidden">
{}
<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-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">
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 sm:gap-6 animate-fade-in">
<div className="w-full lg:w-auto">
<div className="flex items-center gap-2 sm:gap-3 mb-2">
<div className="h-1 w-12 sm:w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-xl sm:text-2xl md:text-2xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Dashboard
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Hotel operations overview and analytics</p>
<p className="text-slate-600 mt-2 sm:mt-3 text-xs sm:text-sm md:text-sm font-light">Hotel operations overview and analytics</p>
</div>
{}
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div className="flex gap-3 items-center">
<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="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
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-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
/>
<span className="text-slate-500 font-medium">to</span>
<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="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
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-amber-400 focus:ring-4 focus:ring-amber-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-3 items-center">
<div className="flex gap-2 sm:gap-3 items-center w-full sm:w-auto">
<button
onClick={handleRefresh}
disabled={loading}
className="px-6 py-2.5 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 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-sm"
className="flex-1 sm:flex-none px-4 sm:px-6 py-2 sm:py-2.5 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 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-xs sm:text-sm"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<RefreshCw className={`w-3 h-3 sm:w-4 sm:h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
<button
onClick={handleLogout}
className="px-6 py-2.5 bg-gradient-to-r from-rose-500 to-rose-600 text-white rounded-xl font-semibold hover:from-rose-600 hover:to-rose-700 transition-all duration-200 shadow-lg hover:shadow-xl flex items-center gap-2 text-sm"
className="flex-1 sm:flex-none px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-rose-500 to-rose-600 text-white rounded-xl font-semibold hover:from-rose-600 hover:to-rose-700 transition-all duration-200 shadow-lg hover:shadow-xl flex items-center justify-center gap-2 text-xs sm:text-sm"
title="Logout"
>
<LogOut className="w-4 h-4" />
<LogOut className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">Logout</span>
</button>
</div>
@@ -186,82 +186,82 @@ const DashboardPage: React.FC = () => {
</div>
{}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-5 md:gap-6">
{}
<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" style={{ animationDelay: '0.1s' }}>
<div className="bg-white/80 backdrop-blur-sm 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 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 Revenue</p>
<p className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
<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 font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent truncate">
{formatCurrency(stats?.total_revenue || 0)}
</p>
</div>
<div className="bg-gradient-to-br from-emerald-100 to-emerald-200 p-4 rounded-2xl shadow-lg">
<CurrencyIcon className="text-emerald-600" size={28} />
<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">
<CurrencyIcon className="text-emerald-600" size={20} />
</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 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>
{}
<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.2s' }}>
<div className="bg-white/80 backdrop-blur-sm 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 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 Bookings</p>
<p className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-blue-700 bg-clip-text text-transparent">
<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 Bookings</p>
<p className="text-lg sm:text-xl md:text-2xl font-bold bg-gradient-to-r from-blue-600 to-blue-700 bg-clip-text text-transparent">
{stats?.total_bookings || 0}
</p>
</div>
<div className="bg-gradient-to-br from-blue-100 to-blue-200 p-4 rounded-2xl shadow-lg">
<Calendar className="w-7 h-7 text-blue-600" />
<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">
<Calendar 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-5 pt-4 border-t border-slate-100">
<span className="text-slate-500 text-sm">
<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">
{stats.total_bookings > 0 ? 'Total bookings recorded' : 'No bookings yet'}
</span>
</div>
</div>
{}
<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.3s' }}>
<div className="bg-white/80 backdrop-blur-sm 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 transition-all duration-300" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Available Rooms</p>
<p className="text-3xl font-bold bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent">
<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">Available Rooms</p>
<p className="text-lg sm:text-xl md:text-2xl font-bold bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent">
{stats?.available_rooms || 0}
</p>
</div>
<div className="bg-gradient-to-br from-purple-100 to-purple-200 p-4 rounded-2xl shadow-lg">
<Hotel className="w-7 h-7 text-purple-600" />
<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">
<Hotel 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-5 pt-4 border-t border-slate-100">
<span className="text-slate-500 text-sm">
<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">
{stats?.occupied_rooms || 0} rooms in use
</span>
</div>
</div>
{}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-amber-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.4s' }}>
<div className="bg-white/80 backdrop-blur-sm 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-amber-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.4s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Customers</p>
<p className="text-3xl font-bold bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">
<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">Customers</p>
<p className="text-lg sm:text-xl md:text-2xl font-bold bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">
{stats?.total_customers || 0}
</p>
</div>
<div className="bg-gradient-to-br from-amber-100 to-amber-200 p-4 rounded-2xl shadow-lg">
<Users className="w-7 h-7 text-amber-600" />
<div className="bg-gradient-to-br from-amber-100 to-amber-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
<Users className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-amber-600" />
</div>
</div>
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<span className="text-slate-500 text-sm">
<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">
Unique customers with bookings
</span>
</div>
@@ -269,35 +269,35 @@ const DashboardPage: React.FC = () => {
</div>
{}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 md:gap-6">
{}
<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">Daily Revenue</h2>
<div className="p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-xl shadow-md">
<BarChart3 className="w-5 h-5 text-blue-600" />
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in">
<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-sm sm:text-base md:text-base font-bold text-slate-900">Daily Revenue</h2>
<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">
<BarChart3 className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
</div>
</div>
{stats?.revenue_by_date && stats.revenue_by_date.length > 0 ? (
<div className="space-y-3">
<div className="space-y-2 sm:space-y-3">
{stats.revenue_by_date.slice(0, 7).map((item, index) => {
const maxRevenue = Math.max(...stats.revenue_by_date!.map(r => r.revenue));
return (
<div key={index} className="flex items-center py-2">
<span className="text-sm text-slate-600 w-24 font-medium">
<div key={index} className="flex items-center py-1.5 sm:py-2">
<span className="text-xs sm:text-sm text-slate-600 w-16 sm:w-20 md:w-24 font-medium flex-shrink-0">
{formatDate(item.date, 'short')}
</span>
<div className="flex-1 mx-4">
<div className="bg-slate-200 rounded-full h-5 overflow-hidden shadow-inner">
<div className="flex-1 mx-2 sm:mx-3 md:mx-4 min-w-0">
<div className="bg-slate-200 rounded-full h-4 sm:h-5 overflow-hidden shadow-inner">
<div
className="bg-gradient-to-r from-emerald-500 to-emerald-600 h-5 rounded-full transition-all shadow-md"
className="bg-gradient-to-r from-emerald-500 to-emerald-600 h-4 sm:h-5 rounded-full transition-all shadow-md"
style={{
width: `${Math.min((item.revenue / (maxRevenue || 1)) * 100, 100)}%`,
}}
/>
</div>
</div>
<span className="text-sm font-bold text-slate-900 w-32 text-right bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
<span className="text-xs sm:text-sm font-bold text-slate-900 w-20 sm:w-28 md:w-32 text-right bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent flex-shrink-0">
{formatCurrency(item.revenue)}
</span>
</div>
@@ -313,12 +313,12 @@ const DashboardPage: React.FC = () => {
</div>
{}
<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">Booking Status</h2>
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" 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-sm sm:text-base md:text-base font-bold text-slate-900">Booking Status</h2>
</div>
{stats?.bookings_by_status && Object.keys(stats.bookings_by_status).length > 0 ? (
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
{Object.entries(stats.bookings_by_status)
.filter(([_, count]) => count > 0)
.map(([status, count]) => {
@@ -337,12 +337,12 @@ const DashboardPage: React.FC = () => {
cancelled: '❌ Canceled',
};
return (
<div key={status} className="flex items-center justify-between p-3 bg-gradient-to-r from-slate-50 to-white rounded-xl hover:shadow-md transition-all duration-200 border border-slate-100">
<div className="flex items-center gap-3">
<div className={`w-4 h-4 rounded-full shadow-md ${statusColors[status] || 'bg-slate-500'}`} />
<span className="text-slate-700 font-medium">{statusLabels[status] || status}</span>
<div key={status} className="flex items-center justify-between p-2.5 sm:p-3 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl hover:shadow-md transition-all duration-200 border border-slate-100">
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
<div className={`w-3 h-3 sm:w-4 sm:h-4 rounded-full shadow-md flex-shrink-0 ${statusColors[status] || 'bg-slate-500'}`} />
<span className="text-xs sm:text-sm md:text-base text-slate-700 font-medium truncate">{statusLabels[status] || status}</span>
</div>
<span className="font-bold text-slate-900 text-lg">{count}</span>
<span className="font-bold text-slate-900 text-sm sm:text-base flex-shrink-0 ml-2">{count}</span>
</div>
);
})}
@@ -357,29 +357,29 @@ const DashboardPage: React.FC = () => {
</div>
{}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5 md:gap-6">
{}
<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">Top Booked Rooms</h2>
<div className="p-2 bg-gradient-to-br from-amber-100 to-amber-200 rounded-xl">
<Hotel className="w-5 h-5 text-amber-600" />
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in">
<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-sm sm:text-base md:text-base font-bold text-slate-900">Top Booked Rooms</h2>
<div className="p-2 bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg sm:rounded-xl flex-shrink-0">
<Hotel className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600" />
</div>
</div>
{stats?.top_rooms && stats.top_rooms.length > 0 ? (
<div className="space-y-3">
<div className="space-y-2 sm:space-y-3">
{stats.top_rooms.map((room, index) => (
<div key={room.room_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-slate-50 to-white rounded-xl hover:from-amber-50 hover:to-yellow-50 transition-all duration-300 border border-slate-200 hover:border-amber-300 hover:shadow-lg">
<div className="flex items-center gap-4">
<span className="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-amber-500 to-amber-600 text-white rounded-xl font-bold shadow-lg shadow-amber-500/40 text-lg">
<div key={room.room_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-amber-50 hover:to-yellow-50 transition-all duration-300 border border-slate-200 hover:border-amber-300 hover:shadow-lg">
<div className="flex items-center gap-2 sm:gap-3 md:gap-4 min-w-0 flex-1">
<span className="flex items-center justify-center w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-amber-500 to-amber-600 text-white rounded-lg sm:rounded-xl font-bold shadow-lg shadow-amber-500/40 text-base sm:text-lg flex-shrink-0">
{index + 1}
</span>
<div>
<p className="font-semibold text-slate-900">Room {room.room_number}</p>
<p className="text-sm text-slate-500">{room.bookings} booking{room.bookings !== 1 ? 's' : ''}</p>
<div className="min-w-0 flex-1">
<p className="font-semibold text-slate-900 text-sm sm:text-base truncate">Room {room.room_number}</p>
<p className="text-xs sm:text-sm text-slate-500">{room.bookings} booking{room.bookings !== 1 ? 's' : ''}</p>
</div>
</div>
<span className="font-bold text-emerald-600 bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
<span className="font-bold text-emerald-600 bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent text-sm sm:text-base flex-shrink-0 ml-2">
{formatCurrency(room.revenue)}
</span>
</div>
@@ -394,22 +394,22 @@ const DashboardPage: React.FC = () => {
</div>
{}
<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">Services Used</h2>
<div className="p-2 bg-gradient-to-br from-purple-100 to-purple-200 rounded-xl">
<BarChart3 className="w-5 h-5 text-purple-600" />
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" 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-sm sm:text-base md:text-base font-bold text-slate-900">Services Used</h2>
<div className="p-2 bg-gradient-to-br from-purple-100 to-purple-200 rounded-lg sm:rounded-xl flex-shrink-0">
<BarChart3 className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
</div>
</div>
{stats?.service_usage && stats.service_usage.length > 0 ? (
<div className="space-y-3">
<div className="space-y-2 sm:space-y-3">
{stats.service_usage.map((service) => (
<div key={service.service_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 transition-all duration-300 border border-slate-200 hover:border-purple-300 hover:shadow-lg">
<div>
<p className="font-semibold text-slate-900">{service.service_name}</p>
<p className="text-sm text-slate-500">{service.usage_count} time{service.usage_count !== 1 ? 's' : ''} used</p>
<div key={service.service_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 transition-all duration-300 border border-slate-200 hover:border-purple-300 hover:shadow-lg">
<div className="min-w-0 flex-1 pr-2">
<p className="font-semibold text-slate-900 text-sm sm:text-base truncate">{service.service_name}</p>
<p className="text-xs sm:text-sm text-slate-500">{service.usage_count} time{service.usage_count !== 1 ? 's' : ''} used</p>
</div>
<span className="font-bold text-purple-600 bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent">
<span className="font-bold text-purple-600 bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent text-sm sm:text-base flex-shrink-0">
{formatCurrency(service.total_revenue)}
</span>
</div>
@@ -424,12 +424,12 @@ const DashboardPage: React.FC = () => {
</div>
{}
<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.3s' }}>
<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">Recent Payments</h2>
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" style={{ animationDelay: '0.3s' }}>
<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-sm sm:text-base md:text-base font-bold text-slate-900">Recent Payments</h2>
<button
onClick={() => navigate('/admin/payments')}
className="text-sm text-amber-600 hover:text-amber-700 font-semibold hover:underline transition-colors"
className="text-xs sm:text-sm text-amber-600 hover:text-amber-700 font-semibold hover:underline transition-colors flex-shrink-0 ml-2"
>
View All
</button>
@@ -439,23 +439,23 @@ const DashboardPage: React.FC = () => {
<Loading text="Loading payments..." />
</div>
) : recentPayments && recentPayments.length > 0 ? (
<div className="space-y-3">
<div className="space-y-2 sm:space-y-3">
{recentPayments.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-amber-50 hover:to-yellow-50 border border-slate-200 hover:border-amber-300 hover:shadow-lg cursor-pointer transition-all duration-200"
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-amber-50 hover:to-yellow-50 border border-slate-200 hover:border-amber-300 hover:shadow-lg cursor-pointer transition-all duration-200"
onClick={() => navigate(`/admin/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 className="flex items-center space-x-2 sm:space-x-3 md: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-lg">
<p className="font-bold text-slate-900 truncate text-xs sm:text-sm md:text-base">
{formatCurrency(payment.amount)}
</p>
<div className="flex items-center gap-2 mt-1">
<p className="text-sm text-slate-600 font-medium">
<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">
{getPaymentMethodLabel(payment.payment_method)}
</p>
{payment.payment_date && (
@@ -466,7 +466,7 @@ const DashboardPage: React.FC = () => {
</div>
</div>
</div>
<span className={`px-3 py-1.5 text-xs font-semibold rounded-full border shadow-sm ${getPaymentStatusColor(payment.payment_status)}`}>
<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>

View File

@@ -0,0 +1,947 @@
import React, { useState, useEffect } from 'react';
import {
Mail,
Plus,
Send,
Eye,
Edit,
Trash2,
Users,
BarChart3,
Calendar,
Filter,
Search,
FileText,
TrendingUp,
CheckCircle,
XCircle,
Clock,
Play,
Pause,
RefreshCw,
X,
Save,
Layers,
Target
} from 'lucide-react';
import { emailCampaignService, Campaign, CampaignSegment, EmailTemplate, DripSequence, CampaignAnalytics } from '../../services/api/emailCampaignService';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import { formatDate } from '../../utils/format';
type CampaignTab = 'campaigns' | 'segments' | 'templates' | 'drip-sequences' | 'analytics';
const EmailCampaignManagementPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<CampaignTab>('campaigns');
const [loading, setLoading] = useState(false);
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [segments, setSegments] = useState<CampaignSegment[]>([]);
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [dripSequences, setDripSequences] = useState<DripSequence[]>([]);
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
const [analytics, setAnalytics] = useState<CampaignAnalytics | null>(null);
const [showCampaignModal, setShowCampaignModal] = useState(false);
const [showSegmentModal, setShowSegmentModal] = useState(false);
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [showDripModal, setShowDripModal] = useState(false);
const [editingItem, setEditingItem] = useState<any>(null);
const [dripForm, setDripForm] = useState({
name: '',
description: '',
trigger_event: ''
});
const [filters, setFilters] = useState({
status: '',
campaign_type: ''
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [campaignForm, setCampaignForm] = useState({
name: '',
subject: '',
html_content: '',
text_content: '',
campaign_type: 'newsletter',
segment_id: undefined as number | undefined,
scheduled_at: '',
template_id: undefined as number | undefined,
from_name: '',
from_email: '',
track_opens: true,
track_clicks: true
});
const [segmentForm, setSegmentForm] = useState({
name: '',
description: '',
criteria: {
role: '',
has_bookings: undefined as boolean | undefined,
is_vip: undefined as boolean | undefined,
last_booking_days: undefined as number | undefined
}
});
const [templateForm, setTemplateForm] = useState({
name: '',
subject: '',
html_content: '',
text_content: '',
category: ''
});
useEffect(() => {
if (activeTab === 'campaigns') {
fetchCampaigns();
} else if (activeTab === 'segments') {
fetchSegments();
} else if (activeTab === 'templates') {
fetchTemplates();
} else if (activeTab === 'drip-sequences') {
fetchDripSequences();
}
}, [activeTab, filters, currentPage]);
const fetchCampaigns = async () => {
setLoading(true);
try {
const data = await emailCampaignService.getCampaigns({
status: filters.status || undefined,
campaign_type: filters.campaign_type || undefined,
limit: 20,
offset: (currentPage - 1) * 20
});
setCampaigns(data);
setTotalPages(Math.ceil(data.length / 20));
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to fetch campaigns');
} finally {
setLoading(false);
}
};
const fetchSegments = async () => {
setLoading(true);
try {
const data = await emailCampaignService.getSegments();
setSegments(data);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to fetch segments');
} finally {
setLoading(false);
}
};
const fetchTemplates = async () => {
setLoading(true);
try {
const data = await emailCampaignService.getTemplates();
setTemplates(data);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to fetch templates');
} finally {
setLoading(false);
}
};
const fetchDripSequences = async () => {
setLoading(true);
try {
const data = await emailCampaignService.getDripSequences();
setDripSequences(data);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to fetch drip sequences');
} finally {
setLoading(false);
}
};
const handleCreateCampaign = async () => {
try {
if (editingItem) {
await emailCampaignService.updateCampaign(editingItem.id, campaignForm);
toast.success('Campaign updated');
} else {
await emailCampaignService.createCampaign(campaignForm);
toast.success('Campaign created');
}
setShowCampaignModal(false);
resetCampaignForm();
fetchCampaigns();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to save campaign');
}
};
const handleSendCampaign = async (campaignId: number) => {
if (!window.confirm('Are you sure you want to send this campaign?')) return;
try {
const result = await emailCampaignService.sendCampaign(campaignId);
toast.success(`Campaign sent! ${result.sent} emails sent, ${result.failed} failed`);
fetchCampaigns();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to send campaign');
}
};
const handleViewAnalytics = async (campaignId: number) => {
try {
const data = await emailCampaignService.getCampaignAnalytics(campaignId);
setAnalytics(data);
setActiveTab('analytics');
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to fetch analytics');
}
};
const handleCreateSegment = async () => {
try {
// Build criteria object
const criteria: any = {};
if (segmentForm.criteria.role) criteria.role = segmentForm.criteria.role;
if (segmentForm.criteria.has_bookings !== undefined) criteria.has_bookings = segmentForm.criteria.has_bookings;
if (segmentForm.criteria.is_vip !== undefined) criteria.is_vip = segmentForm.criteria.is_vip;
if (segmentForm.criteria.last_booking_days) criteria.last_booking_days = segmentForm.criteria.last_booking_days;
await emailCampaignService.createSegment({
name: segmentForm.name,
description: segmentForm.description,
criteria
});
toast.success('Segment created');
setShowSegmentModal(false);
resetSegmentForm();
fetchSegments();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to create segment');
}
};
const handleCreateTemplate = async () => {
try {
await emailCampaignService.createTemplate(templateForm);
toast.success('Template created');
setShowTemplateModal(false);
resetTemplateForm();
fetchTemplates();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to create template');
}
};
const handleCreateDripSequence = async () => {
try {
await emailCampaignService.createDripSequence(dripForm);
toast.success('Drip sequence created');
setShowDripModal(false);
resetDripForm();
fetchDripSequences();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to create drip sequence');
}
};
const resetCampaignForm = () => {
setCampaignForm({
name: '',
subject: '',
html_content: '',
text_content: '',
campaign_type: 'newsletter',
segment_id: undefined,
scheduled_at: '',
template_id: undefined,
from_name: '',
from_email: '',
track_opens: true,
track_clicks: true
});
setEditingItem(null);
};
const resetSegmentForm = () => {
setSegmentForm({
name: '',
description: '',
criteria: {
role: '',
has_bookings: undefined,
is_vip: undefined,
last_booking_days: undefined
}
});
};
const resetTemplateForm = () => {
setTemplateForm({
name: '',
subject: '',
html_content: '',
text_content: '',
category: ''
});
};
const resetDripForm = () => {
setDripForm({
name: '',
description: '',
trigger_event: ''
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'sent': return 'bg-green-100 text-green-800';
case 'sending': return 'bg-blue-100 text-blue-800';
case 'scheduled': return 'bg-yellow-100 text-yellow-800';
case 'draft': return 'bg-gray-100 text-gray-800';
case 'paused': return 'bg-orange-100 text-orange-800';
default: return 'bg-gray-100 text-gray-800';
}
};
if (loading && !campaigns.length && !segments.length && !templates.length) {
return <Loading />;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Header */}
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-r from-blue-400/5 via-transparent to-purple-600/5 rounded-3xl blur-3xl"></div>
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-blue-200/30 p-8">
<div className="flex items-center gap-5">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-blue-400 to-purple-600 rounded-2xl blur-lg opacity-50"></div>
<div className="relative p-4 rounded-2xl bg-gradient-to-br from-blue-500 via-blue-500 to-purple-600 shadow-xl border border-blue-400/50">
<Mail className="w-8 h-8 text-white" />
</div>
</div>
<div>
<h1 className="text-4xl font-extrabold bg-gradient-to-r from-slate-900 via-blue-700 to-slate-900 bg-clip-text text-transparent">
Email Marketing & Campaigns
</h1>
<p className="text-gray-600 mt-2">Create, manage, and track email campaigns</p>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-200 p-2">
<div className="flex flex-wrap gap-2">
{[
{ id: 'campaigns', label: 'Campaigns', icon: Mail },
{ id: 'segments', label: 'Segments', icon: Target },
{ id: 'templates', label: 'Templates', icon: FileText },
{ id: 'drip-sequences', label: 'Drip Campaigns', icon: Layers },
{ id: 'analytics', label: 'Analytics', icon: BarChart3 }
].map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as CampaignTab)}
className={`flex items-center gap-2 px-4 py-2 rounded-xl font-medium transition-all ${
activeTab === tab.id
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white shadow-lg'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</div>
</div>
{/* Content */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-200 p-6">
{activeTab === 'campaigns' && (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h3 className="text-xl font-semibold">Email Campaigns</h3>
<button
onClick={() => {
resetCampaignForm();
setShowCampaignModal(true);
}}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Create Campaign
</button>
</div>
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="scheduled">Scheduled</option>
<option value="sending">Sending</option>
<option value="sent">Sent</option>
<option value="paused">Paused</option>
</select>
<select
value={filters.campaign_type}
onChange={(e) => setFilters({ ...filters, campaign_type: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="">All Types</option>
<option value="newsletter">Newsletter</option>
<option value="promotional">Promotional</option>
<option value="transactional">Transactional</option>
<option value="abandoned_booking">Abandoned Booking</option>
<option value="welcome">Welcome</option>
</select>
</div>
{/* Campaigns Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-semibold">Name</th>
<th className="text-left py-3 px-4 font-semibold">Type</th>
<th className="text-left py-3 px-4 font-semibold">Status</th>
<th className="text-left py-3 px-4 font-semibold">Recipients</th>
<th className="text-left py-3 px-4 font-semibold">Open Rate</th>
<th className="text-left py-3 px-4 font-semibold">Click Rate</th>
<th className="text-left py-3 px-4 font-semibold">Date</th>
<th className="text-left py-3 px-4 font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{campaigns.map((campaign) => (
<tr key={campaign.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4 font-medium">{campaign.name}</td>
<td className="py-3 px-4 text-sm text-gray-600">{campaign.campaign_type}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded-full text-xs ${getStatusColor(campaign.status)}`}>
{campaign.status}
</span>
</td>
<td className="py-3 px-4">{campaign.total_recipients}</td>
<td className="py-3 px-4">
{campaign.open_rate !== null && campaign.open_rate !== undefined
? `${campaign.open_rate.toFixed(2)}%`
: '-'}
</td>
<td className="py-3 px-4">
{campaign.click_rate !== null && campaign.click_rate !== undefined
? `${campaign.click_rate.toFixed(2)}%`
: '-'}
</td>
<td className="py-3 px-4 text-sm text-gray-600">
{campaign.sent_at ? formatDate(campaign.sent_at) : formatDate(campaign.created_at)}
</td>
<td className="py-3 px-4">
<div className="flex gap-2">
<button
onClick={() => handleViewAnalytics(campaign.id)}
className="px-3 py-1 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm"
>
Analytics
</button>
{campaign.status === 'draft' && (
<button
onClick={() => handleSendCampaign(campaign.id)}
className="px-3 py-1 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm"
>
Send
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
{activeTab === 'segments' && (
<SegmentsTab
segments={segments}
onRefresh={fetchSegments}
onCreate={() => setShowSegmentModal(true)}
/>
)}
{activeTab === 'templates' && (
<TemplatesTab
templates={templates}
onRefresh={fetchTemplates}
onCreate={() => setShowTemplateModal(true)}
/>
)}
{activeTab === 'drip-sequences' && (
<DripSequencesTab
sequences={dripSequences}
onRefresh={fetchDripSequences}
onCreate={() => {
resetDripForm();
setShowDripModal(true);
}}
/>
)}
{activeTab === 'analytics' && analytics && (
<AnalyticsTab analytics={analytics} />
)}
</div>
{/* Campaign Modal */}
{showCampaignModal && (
<CampaignModal
form={campaignForm}
setForm={setCampaignForm}
segments={segments}
templates={templates}
onSave={handleCreateCampaign}
onClose={() => {
setShowCampaignModal(false);
resetCampaignForm();
}}
editing={!!editingItem}
/>
)}
{/* Segment Modal */}
{showSegmentModal && (
<SegmentModal
form={segmentForm}
setForm={setSegmentForm}
onSave={handleCreateSegment}
onClose={() => {
setShowSegmentModal(false);
resetSegmentForm();
}}
/>
)}
{/* Template Modal */}
{showTemplateModal && (
<TemplateModal
form={templateForm}
setForm={setTemplateForm}
onSave={handleCreateTemplate}
onClose={() => {
setShowTemplateModal(false);
resetTemplateForm();
}}
/>
)}
{/* Drip Sequence Modal */}
{showDripModal && (
<DripSequenceModal
form={dripForm}
setForm={setDripForm}
onSave={handleCreateDripSequence}
onClose={() => {
setShowDripModal(false);
resetDripForm();
}}
/>
)}
</div>
</div>
);
};
// Sub-components
const SegmentsTab: React.FC<{
segments: CampaignSegment[];
onRefresh: () => void;
onCreate: () => void;
}> = ({ segments, onCreate }) => (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-xl font-semibold">Segments</h3>
<button
onClick={onCreate}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Create Segment
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{segments.map((segment) => (
<div key={segment.id} className="border rounded-xl p-4">
<h4 className="font-semibold">{segment.name}</h4>
<p className="text-sm text-gray-600 mt-1">{segment.description}</p>
<p className="text-sm text-blue-600 mt-2">
Estimated: {segment.estimated_count || 0} users
</p>
</div>
))}
</div>
</div>
);
const TemplatesTab: React.FC<{
templates: EmailTemplate[];
onRefresh: () => void;
onCreate: () => void;
}> = ({ templates, onCreate }) => (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-xl font-semibold">Email Templates</h3>
<button
onClick={onCreate}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Create Template
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{templates.map((template) => (
<div key={template.id} className="border rounded-xl p-4">
<h4 className="font-semibold">{template.name}</h4>
<p className="text-sm text-gray-600 mt-1">{template.subject}</p>
{template.category && (
<span className="inline-block mt-2 px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs">
{template.category}
</span>
)}
</div>
))}
</div>
</div>
);
const DripSequencesTab: React.FC<{
sequences: DripSequence[];
onRefresh: () => void;
onCreate: () => void;
}> = ({ sequences, onCreate }) => (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-xl font-semibold">Drip Sequences</h3>
<button
onClick={onCreate}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Create Sequence
</button>
</div>
<div className="space-y-4">
{sequences.map((sequence) => (
<div key={sequence.id} className="border rounded-xl p-4">
<div className="flex justify-between items-start">
<div>
<h4 className="font-semibold">{sequence.name}</h4>
<p className="text-sm text-gray-600 mt-1">{sequence.description}</p>
<p className="text-sm text-blue-600 mt-2">
{sequence.step_count} steps
{sequence.trigger_event && ` • Trigger: ${sequence.trigger_event}`}
</p>
</div>
<button className="px-3 py-1 bg-blue-500 text-white rounded-lg text-sm">
Edit
</button>
</div>
</div>
))}
</div>
</div>
);
const AnalyticsTab: React.FC<{ analytics: CampaignAnalytics }> = ({ analytics }) => (
<div className="space-y-6">
<h3 className="text-xl font-semibold">Campaign Analytics</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="bg-blue-50 rounded-xl p-6 border border-blue-100">
<p className="text-sm text-blue-600">Open Rate</p>
<p className="text-3xl font-bold text-blue-800 mt-2">{analytics.open_rate.toFixed(2)}%</p>
</div>
<div className="bg-green-50 rounded-xl p-6 border border-green-100">
<p className="text-sm text-green-600">Click Rate</p>
<p className="text-3xl font-bold text-green-800 mt-2">{analytics.click_rate.toFixed(2)}%</p>
</div>
<div className="bg-purple-50 rounded-xl p-6 border border-purple-100">
<p className="text-sm text-purple-600">Total Opened</p>
<p className="text-3xl font-bold text-purple-800 mt-2">{analytics.total_opened}</p>
</div>
<div className="bg-orange-50 rounded-xl p-6 border border-orange-100">
<p className="text-sm text-orange-600">Total Clicked</p>
<p className="text-3xl font-bold text-orange-800 mt-2">{analytics.total_clicked}</p>
</div>
</div>
</div>
);
const CampaignModal: React.FC<{
form: any;
setForm: (form: any) => void;
segments: CampaignSegment[];
templates: EmailTemplate[];
onSave: () => void;
onClose: () => void;
editing: boolean;
}> = ({ form, setForm, segments, templates, onSave, onClose, editing }) => (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-start mb-4">
<h4 className="text-lg font-semibold">{editing ? 'Edit Campaign' : 'Create Campaign'}</h4>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<input
type="text"
placeholder="Campaign Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
<input
type="text"
placeholder="Subject"
value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
<select
value={form.campaign_type}
onChange={(e) => setForm({ ...form, campaign_type: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="newsletter">Newsletter</option>
<option value="promotional">Promotional</option>
<option value="transactional">Transactional</option>
<option value="abandoned_booking">Abandoned Booking</option>
<option value="welcome">Welcome</option>
</select>
<select
value={form.segment_id || ''}
onChange={(e) => setForm({ ...form, segment_id: e.target.value ? parseInt(e.target.value) : undefined })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="">No Segment (All Users)</option>
{segments.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<textarea
placeholder="HTML Content"
value={form.html_content}
onChange={(e) => setForm({ ...form, html_content: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
rows={10}
/>
<div className="flex gap-2">
<button
onClick={onSave}
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
{editing ? 'Update' : 'Create'}
</button>
<button
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
const SegmentModal: React.FC<{
form: any;
setForm: (form: any) => void;
onSave: () => void;
onClose: () => void;
}> = ({ form, setForm, onSave, onClose }) => (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-2xl w-full">
<div className="flex justify-between items-start mb-4">
<h4 className="text-lg font-semibold">Create Segment</h4>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<input
type="text"
placeholder="Segment Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
<textarea
placeholder="Description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
rows={3}
/>
<select
value={form.criteria.role}
onChange={(e) => setForm({ ...form, criteria: { ...form.criteria, role: e.target.value } })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="">All Roles</option>
<option value="customer">Customer</option>
<option value="admin">Admin</option>
<option value="staff">Staff</option>
</select>
<div className="flex gap-2">
<button
onClick={onSave}
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Create
</button>
<button
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
const TemplateModal: React.FC<{
form: any;
setForm: (form: any) => void;
onSave: () => void;
onClose: () => void;
}> = ({ form, setForm, onSave, onClose }) => (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-start mb-4">
<h4 className="text-lg font-semibold">Create Template</h4>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<input
type="text"
placeholder="Template Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
<input
type="text"
placeholder="Subject"
value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
<textarea
placeholder="HTML Content"
value={form.html_content}
onChange={(e) => setForm({ ...form, html_content: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
rows={15}
/>
<div className="flex gap-2">
<button
onClick={onSave}
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Create
</button>
<button
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
const DripSequenceModal: React.FC<{
form: any;
setForm: (form: any) => void;
onSave: () => void;
onClose: () => void;
}> = ({ form, setForm, onSave, onClose }) => (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-2xl w-full">
<div className="flex justify-between items-start mb-4">
<h4 className="text-lg font-semibold">Create Drip Sequence</h4>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<input
type="text"
placeholder="Sequence Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
<textarea
placeholder="Description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
rows={3}
/>
<select
value={form.trigger_event}
onChange={(e) => setForm({ ...form, trigger_event: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="">No Trigger (Manual)</option>
<option value="user_signup">User Signup</option>
<option value="booking_created">Booking Created</option>
<option value="booking_cancelled">Booking Cancelled</option>
<option value="check_in">Check In</option>
<option value="check_out">Check Out</option>
</select>
<div className="flex gap-2">
<button
onClick={onSave}
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Create
</button>
<button
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
export default EmailCampaignManagementPage;

View File

@@ -0,0 +1,538 @@
import React, { useEffect, useState } from 'react';
import { Search, Eye, XCircle, CheckCircle, Loader2, Users, Plus } from 'lucide-react';
import { groupBookingService, GroupBooking } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { formatDate } from '../../utils/format';
import CreateGroupBookingModal from '../../components/shared/CreateGroupBookingModal';
const GroupBookingManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [groupBookings, setGroupBookings] = useState<GroupBooking[]>([]);
const [loading, setLoading] = useState(true);
const [selectedBooking, setSelectedBooking] = useState<GroupBooking | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [confirmingBookingId, setConfirmingBookingId] = useState<number | null>(null);
const [cancellingBookingId, setCancellingBookingId] = useState<number | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [filters, setFilters] = useState({
search: '',
status: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const itemsPerPage = 10;
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchGroupBookings();
}, [filters, currentPage]);
const fetchGroupBookings = async () => {
try {
setLoading(true);
const response = await groupBookingService.getGroupBookings({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setGroupBookings(response.data.group_bookings);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load group bookings');
} finally {
setLoading(false);
}
};
const handleConfirmBooking = async (id: number) => {
if (!window.confirm('Are you sure you want to confirm this group booking?')) return;
try {
setConfirmingBookingId(id);
await groupBookingService.confirmGroupBooking(id);
toast.success('Group booking confirmed successfully');
await fetchGroupBookings();
if (selectedBooking?.id === id) {
const updated = await groupBookingService.getGroupBooking(id);
setSelectedBooking(updated.data.group_booking);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || error.response?.data?.message || 'Unable to confirm booking');
} finally {
setConfirmingBookingId(null);
}
};
const handleCancelBooking = async (id: number) => {
if (!window.confirm('Are you sure you want to cancel this group booking?')) return;
try {
setCancellingBookingId(id);
await groupBookingService.cancelGroupBooking(id, 'Cancelled by admin');
toast.success('Group booking cancelled successfully');
await fetchGroupBookings();
if (selectedBooking?.id === id) {
setShowDetailModal(false);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || error.response?.data?.message || 'Unable to cancel booking');
} finally {
setCancellingBookingId(null);
}
};
const handleViewDetails = async (booking: GroupBooking) => {
try {
const response = await groupBookingService.getGroupBooking(booking.id);
setSelectedBooking(response.data.group_booking);
setShowDetailModal(true);
} catch (error: any) {
toast.error('Unable to load booking details');
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
draft: {
bg: 'bg-gradient-to-r from-gray-50 to-slate-50',
text: 'text-gray-700',
label: 'Draft',
border: 'border-gray-200'
},
pending: {
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
text: 'text-amber-800',
label: 'Pending',
border: 'border-amber-200'
},
confirmed: {
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
text: 'text-blue-800',
label: 'Confirmed',
border: 'border-blue-200'
},
partially_confirmed: {
bg: 'bg-gradient-to-r from-purple-50 to-violet-50',
text: 'text-purple-800',
label: 'Partially Confirmed',
border: 'border-purple-200'
},
checked_in: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Checked In',
border: 'border-emerald-200'
},
checked_out: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: 'Checked Out',
border: 'border-slate-200'
},
cancelled: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: 'Cancelled',
border: 'border-rose-200'
},
};
const badge = badges[status] || badges.draft;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${badge.bg} ${badge.text} ${badge.border}`}>
{badge.label}
</span>
);
};
if (loading && groupBookings.length === 0) {
return <Loading />;
}
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6 flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Group Booking Management</h1>
<p className="text-gray-600">Manage group bookings, room blocks, and member assignments</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-5 h-5" />
Create Group Booking
</button>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Search by booking number, group name..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="partially_confirmed">Partially Confirmed</option>
<option value="checked_in">Checked In</option>
<option value="checked_out">Checked Out</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
{/* Group Bookings Table */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Booking Number
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Group Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Coordinator
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Dates
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rooms / Guests
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Total Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{groupBookings.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-12 text-center text-gray-500">
No group bookings found
</td>
</tr>
) : (
groupBookings.map((booking) => (
<tr key={booking.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{booking.group_booking_number}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{booking.group_name || 'N/A'}
</div>
{booking.group_type && (
<div className="text-xs text-gray-500">{booking.group_type}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{booking.coordinator.name}</div>
<div className="text-xs text-gray-500">{booking.coordinator.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{formatDate(booking.check_in_date, 'short')}
</div>
<div className="text-xs text-gray-500">
to {formatDate(booking.check_out_date, 'short')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2 text-sm text-gray-900">
<Users className="w-4 h-4" />
{booking.total_rooms} rooms
</div>
<div className="text-xs text-gray-500">{booking.total_guests} guests</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{formatCurrency(booking.total_price)}
</div>
{booking.discount_amount > 0 && (
<div className="text-xs text-green-600">
-{formatCurrency(booking.discount_amount)} discount
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(booking.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
<button
onClick={() => handleViewDetails(booking)}
className="text-blue-600 hover:text-blue-900 flex items-center gap-1"
>
<Eye className="w-4 h-4" />
View
</button>
{booking.status === 'draft' || booking.status === 'pending' ? (
<button
onClick={() => handleConfirmBooking(booking.id)}
disabled={confirmingBookingId === booking.id}
className="text-green-600 hover:text-green-900 flex items-center gap-1 disabled:opacity-50"
>
{confirmingBookingId === booking.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCircle className="w-4 h-4" />
)}
Confirm
</button>
) : null}
{booking.status !== 'cancelled' && booking.status !== 'checked_out' ? (
<button
onClick={() => handleCancelBooking(booking.id)}
disabled={cancellingBookingId === booking.id}
className="text-red-600 hover:text-red-900 flex items-center gap-1 disabled:opacity-50"
>
{cancellingBookingId === booking.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<XCircle className="w-4 h-4" />
)}
Cancel
</button>
) : null}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</div>
{/* Detail Modal */}
{showDetailModal && selectedBooking && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<div className="flex justify-between items-start">
<div>
<h2 className="text-2xl font-bold text-gray-900">
{selectedBooking.group_booking_number}
</h2>
<p className="text-gray-600 mt-1">{selectedBooking.group_name || 'No group name'}</p>
</div>
<button
onClick={() => setShowDetailModal(false)}
className="text-gray-400 hover:text-gray-600"
>
<XCircle className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-6">
{/* Booking Info */}
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-sm font-medium text-gray-500 mb-2">Coordinator</h3>
<p className="text-gray-900">{selectedBooking.coordinator.name}</p>
<p className="text-sm text-gray-600">{selectedBooking.coordinator.email}</p>
{selectedBooking.coordinator.phone && (
<p className="text-sm text-gray-600">{selectedBooking.coordinator.phone}</p>
)}
</div>
<div>
<h3 className="text-sm font-medium text-gray-500 mb-2">Status</h3>
{getStatusBadge(selectedBooking.status)}
</div>
<div>
<h3 className="text-sm font-medium text-gray-500 mb-2">Check-in</h3>
<p className="text-gray-900">{formatDate(selectedBooking.check_in_date, 'short')}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500 mb-2">Check-out</h3>
<p className="text-gray-900">{formatDate(selectedBooking.check_out_date, 'short')}</p>
</div>
</div>
{/* Room Blocks */}
{selectedBooking.room_blocks && selectedBooking.room_blocks.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">Room Blocks</h3>
<div className="space-y-3">
{selectedBooking.room_blocks.map((block) => (
<div key={block.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex justify-between items-start">
<div>
<p className="font-medium text-gray-900">
{block.room_type?.name || `Room Type ${block.room_type_id}`}
</p>
<p className="text-sm text-gray-600">
{block.rooms_blocked} rooms blocked {block.rooms_confirmed} confirmed {block.rooms_available} available
</p>
</div>
<div className="text-right">
<p className="font-medium text-gray-900">
{formatCurrency(block.rate_per_room)}/room
</p>
<p className="text-sm text-gray-600">
Total: {formatCurrency(block.total_block_price)}
</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Members */}
{selectedBooking.members && selectedBooking.members.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">
Members ({selectedBooking.members.length})
</h3>
<div className="space-y-2">
{selectedBooking.members.map((member) => (
<div key={member.id} className="border border-gray-200 rounded-lg p-3">
<div className="flex justify-between items-start">
<div>
<p className="font-medium text-gray-900">{member.full_name}</p>
{member.email && <p className="text-sm text-gray-600">{member.email}</p>}
{member.phone && <p className="text-sm text-gray-600">{member.phone}</p>}
{member.assigned_room_id && (
<p className="text-sm text-blue-600">Room #{member.assigned_room_id}</p>
)}
</div>
{member.individual_amount && (
<div className="text-right">
<p className="text-sm text-gray-600">
Amount: {formatCurrency(member.individual_amount)}
</p>
<p className="text-sm text-gray-600">
Paid: {formatCurrency(member.individual_paid)}
</p>
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Pricing */}
<div className="border-t border-gray-200 pt-4">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Pricing Summary</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Original Total:</span>
<span className="text-gray-900">{formatCurrency(selectedBooking.original_total_price)}</span>
</div>
{selectedBooking.discount_amount > 0 && (
<div className="flex justify-between text-green-600">
<span>Discount ({selectedBooking.group_discount_percentage}%):</span>
<span>-{formatCurrency(selectedBooking.discount_amount)}</span>
</div>
)}
<div className="flex justify-between font-semibold text-lg border-t border-gray-200 pt-2">
<span>Total Price:</span>
<span>{formatCurrency(selectedBooking.total_price)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Amount Paid:</span>
<span className="text-gray-900">{formatCurrency(selectedBooking.amount_paid)}</span>
</div>
<div className="flex justify-between text-sm font-medium">
<span className="text-gray-900">Balance Due:</span>
<span className={selectedBooking.balance_due > 0 ? 'text-red-600' : 'text-green-600'}>
{formatCurrency(selectedBooking.balance_due)}
</span>
</div>
</div>
</div>
{/* Payments */}
{selectedBooking.payments && selectedBooking.payments.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">Payments</h3>
<div className="space-y-2">
{selectedBooking.payments.map((payment) => (
<div key={payment.id} className="border border-gray-200 rounded-lg p-3">
<div className="flex justify-between items-start">
<div>
<p className="font-medium text-gray-900">
{formatCurrency(payment.amount)} - {payment.payment_method}
</p>
<p className="text-sm text-gray-600">
{payment.payment_type} {payment.payment_status}
</p>
{payment.payment_date && (
<p className="text-xs text-gray-500">
{new Date(payment.payment_date).toLocaleDateString()}
</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Create Group Booking Modal */}
<CreateGroupBookingModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
setShowCreateModal(false);
fetchGroupBookings();
}}
/>
</div>
);
};
export default GroupBookingManagementPage;

View File

@@ -43,7 +43,9 @@ const InvoiceManagementPage: React.FC = () => {
if (response.status === 'success' && response.data) {
let invoiceList = response.data.invoices || [];
// Client-side filtering for search (only on current page results)
// Note: This is a limitation - search only works on current page
// For full search functionality, backend needs to support search parameter
if (filters.search) {
invoiceList = invoiceList.filter((inv) =>
inv.invoice_number.toLowerCase().includes(filters.search.toLowerCase()) ||
@@ -54,8 +56,15 @@ const InvoiceManagementPage: React.FC = () => {
}
setInvoices(invoiceList);
setTotalPages(response.data.total_pages || 1);
setTotalItems(response.data.total || 0);
// Only update pagination if not searching (to avoid incorrect counts)
if (!filters.search) {
setTotalPages(response.data.total_pages || 1);
setTotalItems(response.data.total || 0);
} else {
// When searching, keep original pagination but show filtered count
setTotalPages(response.data.total_pages || 1);
setTotalItems(response.data.total || 0);
}
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load invoices');

View File

@@ -0,0 +1,271 @@
import React, { useState } from 'react';
import {
Bell,
Mail,
MessageSquare,
Smartphone,
Send,
Plus,
Eye,
Filter,
CheckCircle2,
Clock,
XCircle,
AlertCircle,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Loading, EmptyState } from '../../components/common';
import { useAsync } from '../../hooks/useAsync';
import notificationService, { Notification } from '../../services/api/notificationService';
import { formatDate } from '../../utils/format';
import SendNotificationModal from '../../components/notifications/SendNotificationModal';
import NotificationTemplatesModal from '../../components/notifications/NotificationTemplatesModal';
const NotificationManagementPage: React.FC = () => {
const [showSendModal, setShowSendModal] = useState(false);
const [showTemplatesModal, setShowTemplatesModal] = useState(false);
const [filters, setFilters] = useState({
notification_type: '',
channel: '',
status: '',
});
const { data: notifications, loading, execute: fetchNotifications } = useAsync<Notification[]>(
() => notificationService.getNotifications({
notification_type: filters.notification_type || undefined,
channel: filters.channel || undefined,
status: filters.status || undefined,
limit: 100,
}).then(r => r.data || []),
{ immediate: true }
);
const getChannelIcon = (channel: string) => {
switch (channel) {
case 'email':
return <Mail className="w-5 h-5 text-blue-500" />;
case 'sms':
return <MessageSquare className="w-5 h-5 text-green-500" />;
case 'push':
return <Bell className="w-5 h-5 text-purple-500" />;
case 'whatsapp':
return <Smartphone className="w-5 h-5 text-emerald-500" />;
default:
return <Bell className="w-5 h-5 text-gray-500" />;
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'sent':
case 'delivered':
return <CheckCircle2 className="w-5 h-5 text-green-500" />;
case 'read':
return <Eye className="w-5 h-5 text-blue-500" />;
case 'failed':
return <XCircle className="w-5 h-5 text-red-500" />;
default:
return <Clock className="w-5 h-5 text-amber-500" />;
}
};
const getStatusBadge = (status: string) => {
const baseClasses = 'px-3 py-1 rounded-full text-xs font-semibold';
switch (status) {
case 'sent':
case 'delivered':
return `${baseClasses} bg-green-100 text-green-800 border border-green-200`;
case 'read':
return `${baseClasses} bg-blue-100 text-blue-800 border border-blue-200`;
case 'failed':
return `${baseClasses} bg-red-100 text-red-800 border border-red-200`;
default:
return `${baseClasses} bg-amber-100 text-amber-800 border border-amber-200`;
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Header */}
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Notification Management</h1>
<p className="text-gray-600">Manage and send multi-channel notifications</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowTemplatesModal(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center gap-2"
>
<Bell className="w-5 h-5" />
Templates
</button>
<button
onClick={() => setShowSendModal(true)}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Send Notification
</button>
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center gap-3 mb-4">
<Filter className="w-5 h-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900">Filters</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Type</label>
<select
value={filters.notification_type}
onChange={(e) => setFilters({ ...filters, notification_type: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">All Types</option>
<option value="booking_confirmation">Booking Confirmation</option>
<option value="payment_receipt">Payment Receipt</option>
<option value="pre_arrival_reminder">Pre-Arrival Reminder</option>
<option value="check_in_reminder">Check-In Reminder</option>
<option value="check_out_reminder">Check-Out Reminder</option>
<option value="marketing_campaign">Marketing Campaign</option>
<option value="loyalty_update">Loyalty Update</option>
<option value="system_alert">System Alert</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Channel</label>
<select
value={filters.channel}
onChange={(e) => setFilters({ ...filters, channel: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">All Channels</option>
<option value="email">Email</option>
<option value="sms">SMS</option>
<option value="push">Push</option>
<option value="whatsapp">WhatsApp</option>
<option value="in_app">In-App</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Status</label>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="sent">Sent</option>
<option value="delivered">Delivered</option>
<option value="read">Read</option>
<option value="failed">Failed</option>
</select>
</div>
</div>
</div>
{/* Notifications List */}
{loading ? (
<Loading fullScreen text="Loading notifications..." />
) : !notifications || !Array.isArray(notifications) || notifications.length === 0 ? (
<div className="bg-white rounded-xl shadow-lg p-8 border border-gray-100">
<EmptyState
title="No notifications found"
description="Send your first notification or adjust your filters"
action={{
label: 'Send Notification',
onClick: () => setShowSendModal(true),
}}
/>
</div>
) : (
<div className="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-gray-50 to-gray-100">
<tr>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Notification</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Channel</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Type</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Status</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Sent At</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{notifications.map((notification) => (
<tr key={notification.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4">
<div>
<p className="text-sm font-semibold text-gray-900">
{notification.subject || notification.notification_type.replace('_', ' ')}
</p>
<p className="text-xs text-gray-500 line-clamp-1 mt-1">{notification.content}</p>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
{getChannelIcon(notification.channel)}
<span className="text-sm text-gray-700 capitalize">{notification.channel}</span>
</div>
</td>
<td className="px-6 py-4">
<span className="text-sm text-gray-700 capitalize">
{notification.notification_type.replace('_', ' ')}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
{getStatusIcon(notification.status)}
<span className={getStatusBadge(notification.status)}>
{notification.status}
</span>
</div>
</td>
<td className="px-6 py-4">
{notification.sent_at ? (
<span className="text-sm text-gray-700">
{formatDate(new Date(notification.sent_at), 'short')}
</span>
) : (
<span className="text-sm text-gray-400">Not sent</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Send Notification Modal */}
{showSendModal && (
<SendNotificationModal
onClose={() => setShowSendModal(false)}
onSuccess={() => {
setShowSendModal(false);
fetchNotifications();
}}
/>
)}
{/* Templates Modal */}
{showTemplatesModal && (
<NotificationTemplatesModal
onClose={() => setShowTemplatesModal(false)}
/>
)}
</div>
);
};
export default NotificationManagementPage;

View File

@@ -0,0 +1,708 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X, Package as PackageIcon } from 'lucide-react';
import { packageService, Package, PackageStatus, PackageItem, PackageItemType, CreatePackageData } from '../../services/api';
import { roomService, Room } from '../../services/api';
import { serviceService, Service } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
const PackageManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [packages, setPackages] = useState<Package[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingPackage, setEditingPackage] = useState<Package | null>(null);
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
const [services, setServices] = useState<Service[]>([]);
const [filters, setFilters] = useState({
search: '',
status: '',
room_type_id: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 10;
const [formData, setFormData] = useState<CreatePackageData>({
name: '',
code: '',
description: '',
status: 'active',
base_price: undefined,
price_modifier: 1.0,
discount_percentage: undefined,
room_type_id: undefined,
min_nights: undefined,
max_nights: undefined,
valid_from: '',
valid_to: '',
image_url: '',
highlights: [],
terms_conditions: '',
extra_data: undefined,
items: [],
});
const [newItem, setNewItem] = useState<Partial<PackageItem>>({
item_type: 'service',
item_name: '',
item_description: '',
quantity: 1,
unit: 'per_stay',
price: undefined,
included: true,
display_order: 0,
});
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchPackages();
}, [filters, currentPage]);
useEffect(() => {
fetchRoomTypes();
fetchServices();
}, []);
const fetchRoomTypes = async () => {
try {
const response = await roomService.getRooms({ limit: 100, page: 1 });
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
response.data.rooms.forEach((room: Room) => {
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
allUniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
name: room.room_type.name,
});
}
});
if (allUniqueRoomTypes.size > 0) {
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
}
} catch (err) {
console.error('Failed to fetch room types:', err);
}
};
const fetchServices = async () => {
try {
const response = await serviceService.getServices();
if (response.data && response.data.services) {
setServices(response.data.services);
}
} catch (err) {
console.error('Failed to fetch services:', err);
}
};
const fetchPackages = async () => {
try {
setLoading(true);
const params: any = {
page: currentPage,
limit: itemsPerPage,
};
if (filters.search) params.search = filters.search;
if (filters.status) params.status = filters.status;
if (filters.room_type_id) params.room_type_id = parseInt(filters.room_type_id);
const response = await packageService.getPackages(params);
setPackages(response.data.packages);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Unable to load packages');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const submitData = {
...formData,
room_type_id: formData.room_type_id ? parseInt(formData.room_type_id.toString()) : undefined,
base_price: formData.base_price || undefined,
discount_percentage: formData.discount_percentage || undefined,
valid_from: formData.valid_from || undefined,
valid_to: formData.valid_to || undefined,
};
if (editingPackage) {
await packageService.updatePackage(editingPackage.id, submitData);
toast.success('Package updated successfully');
} else {
await packageService.createPackage(submitData);
toast.success('Package created successfully');
}
setShowModal(false);
resetForm();
fetchPackages();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'An error occurred');
}
};
const handleEdit = (pkg: Package) => {
setEditingPackage(pkg);
setFormData({
name: pkg.name,
code: pkg.code,
description: pkg.description || '',
status: pkg.status,
base_price: pkg.base_price,
price_modifier: pkg.price_modifier,
discount_percentage: pkg.discount_percentage,
room_type_id: pkg.room_type_id,
min_nights: pkg.min_nights,
max_nights: pkg.max_nights,
valid_from: pkg.valid_from?.split('T')[0] || '',
valid_to: pkg.valid_to?.split('T')[0] || '',
image_url: pkg.image_url || '',
highlights: pkg.highlights || [],
terms_conditions: pkg.terms_conditions || '',
items: pkg.items || [],
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this package?')) return;
try {
await packageService.deletePackage(id);
toast.success('Package deleted successfully');
fetchPackages();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Unable to delete package');
}
};
const resetForm = () => {
setEditingPackage(null);
setFormData({
name: '',
code: '',
description: '',
status: 'active',
base_price: undefined,
price_modifier: 1.0,
discount_percentage: undefined,
room_type_id: undefined,
min_nights: undefined,
max_nights: undefined,
valid_from: '',
valid_to: '',
image_url: '',
highlights: [],
terms_conditions: '',
extra_data: undefined,
items: [],
});
setNewItem({
item_type: 'service',
item_name: '',
item_description: '',
quantity: 1,
unit: 'per_stay',
price: undefined,
included: true,
display_order: 0,
});
};
const addItem = () => {
if (!newItem.item_name) {
toast.error('Please enter item name');
return;
}
setFormData({
...formData,
items: [
...(formData.items || []),
{
...newItem,
display_order: formData.items?.length || 0,
} as PackageItem,
],
});
setNewItem({
item_type: 'service',
item_name: '',
item_description: '',
quantity: 1,
unit: 'per_stay',
price: undefined,
included: true,
display_order: 0,
});
};
const removeItem = (index: number) => {
const newItems = [...(formData.items || [])];
newItems.splice(index, 1);
setFormData({ ...formData, items: newItems });
};
const addHighlight = () => {
const highlight = prompt('Enter highlight:');
if (highlight) {
setFormData({
...formData,
highlights: [...(formData.highlights || []), highlight],
});
}
};
const removeHighlight = (index: number) => {
const newHighlights = [...(formData.highlights || [])];
newHighlights.splice(index, 1);
setFormData({ ...formData, highlights: newHighlights });
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
active: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Active',
border: 'border-emerald-200'
},
inactive: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: 'Inactive',
border: 'border-slate-200'
},
scheduled: {
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
text: 'text-blue-800',
label: 'Scheduled',
border: 'border-blue-200'
},
expired: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: 'Expired',
border: 'border-rose-200'
},
};
const badge = badges[status] || badges.active;
return (
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
{badge.label}
</span>
);
};
if (loading && packages.length === 0) {
return <Loading />;
}
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">
<div className="flex justify-between items-center animate-fade-in">
<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">
Package Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage package deals and bundles</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
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" />
Add Package
</button>
</div>
{/* Filters */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search by name or code..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="scheduled">Scheduled</option>
<option value="expired">Expired</option>
</select>
<select
value={filters.room_type_id}
onChange={(e) => setFilters({ ...filters, room_type_id: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Room Types</option>
{roomTypes.map((rt) => (
<option key={rt.id} value={rt.id}>{rt.name}</option>
))}
</select>
</div>
</div>
{/* Packages Table */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Code</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Name</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Items</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Pricing</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Room Type</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Status</th>
<th className="px-8 py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{packages.map((pkg, index) => (
<tr
key={pkg.id}
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
>
<td className="px-8 py-5 whitespace-nowrap">
<div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg">
<PackageIcon className="w-4 h-4 text-amber-600" />
</div>
<span className="text-sm font-mono font-bold bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">{pkg.code}</span>
</div>
</td>
<td className="px-8 py-5">
<div className="text-sm font-semibold text-slate-900">{pkg.name}</div>
<div className="text-xs text-slate-500 mt-0.5">{pkg.description}</div>
</td>
<td className="px-8 py-5">
<div className="text-sm text-slate-600">
{pkg.items?.length || 0} item(s)
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm">
{pkg.base_price ? (
<span className="font-bold text-emerald-600">{formatCurrency(pkg.base_price)}</span>
) : pkg.discount_percentage ? (
<span className="font-bold text-emerald-600">{pkg.discount_percentage}% off</span>
) : (
<span className="text-slate-600">Custom pricing</span>
)}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm text-slate-600">{pkg.room_type_name || 'All Types'}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
{getStatusBadge(pkg.status)}
</td>
<td className="px-8 py-5 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(pkg)}
className="p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all duration-200 shadow-sm hover:shadow-md border border-blue-200 hover:border-blue-300"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(pkg.id)}
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
title="Delete"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{packages.length === 0 && (
<div className="text-center py-12">
<PackageIcon className="w-16 h-16 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500">No packages found</p>
</div>
)}
</div>
{totalPages > 1 && (
<div className="px-8 py-5 border-t border-slate-200">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
)}
</div>
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-5xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 px-8 py-6 flex justify-between items-center rounded-t-2xl">
<h2 className="text-2xl font-bold text-white">
{editingPackage ? 'Edit Package' : 'Create Package'}
</h2>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="p-2 rounded-lg text-white hover:bg-white/20 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Name *</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Code *</label>
<input
type="text"
required
disabled={!!editingPackage}
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all disabled:bg-slate-100"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-semibold text-slate-700 mb-2">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Status *</label>
<select
required
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as PackageStatus })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="scheduled">Scheduled</option>
<option value="expired">Expired</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Room Type</label>
<select
value={formData.room_type_id || ''}
onChange={(e) => setFormData({ ...formData, room_type_id: e.target.value ? parseInt(e.target.value) : undefined })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
>
<option value="">All Room Types</option>
{roomTypes.map((rt) => (
<option key={rt.id} value={rt.id}>{rt.name}</option>
))}
</select>
</div>
</div>
<div className="border-t border-slate-200 pt-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Pricing</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Base Price</label>
<input
type="number"
step="0.01"
min="0"
value={formData.base_price || ''}
onChange={(e) => setFormData({ ...formData, base_price: e.target.value ? parseFloat(e.target.value) : undefined })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
<p className="text-xs text-slate-500 mt-1">Fixed price per night</p>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Price Modifier</label>
<input
type="number"
step="0.01"
min="0"
value={formData.price_modifier}
onChange={(e) => setFormData({ ...formData, price_modifier: parseFloat(e.target.value) || 1.0 })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Discount %</label>
<input
type="number"
step="0.01"
min="0"
max="100"
value={formData.discount_percentage || ''}
onChange={(e) => setFormData({ ...formData, discount_percentage: e.target.value ? parseFloat(e.target.value) : undefined })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
</div>
</div>
<div className="border-t border-slate-200 pt-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Package Items</h3>
<div className="space-y-4">
{formData.items?.map((item, index) => (
<div key={index} className="flex items-center gap-4 p-4 bg-slate-50 rounded-xl">
<div className="flex-1">
<div className="font-semibold text-slate-900">{item.item_name}</div>
<div className="text-sm text-slate-600">{item.item_type} - Qty: {item.quantity} {item.unit}</div>
{item.price && <div className="text-sm text-emerald-600">{formatCurrency(item.price)}</div>}
</div>
<button
type="button"
onClick={() => removeItem(index)}
className="p-2 text-rose-600 hover:bg-rose-50 rounded-lg"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
))}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 bg-amber-50 rounded-xl">
<div>
<label className="block text-xs font-semibold text-slate-700 mb-1">Type</label>
<select
value={newItem.item_type}
onChange={(e) => setNewItem({ ...newItem, item_type: e.target.value as PackageItemType })}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
>
<option value="service">Service</option>
<option value="breakfast">Breakfast</option>
<option value="activity">Activity</option>
<option value="amenity">Amenity</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-700 mb-1">Name *</label>
<input
type="text"
value={newItem.item_name}
onChange={(e) => setNewItem({ ...newItem, item_name: e.target.value })}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
placeholder="Item name"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-700 mb-1">Quantity</label>
<input
type="number"
min="1"
value={newItem.quantity}
onChange={(e) => setNewItem({ ...newItem, quantity: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
</div>
<div className="flex items-end">
<button
type="button"
onClick={addItem}
className="w-full px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-semibold"
>
Add Item
</button>
</div>
</div>
</div>
</div>
<div className="border-t border-slate-200 pt-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Highlights</h3>
<div className="space-y-2">
{formData.highlights?.map((highlight, index) => (
<div key={index} className="flex items-center gap-2 p-2 bg-slate-50 rounded-lg">
<span className="flex-1 text-sm text-slate-700">{highlight}</span>
<button
type="button"
onClick={() => removeHighlight(index)}
className="p-1 text-rose-600 hover:bg-rose-50 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
))}
<button
type="button"
onClick={addHighlight}
className="px-4 py-2 border-2 border-dashed border-slate-300 text-slate-600 rounded-lg hover:border-amber-400 hover:text-amber-600 transition-colors text-sm font-semibold"
>
+ Add Highlight
</button>
</div>
</div>
<div className="flex justify-end gap-4 pt-6 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all"
>
Cancel
</button>
<button
type="submit"
className="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 shadow-lg hover:shadow-xl"
>
{editingPackage ? 'Update' : 'Create'} Package
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default PackageManagementPage;

View File

@@ -564,26 +564,26 @@ const PageContentDashboard: React.FC = () => {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-10">
<div className="max-w-7xl mx-auto px-2 sm:px-3 md:px-4 lg:px-6 xl:px-8 py-2 sm:py-4 md:py-6 lg:py-8 space-y-3 sm:space-y-4 md:space-y-6 lg:space-y-8">
{/* Luxury Header */}
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-r from-purple-400/5 via-transparent to-indigo-600/5 rounded-3xl blur-3xl"></div>
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-purple-200/30 p-8 md:p-10">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-8">
<div className="flex items-start gap-5">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-purple-400 to-indigo-600 rounded-2xl blur-lg opacity-50"></div>
<div className="relative p-4 rounded-2xl bg-gradient-to-br from-purple-500 via-purple-500 to-indigo-600 shadow-xl border border-purple-400/50">
<Globe className="w-8 h-8 text-white" />
<div className="relative bg-white/80 backdrop-blur-xl rounded-xl sm:rounded-2xl md:rounded-3xl shadow-2xl border border-purple-200/30 p-3 sm:p-4 md:p-6 lg:p-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 sm:gap-6 md:gap-8">
<div className="flex items-start gap-3 sm:gap-4 md:gap-5">
<div className="relative flex-shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-purple-400 to-indigo-600 rounded-xl sm:rounded-2xl blur-lg opacity-50"></div>
<div className="relative p-2.5 sm:p-3 md:p-4 rounded-xl sm:rounded-2xl bg-gradient-to-br from-purple-500 via-purple-500 to-indigo-600 shadow-xl border border-purple-400/50">
<Globe className="w-5 h-5 sm:w-6 sm:h-6 md:w-8 md:h-8 text-white" />
</div>
</div>
<div className="space-y-3 flex-1">
<div className="flex items-center gap-3">
<h1 className="text-4xl md:text-5xl font-extrabold bg-gradient-to-r from-slate-900 via-purple-700 to-slate-900 bg-clip-text text-transparent">
<div className="space-y-2 sm:space-y-3 flex-1">
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
<h1 className="text-2xl sm:text-3xl md:text-3xl font-extrabold bg-gradient-to-r from-slate-900 via-purple-700 to-slate-900 bg-clip-text text-transparent">
Page Content Management
</h1>
</div>
<p className="text-gray-600 text-base md:text-lg max-w-2xl leading-relaxed">
<p className="text-gray-600 text-xs sm:text-sm md:text-sm max-w-2xl leading-relaxed">
Centralized control for all frontend pages and SEO optimization
</p>
</div>
@@ -591,36 +591,38 @@ const PageContentDashboard: React.FC = () => {
</div>
{/* Premium Tab Navigation */}
<div className="mt-10 pt-8 border-t border-gradient-to-r from-transparent via-purple-200/30 to-transparent">
<div className="flex flex-wrap gap-3">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
group relative flex items-center gap-3 px-6 py-3.5 rounded-xl font-semibold text-sm
transition-all duration-300 overflow-hidden
${
isActive
? 'bg-gradient-to-r from-purple-500 via-purple-500 to-indigo-600 text-white shadow-xl shadow-purple-500/40 scale-105'
: 'bg-white/80 text-gray-700 border border-gray-200/60 hover:border-purple-300/60 hover:bg-gradient-to-r hover:from-purple-50/50 hover:to-indigo-50/30 hover:shadow-lg hover:scale-102'
}
`}
>
{isActive && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
)}
<Icon className={`w-5 h-5 transition-transform duration-300 ${isActive ? 'text-white' : 'text-gray-600 group-hover:text-purple-600 group-hover:scale-110'}`} />
<span className="relative z-10">{tab.label}</span>
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-purple-300 via-purple-400 to-indigo-400"></div>
)}
</button>
);
})}
<div className="mt-4 sm:mt-6 md:mt-8 lg:mt-10 pt-4 sm:pt-6 md:pt-8 border-t border-gradient-to-r from-transparent via-purple-200/30 to-transparent">
<div className="overflow-x-auto -mx-2 sm:-mx-3 px-2 sm:px-3 scrollbar-hide">
<div className="flex gap-2 sm:gap-3 min-w-max sm:min-w-0 sm:flex-wrap">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
group relative flex items-center gap-1.5 sm:gap-2 md:gap-3 px-3 sm:px-4 md:px-6 py-2 sm:py-2.5 md:py-3.5 rounded-lg sm:rounded-xl font-semibold text-xs sm:text-sm flex-shrink-0
transition-all duration-300 overflow-hidden
${
isActive
? 'bg-gradient-to-r from-purple-500 via-purple-500 to-indigo-600 text-white shadow-xl shadow-purple-500/40 scale-105'
: 'bg-white/80 text-gray-700 border border-gray-200/60 hover:border-purple-300/60 hover:bg-gradient-to-r hover:from-purple-50/50 hover:to-indigo-50/30 hover:shadow-lg hover:scale-102'
}
`}
>
{isActive && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
)}
<Icon className={`w-3.5 h-3.5 sm:w-4 sm:h-4 md:w-5 md:h-5 transition-transform duration-300 flex-shrink-0 ${isActive ? 'text-white' : 'text-gray-600 group-hover:text-purple-600 group-hover:scale-110'}`} />
<span className="relative z-10 whitespace-nowrap">{tab.label}</span>
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 sm:h-1 bg-gradient-to-r from-purple-300 via-purple-400 to-indigo-400"></div>
)}
</button>
);
})}
</div>
</div>
</div>
</div>
@@ -628,7 +630,7 @@ const PageContentDashboard: React.FC = () => {
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5 md:gap-6 lg:gap-8">
{[
{ id: 'home' as PageType, label: 'Home Page', icon: Home, color: 'blue', description: 'Manage hero section, featured content' },
{ id: 'contact' as PageType, label: 'Contact Page', icon: Mail, color: 'green', description: 'Manage contact information and form' },

View File

@@ -35,15 +35,67 @@ const PaymentManagementPage: React.FC = () => {
const fetchPayments = async () => {
try {
setLoading(true);
const response = await paymentService.getPayments({
...filters,
// Backend only supports: booking_id, status, page, limit
// Remove search, method, from, to from API call and handle client-side
const apiParams: any = {
page: currentPage,
limit: itemsPerPage,
});
setPayments(response.data.payments);
};
if (filters.method) {
// Note: Backend doesn't support method filter, will filter client-side
}
if (filters.from || filters.to) {
// Note: Backend doesn't support date range filter, will filter client-side
}
const response = await paymentService.getPayments(apiParams);
let paymentsList = response.data.payments || [];
// Client-side filtering for search, method, and date range
// Note: This only filters current page results
if (filters.search) {
paymentsList = paymentsList.filter((p) =>
(p.transaction_id && p.transaction_id.toLowerCase().includes(filters.search.toLowerCase())) ||
(p.booking?.booking_number && p.booking.booking_number.toLowerCase().includes(filters.search.toLowerCase())) ||
(p.booking?.user?.name && p.booking.user.name.toLowerCase().includes(filters.search.toLowerCase())) ||
(p.booking?.user?.email && p.booking.user.email.toLowerCase().includes(filters.search.toLowerCase()))
);
}
if (filters.method) {
paymentsList = paymentsList.filter((p) => p.payment_method === filters.method);
}
if (filters.from) {
const fromDate = new Date(filters.from);
paymentsList = paymentsList.filter((p) => {
const paymentDate = p.payment_date ? new Date(p.payment_date) : (p.createdAt ? new Date(p.createdAt) : null);
return paymentDate && paymentDate >= fromDate;
});
}
if (filters.to) {
const toDate = new Date(filters.to);
toDate.setHours(23, 59, 59, 999); // Include entire day
paymentsList = paymentsList.filter((p) => {
const paymentDate = p.payment_date ? new Date(p.payment_date) : (p.createdAt ? new Date(p.createdAt) : null);
return paymentDate && paymentDate <= toDate;
});
}
setPayments(paymentsList);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
// Only update pagination if not filtering (to avoid incorrect counts)
if (!filters.search && !filters.method && !filters.from && !filters.to) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
} else {
// Keep original pagination when filtering
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load payments list');
@@ -226,6 +278,7 @@ const PaymentManagementPage: React.FC = () => {
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Customer</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Method</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Type</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Status</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Amount</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Payment Date</th>
</tr>

View File

@@ -0,0 +1,774 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X, Tag } from 'lucide-react';
import { ratePlanService, RatePlan, RatePlanType, RatePlanStatus, CreateRatePlanData } from '../../services/api';
import { roomService, Room } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
const RatePlanManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [ratePlans, setRatePlans] = useState<RatePlan[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingRatePlan, setEditingRatePlan] = useState<RatePlan | null>(null);
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
const [filters, setFilters] = useState({
search: '',
status: '',
plan_type: '',
room_type_id: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 10;
const [formData, setFormData] = useState<CreateRatePlanData>({
name: '',
code: '',
description: '',
plan_type: 'BAR',
status: 'active',
base_price_modifier: 1.0,
discount_percentage: 0,
fixed_discount: 0,
room_type_id: undefined,
min_nights: undefined,
max_nights: undefined,
advance_days_required: undefined,
valid_from: '',
valid_to: '',
is_refundable: true,
requires_deposit: false,
deposit_percentage: 0,
cancellation_hours: undefined,
corporate_code: '',
requires_verification: false,
verification_type: '',
long_stay_nights: undefined,
is_package: false,
package_id: undefined,
priority: 100,
extra_data: undefined,
});
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchRatePlans();
}, [filters, currentPage]);
useEffect(() => {
fetchRoomTypes();
}, []);
const fetchRoomTypes = async () => {
try {
const response = await roomService.getRooms({ limit: 100, page: 1 });
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
response.data.rooms.forEach((room: Room) => {
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
allUniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
name: room.room_type.name,
});
}
});
if (allUniqueRoomTypes.size > 0) {
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
}
} catch (err) {
console.error('Failed to fetch room types:', err);
}
};
const fetchRatePlans = async () => {
try {
setLoading(true);
const params: any = {
page: currentPage,
limit: itemsPerPage,
};
if (filters.search) params.search = filters.search;
if (filters.status) params.status = filters.status;
if (filters.plan_type) params.plan_type = filters.plan_type;
if (filters.room_type_id) params.room_type_id = parseInt(filters.room_type_id);
const response = await ratePlanService.getRatePlans(params);
setRatePlans(response.data.rate_plans);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Unable to load rate plans');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const submitData = {
...formData,
room_type_id: formData.room_type_id ? parseInt(formData.room_type_id.toString()) : undefined,
discount_percentage: formData.discount_percentage || undefined,
fixed_discount: formData.fixed_discount || undefined,
deposit_percentage: formData.deposit_percentage || undefined,
valid_from: formData.valid_from || undefined,
valid_to: formData.valid_to || undefined,
};
if (editingRatePlan) {
await ratePlanService.updateRatePlan(editingRatePlan.id, submitData);
toast.success('Rate plan updated successfully');
} else {
await ratePlanService.createRatePlan(submitData);
toast.success('Rate plan created successfully');
}
setShowModal(false);
resetForm();
fetchRatePlans();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'An error occurred');
}
};
const handleEdit = (ratePlan: RatePlan) => {
setEditingRatePlan(ratePlan);
setFormData({
name: ratePlan.name,
code: ratePlan.code,
description: ratePlan.description || '',
plan_type: ratePlan.plan_type,
status: ratePlan.status,
base_price_modifier: ratePlan.base_price_modifier,
discount_percentage: ratePlan.discount_percentage,
fixed_discount: ratePlan.fixed_discount,
room_type_id: ratePlan.room_type_id,
min_nights: ratePlan.min_nights,
max_nights: ratePlan.max_nights,
advance_days_required: ratePlan.advance_days_required,
valid_from: ratePlan.valid_from?.split('T')[0] || '',
valid_to: ratePlan.valid_to?.split('T')[0] || '',
is_refundable: ratePlan.is_refundable,
requires_deposit: ratePlan.requires_deposit,
deposit_percentage: ratePlan.deposit_percentage,
cancellation_hours: ratePlan.cancellation_hours,
corporate_code: ratePlan.corporate_code || '',
requires_verification: ratePlan.requires_verification,
verification_type: ratePlan.verification_type || '',
long_stay_nights: ratePlan.long_stay_nights,
is_package: ratePlan.is_package,
package_id: ratePlan.package_id,
priority: ratePlan.priority,
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this rate plan?')) return;
try {
await ratePlanService.deleteRatePlan(id);
toast.success('Rate plan deleted successfully');
fetchRatePlans();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Unable to delete rate plan');
}
};
const resetForm = () => {
setEditingRatePlan(null);
setFormData({
name: '',
code: '',
description: '',
plan_type: 'BAR',
status: 'active',
base_price_modifier: 1.0,
discount_percentage: 0,
fixed_discount: 0,
room_type_id: undefined,
min_nights: undefined,
max_nights: undefined,
advance_days_required: undefined,
valid_from: '',
valid_to: '',
is_refundable: true,
requires_deposit: false,
deposit_percentage: 0,
cancellation_hours: undefined,
corporate_code: '',
requires_verification: false,
verification_type: '',
long_stay_nights: undefined,
is_package: false,
package_id: undefined,
priority: 100,
extra_data: undefined,
});
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
active: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Active',
border: 'border-emerald-200'
},
inactive: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: 'Inactive',
border: 'border-slate-200'
},
scheduled: {
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
text: 'text-blue-800',
label: 'Scheduled',
border: 'border-blue-200'
},
expired: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: 'Expired',
border: 'border-rose-200'
},
};
const badge = badges[status] || badges.active;
return (
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
{badge.label}
</span>
);
};
const getPlanTypeBadge = (type: string) => {
const types: Record<string, { bg: string; text: string; label: string }> = {
BAR: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'BAR' },
non_refundable: { bg: 'bg-red-100', text: 'text-red-800', label: 'Non-Refundable' },
advance_purchase: { bg: 'bg-purple-100', text: 'text-purple-800', label: 'Advance Purchase' },
corporate: { bg: 'bg-indigo-100', text: 'text-indigo-800', label: 'Corporate' },
government: { bg: 'bg-green-100', text: 'text-green-800', label: 'Government' },
military: { bg: 'bg-amber-100', text: 'text-amber-800', label: 'Military' },
long_stay: { bg: 'bg-teal-100', text: 'text-teal-800', label: 'Long Stay' },
package: { bg: 'bg-pink-100', text: 'text-pink-800', label: 'Package' },
};
const typeInfo = types[type] || types.BAR;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${typeInfo.bg} ${typeInfo.text}`}>
{typeInfo.label}
</span>
);
};
if (loading && ratePlans.length === 0) {
return <Loading />;
}
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">
<div className="flex justify-between items-center animate-fade-in">
<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">
Rate Plan Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage pricing plans and rate structures</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
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" />
Add Rate Plan
</button>
</div>
{/* Filters */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search by name or code..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.plan_type}
onChange={(e) => setFilters({ ...filters, plan_type: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Types</option>
<option value="BAR">BAR</option>
<option value="non_refundable">Non-Refundable</option>
<option value="advance_purchase">Advance Purchase</option>
<option value="corporate">Corporate</option>
<option value="government">Government</option>
<option value="military">Military</option>
<option value="long_stay">Long Stay</option>
<option value="package">Package</option>
</select>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="scheduled">Scheduled</option>
<option value="expired">Expired</option>
</select>
<select
value={filters.room_type_id}
onChange={(e) => setFilters({ ...filters, room_type_id: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Room Types</option>
{roomTypes.map((rt) => (
<option key={rt.id} value={rt.id}>{rt.name}</option>
))}
</select>
</div>
</div>
{/* Rate Plans Table */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Code</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Name</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Type</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Pricing</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Room Type</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Status</th>
<th className="px-8 py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{ratePlans.map((plan) => (
<tr
key={plan.id}
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
>
<td className="px-8 py-5 whitespace-nowrap">
<div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg">
<Tag className="w-4 h-4 text-amber-600" />
</div>
<span className="text-sm font-mono font-bold bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">{plan.code}</span>
</div>
</td>
<td className="px-8 py-5">
<div className="text-sm font-semibold text-slate-900">{plan.name}</div>
<div className="text-xs text-slate-500 mt-0.5">{plan.description}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
{getPlanTypeBadge(plan.plan_type)}
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm">
{plan.discount_percentage ? (
<span className="font-bold text-emerald-600">{plan.discount_percentage}% off</span>
) : plan.fixed_discount ? (
<span className="font-bold text-emerald-600">{formatCurrency(plan.fixed_discount)} off</span>
) : (
<span className="text-slate-600">{plan.base_price_modifier}x base</span>
)}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm text-slate-600">{plan.room_type_name || 'All Types'}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
{getStatusBadge(plan.status)}
</td>
<td className="px-8 py-5 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(plan)}
className="p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all duration-200 shadow-sm hover:shadow-md border border-blue-200 hover:border-blue-300"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(plan.id)}
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
title="Delete"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{ratePlans.length === 0 && (
<div className="text-center py-12">
<Tag className="w-16 h-16 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500">No rate plans found</p>
</div>
)}
</div>
{totalPages > 1 && (
<div className="px-8 py-5 border-t border-slate-200">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
)}
</div>
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 px-8 py-6 flex justify-between items-center rounded-t-2xl">
<h2 className="text-2xl font-bold text-white">
{editingRatePlan ? 'Edit Rate Plan' : 'Create Rate Plan'}
</h2>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="p-2 rounded-lg text-white hover:bg-white/20 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Name *</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Code *</label>
<input
type="text"
required
disabled={!!editingRatePlan}
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all disabled:bg-slate-100"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-semibold text-slate-700 mb-2">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Plan Type *</label>
<select
required
value={formData.plan_type}
onChange={(e) => setFormData({ ...formData, plan_type: e.target.value as RatePlanType })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
>
<option value="BAR">BAR (Best Available Rate)</option>
<option value="non_refundable">Non-Refundable</option>
<option value="advance_purchase">Advance Purchase</option>
<option value="corporate">Corporate</option>
<option value="government">Government</option>
<option value="military">Military</option>
<option value="long_stay">Long Stay</option>
<option value="package">Package</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Status *</label>
<select
required
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as RatePlanStatus })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="scheduled">Scheduled</option>
<option value="expired">Expired</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Room Type</label>
<select
value={formData.room_type_id || ''}
onChange={(e) => setFormData({ ...formData, room_type_id: e.target.value ? parseInt(e.target.value) : undefined })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
>
<option value="">All Room Types</option>
{roomTypes.map((rt) => (
<option key={rt.id} value={rt.id}>{rt.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Priority</label>
<input
type="number"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 100 })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
<p className="text-xs text-slate-500 mt-1">Lower number = higher priority</p>
</div>
</div>
<div className="border-t border-slate-200 pt-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Pricing</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Base Price Modifier</label>
<input
type="number"
step="0.01"
min="0"
value={formData.base_price_modifier}
onChange={(e) => setFormData({ ...formData, base_price_modifier: parseFloat(e.target.value) || 1.0 })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
<p className="text-xs text-slate-500 mt-1">1.0 = 100%, 0.9 = 90%</p>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Discount %</label>
<input
type="number"
step="0.01"
min="0"
max="100"
value={formData.discount_percentage || ''}
onChange={(e) => setFormData({ ...formData, discount_percentage: e.target.value ? parseFloat(e.target.value) : undefined })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Fixed Discount</label>
<input
type="number"
step="0.01"
min="0"
value={formData.fixed_discount || ''}
onChange={(e) => setFormData({ ...formData, fixed_discount: e.target.value ? parseFloat(e.target.value) : undefined })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
</div>
</div>
<div className="border-t border-slate-200 pt-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Restrictions</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Min Nights</label>
<input
type="number"
min="1"
value={formData.min_nights || ''}
onChange={(e) => setFormData({ ...formData, min_nights: e.target.value ? parseInt(e.target.value) : undefined })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Max Nights</label>
<input
type="number"
min="1"
value={formData.max_nights || ''}
onChange={(e) => setFormData({ ...formData, max_nights: e.target.value ? parseInt(e.target.value) : undefined })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Advance Days Required</label>
<input
type="number"
min="0"
value={formData.advance_days_required || ''}
onChange={(e) => setFormData({ ...formData, advance_days_required: e.target.value ? parseInt(e.target.value) : undefined })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Cancellation Hours</label>
<input
type="number"
min="0"
value={formData.cancellation_hours || ''}
onChange={(e) => setFormData({ ...formData, cancellation_hours: e.target.value ? parseInt(e.target.value) : undefined })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Valid From</label>
<input
type="date"
value={formData.valid_from}
onChange={(e) => setFormData({ ...formData, valid_from: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Valid To</label>
<input
type="date"
value={formData.valid_to}
onChange={(e) => setFormData({ ...formData, valid_to: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
</div>
</div>
<div className="border-t border-slate-200 pt-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Options</h3>
<div className="space-y-4">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.is_refundable}
onChange={(e) => setFormData({ ...formData, is_refundable: e.target.checked })}
className="w-5 h-5 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
/>
<span className="text-sm font-medium text-slate-700">Refundable</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.requires_deposit}
onChange={(e) => setFormData({ ...formData, requires_deposit: e.target.checked })}
className="w-5 h-5 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
/>
<span className="text-sm font-medium text-slate-700">Requires Deposit</span>
</label>
{formData.requires_deposit && (
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Deposit Percentage</label>
<input
type="number"
step="0.01"
min="0"
max="100"
value={formData.deposit_percentage || ''}
onChange={(e) => setFormData({ ...formData, deposit_percentage: e.target.value ? parseFloat(e.target.value) : undefined })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
)}
{(formData.plan_type === 'corporate' || formData.plan_type === 'government' || formData.plan_type === 'military') && (
<>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Corporate/Code</label>
<input
type="text"
value={formData.corporate_code || ''}
onChange={(e) => setFormData({ ...formData, corporate_code: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.requires_verification}
onChange={(e) => setFormData({ ...formData, requires_verification: e.target.checked })}
className="w-5 h-5 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
/>
<span className="text-sm font-medium text-slate-700">Requires Verification</span>
</label>
{formData.requires_verification && (
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Verification Type</label>
<select
value={formData.verification_type || ''}
onChange={(e) => setFormData({ ...formData, verification_type: e.target.value || undefined })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
>
<option value="">Select Type</option>
<option value="corporate_id">Corporate ID</option>
<option value="government_id">Government ID</option>
<option value="military_id">Military ID</option>
</select>
</div>
)}
</>
)}
{formData.plan_type === 'long_stay' && (
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Long Stay Nights</label>
<input
type="number"
min="1"
value={formData.long_stay_nights || ''}
onChange={(e) => setFormData({ ...formData, long_stay_nights: e.target.value ? parseInt(e.target.value) : undefined })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
/>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-4 pt-6 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all"
>
Cancel
</button>
<button
type="submit"
className="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 shadow-lg hover:shadow-xl"
>
{editingRatePlan ? 'Update' : 'Create'} Rate Plan
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default RatePlanManagementPage;

File diff suppressed because it is too large Load Diff

View File

@@ -564,25 +564,25 @@ const RoomManagementPage: React.FC = () => {
}
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">
<div className="space-y-4 sm:space-y-6 md:space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-3 sm:-m-4 md:-m-6 p-4 sm:p-6 md:p-8">
{}
<div className="flex justify-between items-center animate-fade-in">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4 animate-fade-in">
<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">
<div className="flex items-center gap-2 sm:gap-3 mb-2">
<div className="h-1 w-12 sm:w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-xl sm:text-2xl md:text-2xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Room Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage hotel room information</p>
<p className="text-slate-600 mt-2 sm:mt-3 text-xs sm:text-sm md:text-sm font-light">Manage hotel room information</p>
</div>
<div className="flex gap-3">
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 w-full sm:w-auto">
{selectedRooms.length > 0 && (
<button
onClick={handleBulkDelete}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-rose-500 to-rose-600 text-white rounded-xl font-semibold hover:from-rose-600 hover:to-rose-700 transition-all duration-200 shadow-lg hover:shadow-xl"
className="flex items-center justify-center gap-2 px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-rose-500 to-rose-600 text-white rounded-lg sm:rounded-xl font-semibold hover:from-rose-600 hover:to-rose-700 transition-all duration-200 shadow-lg hover:shadow-xl text-xs sm:text-sm w-full sm:w-auto"
>
<Trash2 className="w-5 h-5" />
<Trash2 className="w-4 h-4 sm:w-5 sm:h-5" />
Delete Selected ({selectedRooms.length})
</button>
)}
@@ -591,16 +591,16 @@ const RoomManagementPage: React.FC = () => {
resetForm();
setShowModal(true);
}}
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"
className="flex items-center justify-center gap-2 px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg sm:rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl text-xs sm:text-sm w-full sm:w-auto"
>
<Plus className="w-5 h-5" />
<Plus className="w-4 h-4 sm:w-5 sm:h-5" />
Add Room
</button>
</div>
</div>
{}
<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.1s' }}>
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />

File diff suppressed because it is too large Load Diff

View File

@@ -115,32 +115,32 @@ const ServiceManagementPage: React.FC = () => {
}
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">
<div className="space-y-4 sm:space-y-6 md:space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-3 sm:-m-4 md:-m-6 p-4 sm:p-6 md:p-8">
{}
<div className="flex justify-between items-center animate-fade-in">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4 animate-fade-in">
<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">
<div className="flex items-center gap-2 sm:gap-3 mb-2">
<div className="h-1 w-12 sm:w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-xl sm:text-2xl md:text-2xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Service Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage hotel services</p>
<p className="text-slate-600 mt-2 sm:mt-3 text-xs sm:text-sm md:text-sm font-light">Manage hotel services</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
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"
className="flex items-center justify-center gap-2 px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg sm:rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl text-xs sm:text-sm w-full sm:w-auto"
>
<Plus className="w-5 h-5" />
<Plus className="w-4 h-4 sm:w-5 sm:h-5" />
Add Service
</button>
</div>
{}
<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.1s' }}>
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,398 @@
import React, { useState, useEffect } from 'react';
import {
CheckSquare,
Clock,
AlertCircle,
CheckCircle2,
XCircle,
Plus,
Filter,
Search,
Calendar,
User,
Building2,
FileText,
MessageSquare,
TrendingUp,
MoreVertical,
Edit,
Trash2,
Play,
Pause,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Loading, EmptyState } from '../../components/common';
import { useAsync } from '../../hooks/useAsync';
import taskService, { Task, TaskStatistics } from '../../services/api/taskService';
import { formatDate } from '../../utils/format';
import TaskDetailModal from '../../components/tasks/TaskDetailModal';
import CreateTaskModal from '../../components/tasks/CreateTaskModal';
import TaskFilters from '../../components/tasks/TaskFilters';
type TaskStatus = 'pending' | 'assigned' | 'in_progress' | 'completed' | 'cancelled' | 'overdue';
type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
const TaskManagementPage: React.FC = () => {
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [showTaskDetail, setShowTaskDetail] = useState(false);
const [showCreateTask, setShowCreateTask] = useState(false);
const [filters, setFilters] = useState({
status: '' as string,
priority: '' as string,
task_type: '',
assigned_to: '',
search: '',
});
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
const { data: tasks, loading: tasksLoading, execute: fetchTasks } = useAsync<Task[]>(
() => taskService.getTasks({
status: filters.status || undefined,
priority: filters.priority || undefined,
task_type: filters.task_type || undefined,
assigned_to: filters.assigned_to ? parseInt(filters.assigned_to) : undefined,
skip: (currentPage - 1) * itemsPerPage,
limit: itemsPerPage,
}).then(r => {
// Handle response structure: { status: 'success', data: [...] }
// apiClient returns axios response, so r.data is the response body
const responseData = r.data;
const tasksArray = responseData?.data || responseData || [];
return Array.isArray(tasksArray) ? tasksArray : [];
}).catch(error => {
console.error('Error fetching tasks:', error);
return [];
}),
{ immediate: true }
);
const { data: statistics, loading: statsLoading, execute: fetchStatistics } = useAsync<TaskStatistics>(
() => taskService.getTaskStatistics().then(r => r.data),
{ immediate: true }
);
useEffect(() => {
fetchTasks();
}, [filters, currentPage]);
const handleTaskClick = async (task: Task) => {
try {
const response = await taskService.getTask(task.id);
setSelectedTask(response.data.data);
setShowTaskDetail(true);
} catch (error: any) {
toast.error(error.message || 'Failed to load task details');
}
};
const handleTaskComplete = async (taskId: number) => {
try {
await taskService.completeTask(taskId);
toast.success('Task completed successfully');
fetchTasks();
fetchStatistics();
if (selectedTask?.id === taskId) {
setShowTaskDetail(false);
setSelectedTask(null);
}
} catch (error: any) {
toast.error(error.message || 'Failed to complete task');
}
};
const handleTaskStart = async (taskId: number) => {
try {
await taskService.startTask(taskId);
toast.success('Task started');
fetchTasks();
if (selectedTask?.id === taskId) {
const response = await taskService.getTask(taskId);
setSelectedTask(response.data.data);
}
} catch (error: any) {
toast.error(error.message || 'Failed to start task');
}
};
const getStatusIcon = (status: TaskStatus) => {
switch (status) {
case 'completed':
return <CheckCircle2 className="w-5 h-5 text-green-500" />;
case 'in_progress':
return <Play className="w-5 h-5 text-blue-500" />;
case 'overdue':
return <AlertCircle className="w-5 h-5 text-red-500" />;
case 'cancelled':
return <XCircle className="w-5 h-5 text-gray-400" />;
default:
return <Clock className="w-5 h-5 text-amber-500" />;
}
};
const getStatusBadge = (status: TaskStatus) => {
const baseClasses = 'px-3 py-1 rounded-full text-xs font-semibold';
switch (status) {
case 'completed':
return `${baseClasses} bg-green-100 text-green-800 border border-green-200`;
case 'in_progress':
return `${baseClasses} bg-blue-100 text-blue-800 border border-blue-200`;
case 'overdue':
return `${baseClasses} bg-red-100 text-red-800 border border-red-200`;
case 'cancelled':
return `${baseClasses} bg-gray-100 text-gray-800 border border-gray-200`;
case 'assigned':
return `${baseClasses} bg-purple-100 text-purple-800 border border-purple-200`;
default:
return `${baseClasses} bg-amber-100 text-amber-800 border border-amber-200`;
}
};
const getPriorityBadge = (priority: TaskPriority) => {
const baseClasses = 'px-2 py-1 rounded text-xs font-semibold';
switch (priority) {
case 'urgent':
return `${baseClasses} bg-red-100 text-red-800 border border-red-200`;
case 'high':
return `${baseClasses} bg-orange-100 text-orange-800 border border-orange-200`;
case 'medium':
return `${baseClasses} bg-yellow-100 text-yellow-800 border border-yellow-200`;
default:
return `${baseClasses} bg-gray-100 text-gray-800 border border-gray-200`;
}
};
const isOverdue = (dueDate?: string) => {
if (!dueDate) return false;
return new Date(dueDate) < new Date() && !selectedTask?.completed_at;
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Header */}
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Task Management</h1>
<p className="text-gray-600">Manage and track all tasks and workflows</p>
</div>
<button
onClick={() => setShowCreateTask(true)}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Create Task
</button>
</div>
</div>
{/* Statistics */}
{statistics && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Total Tasks</p>
<p className="text-2xl font-bold text-gray-900">{statistics.total}</p>
</div>
<CheckSquare className="w-8 h-8 text-indigo-500" />
</div>
</div>
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">In Progress</p>
<p className="text-2xl font-bold text-blue-600">{statistics.in_progress}</p>
</div>
<Play className="w-8 h-8 text-blue-500" />
</div>
</div>
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Completed</p>
<p className="text-2xl font-bold text-green-600">{statistics.completed}</p>
</div>
<CheckCircle2 className="w-8 h-8 text-green-500" />
</div>
</div>
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Overdue</p>
<p className="text-2xl font-bold text-red-600">{statistics.overdue}</p>
</div>
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
</div>
</div>
)}
{/* Filters */}
<TaskFilters filters={filters} onFiltersChange={setFilters} />
{/* Tasks List */}
{tasksLoading ? (
<Loading fullScreen text="Loading tasks..." />
) : !tasks || tasks.length === 0 ? (
<div className="bg-white rounded-xl shadow-lg p-8 border border-gray-100">
<EmptyState
title="No tasks found"
description="Create a new task or adjust your filters"
action={{
label: 'Create Task',
onClick: () => setShowCreateTask(true),
}}
/>
</div>
) : (
<div className="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-gray-50 to-gray-100">
<tr>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Task</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Status</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Priority</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Assigned To</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Due Date</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{(tasks || []).map((task) => (
<tr
key={task.id}
className="hover:bg-gray-50 transition-colors cursor-pointer"
onClick={() => handleTaskClick(task)}
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{getStatusIcon(task.status)}
<div>
<p className="text-sm font-semibold text-gray-900">{task.title}</p>
{task.description && (
<p className="text-xs text-gray-500 line-clamp-1">{task.description}</p>
)}
</div>
</div>
</td>
<td className="px-6 py-4">
<span className={getStatusBadge(task.status)}>{task.status.replace('_', ' ')}</span>
</td>
<td className="px-6 py-4">
<span className={getPriorityBadge(task.priority)}>{task.priority}</span>
</td>
<td className="px-6 py-4">
{task.assigned_to_name ? (
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-700">{task.assigned_to_name}</span>
</div>
) : (
<span className="text-sm text-gray-400">Unassigned</span>
)}
</td>
<td className="px-6 py-4">
{task.due_date ? (
<div className="flex items-center gap-2">
<Calendar className={`w-4 h-4 ${isOverdue(task.due_date) ? 'text-red-500' : 'text-gray-400'}`} />
<span className={`text-sm ${isOverdue(task.due_date) ? 'text-red-600 font-semibold' : 'text-gray-700'}`}>
{formatDate(new Date(task.due_date), 'short')}
</span>
</div>
) : (
<span className="text-sm text-gray-400">No due date</span>
)}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
{task.status === 'assigned' && (
<button
onClick={(e) => {
e.stopPropagation();
handleTaskStart(task.id);
}}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Start Task"
>
<Play className="w-4 h-4" />
</button>
)}
{task.status === 'in_progress' && (
<button
onClick={(e) => {
e.stopPropagation();
handleTaskComplete(task.id);
}}
className="p-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
title="Complete Task"
>
<CheckCircle2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Pagination */}
{tasks && tasks.length > 0 && (
<div className="flex items-center justify-between">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-4 py-2 bg-white border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-600">Page {currentPage}</span>
<button
onClick={() => setCurrentPage(p => p + 1)}
disabled={tasks.length < itemsPerPage}
className="px-4 py-2 bg-white border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
</div>
{/* Task Detail Modal */}
{showTaskDetail && selectedTask && (
<TaskDetailModal
task={selectedTask}
onClose={() => {
setShowTaskDetail(false);
setSelectedTask(null);
}}
onUpdate={() => {
fetchTasks();
fetchStatistics();
}}
/>
)}
{/* Create Task Modal */}
{showCreateTask && (
<CreateTaskModal
onClose={() => setShowCreateTask(false)}
onSuccess={() => {
setShowCreateTask(false);
fetchTasks();
fetchStatistics();
}}
/>
)}
</div>
);
};
export default TaskManagementPage;

View File

@@ -189,26 +189,26 @@ const UserManagementPage: React.FC = () => {
}
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">
<div className="space-y-4 sm:space-y-6 md:space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-3 sm:-m-4 md:-m-6 p-4 sm:p-6 md:p-8">
{}
<div className="flex justify-between items-center animate-fade-in">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4 animate-fade-in">
<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">
<div className="flex items-center gap-2 sm:gap-3 mb-2">
<div className="h-1 w-12 sm:w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-xl sm:text-2xl md:text-2xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
User Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage accounts and permissions</p>
<p className="text-slate-600 mt-2 sm:mt-3 text-xs sm:text-sm md:text-sm font-light">Manage accounts and permissions</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
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"
className="flex items-center gap-2 px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg sm:rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl text-xs sm:text-sm w-full sm:w-auto"
>
<Plus className="w-5 h-5" />
<Plus className="w-4 h-4 sm:w-5 sm:h-5" />
Add User
</button>
</div>
@@ -339,7 +339,7 @@ const UserManagementPage: React.FC = () => {
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-6 py-5 border-b border-slate-700">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-amber-100">
<h2 className="text-lg sm:text-xl md:text-xl font-bold text-amber-100">
{editingUser ? 'Update User' : 'Add New User'}
</h2>
<p className="text-amber-200/80 text-sm font-light mt-1">

View File

@@ -0,0 +1,215 @@
import React, { useState } from 'react';
import {
Plus,
Edit,
Trash2,
Eye,
CheckCircle2,
Clock,
XCircle,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Loading, EmptyState } from '../../components/common';
import { useAsync } from '../../hooks/useAsync';
import workflowService, { Workflow } from '../../services/api/workflowService';
import WorkflowBuilder from '../../components/workflows/WorkflowBuilder';
import WorkflowDetailModal from '../../components/workflows/WorkflowDetailModal';
const WorkflowManagementPage: React.FC = () => {
const [showBuilder, setShowBuilder] = useState(false);
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
const [selectedWorkflow, setSelectedWorkflow] = useState<Workflow | null>(null);
const [showDetail, setShowDetail] = useState(false);
const { data: workflows, loading, execute: fetchWorkflows } = useAsync<Workflow[]>(
() => workflowService.getWorkflows().then(r => r.data.data || []),
{ immediate: true }
);
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this workflow?')) return;
try {
await workflowService.deleteWorkflow(id);
toast.success('Workflow deleted successfully');
fetchWorkflows();
} catch (error: any) {
toast.error(error.message || 'Failed to delete workflow');
}
};
const handleEdit = (workflow: Workflow) => {
setEditingWorkflow(workflow);
setShowBuilder(true);
};
const handleView = async (workflow: Workflow) => {
try {
const response = await workflowService.getWorkflow(workflow.id);
setSelectedWorkflow(response.data.data);
setShowDetail(true);
} catch (error: any) {
toast.error(error.message || 'Failed to load workflow details');
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'active':
return <CheckCircle2 className="w-5 h-5 text-green-500" />;
case 'inactive':
return <Clock className="w-5 h-5 text-amber-500" />;
default:
return <XCircle className="w-5 h-5 text-gray-400" />;
}
};
const getTypeColor = (type: string) => {
const colors: Record<string, string> = {
pre_arrival: 'bg-blue-100 text-blue-800 border-blue-200',
room_preparation: 'bg-green-100 text-green-800 border-green-200',
maintenance: 'bg-orange-100 text-orange-800 border-orange-200',
guest_communication: 'bg-purple-100 text-purple-800 border-purple-200',
follow_up: 'bg-pink-100 text-pink-800 border-pink-200',
custom: 'bg-gray-100 text-gray-800 border-gray-200',
};
return colors[type] || colors.custom;
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Header */}
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Workflow Management</h1>
<p className="text-gray-600">Create and manage automated workflows</p>
</div>
<button
onClick={() => {
setEditingWorkflow(null);
setShowBuilder(true);
}}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Create Workflow
</button>
</div>
</div>
{/* Workflows List */}
{loading ? (
<Loading fullScreen text="Loading workflows..." />
) : !workflows || !Array.isArray(workflows) || workflows.length === 0 ? (
<div className="bg-white rounded-xl shadow-lg p-8 border border-gray-100">
<EmptyState
title="No workflows found"
description="Create your first workflow to automate tasks"
action={{
label: 'Create Workflow',
onClick: () => setShowBuilder(true),
}}
/>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{workflows.map((workflow) => (
<div
key={workflow.id}
className="bg-white rounded-xl shadow-lg p-6 border border-gray-100 hover:shadow-xl transition-shadow"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
{getStatusIcon(workflow.status)}
<div>
<h3 className="text-lg font-semibold text-gray-900">{workflow.name}</h3>
<span className={`px-2 py-1 rounded text-xs font-semibold border ${getTypeColor(workflow.workflow_type)}`}>
{workflow.workflow_type.replace('_', ' ')}
</span>
</div>
</div>
</div>
{workflow.description && (
<p className="text-sm text-gray-600 mb-4 line-clamp-2">{workflow.description}</p>
)}
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">Trigger:</span>
<span className="font-medium text-gray-700">{workflow.trigger.replace('_', ' ')}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">Steps:</span>
<span className="font-medium text-gray-700">{workflow.steps.length}</span>
</div>
{workflow.sla_hours && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">SLA:</span>
<span className="font-medium text-gray-700">{workflow.sla_hours} hours</span>
</div>
)}
</div>
<div className="flex items-center gap-2 pt-4 border-t border-gray-200">
<button
onClick={() => handleView(workflow)}
className="flex-1 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center justify-center gap-2 text-sm"
>
<Eye className="w-4 h-4" />
View
</button>
<button
onClick={() => handleEdit(workflow)}
className="flex-1 px-3 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors flex items-center justify-center gap-2 text-sm"
>
<Edit className="w-4 h-4" />
Edit
</button>
<button
onClick={() => handleDelete(workflow.id)}
className="px-3 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Workflow Builder Modal */}
{showBuilder && (
<WorkflowBuilder
workflow={editingWorkflow}
onClose={() => {
setShowBuilder(false);
setEditingWorkflow(null);
}}
onSuccess={() => {
setShowBuilder(false);
setEditingWorkflow(null);
fetchWorkflows();
}}
/>
)}
{/* Workflow Detail Modal */}
{showDetail && selectedWorkflow && (
<WorkflowDetailModal
workflow={selectedWorkflow}
onClose={() => {
setShowDetail(false);
setSelectedWorkflow(null);
}}
/>
)}
</div>
);
};
export default WorkflowManagementPage;

View File

@@ -1,15 +1,27 @@
export { default as DashboardPage } from './DashboardPage';
export { default as RoomManagementPage } from './RoomManagementPage';
/**
* Admin Pages
*
* All pages accessible only to administrators
*/
export { default as AdminDashboardPage } from './DashboardPage';
export { default as UserManagementPage } from './UserManagementPage';
export { default as GuestProfilePage } from './GuestProfilePage';
export { default as BookingManagementPage } from './BookingManagementPage';
export { default as GroupBookingManagementPage } from './GroupBookingManagementPage';
export { default as BusinessDashboardPage } from './BusinessDashboardPage';
export { default as ReceptionDashboardPage } from './ReceptionDashboardPage';
export { default as AdvancedRoomManagementPage } from './AdvancedRoomManagementPage';
export { default as PageContentDashboardPage } from './PageContentDashboard';
export { default as AnalyticsDashboardPage } from './AnalyticsDashboardPage';
export { default as TaskManagementPage } from './TaskManagementPage';
export { default as WorkflowManagementPage } from './WorkflowManagementPage';
export { default as NotificationManagementPage } from './NotificationManagementPage';
export { default as SettingsPage } from './SettingsPage';
export { default as InvoiceManagementPage } from './InvoiceManagementPage';
export { default as PaymentManagementPage } from './PaymentManagementPage';
export { default as ServiceManagementPage } from './ServiceManagementPage';
export { default as ReviewManagementPage } from './ReviewManagementPage';
export { default as PromotionManagementPage } from './PromotionManagementPage';
export { default as CheckInPage } from './CheckInPage';
export { default as CheckOutPage } from './CheckOutPage';
export { default as AuditLogsPage } from './AuditLogsPage';
export { default as CurrencySettingsPage } from './CurrencySettingsPage';
export { default as CookieSettingsPage } from './CookieSettingsPage';
export { default as StripeSettingsPage } from './StripeSettingsPage';
export { default as LoyaltyManagementPage } from './LoyaltyManagementPage';
export { default as BookingManagementPage } from './BookingManagementPage';
export { default as RatePlanManagementPage } from './RatePlanManagementPage';
export { default as PackageManagementPage } from './PackageManagementPage';
export { default as SecurityManagementPage } from './SecurityManagementPage';
export { default as EmailCampaignManagementPage } from './EmailCampaignManagementPage';

View File

@@ -0,0 +1,163 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { confirmBoricaPayment } from '../../services/api/paymentService';
import { toast } from 'react-toastify';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
const BoricaReturnPage: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const bookingId = searchParams.get('bookingId');
useEffect(() => {
const confirmPayment = async () => {
if (!bookingId) {
setError('Missing booking information');
setLoading(false);
return;
}
try {
setLoading(true);
// Extract all parameters from URL (Borica sends POST data, but we'll handle GET params)
const responseData: Record<string, string> = {};
searchParams.forEach((value, key) => {
responseData[key] = value;
});
// If no params, try to get from POST data (would need server-side handling)
// For now, we'll use the bookingId to fetch payment status
if (Object.keys(responseData).length === 0 || !responseData.ORDER) {
// Try to confirm with booking ID only
// In a real implementation, Borica would POST the data to a server endpoint
setError('Payment response data not found. Please check your payment status.');
setLoading(false);
return;
}
const response = await confirmBoricaPayment(responseData);
if (response.success) {
setSuccess(true);
toast.success('Payment confirmed successfully!');
setTimeout(() => {
navigate(`/bookings/${bookingId}`);
}, 2000);
} else {
setError(response.message || 'Payment confirmation failed');
toast.error(response.message || 'Payment confirmation failed');
}
} catch (err: any) {
const errorMessage = err.response?.data?.message || err.message || 'Failed to confirm payment';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
confirmPayment();
}, [bookingId, searchParams, navigate]);
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
border border-gray-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl mx-auto text-center
backdrop-blur-xl shadow-2xl shadow-black/20">
<Loader2 className="w-12 h-12 sm:w-16 sm:h-16 animate-spin text-[#d4af37] mx-auto mb-4" />
<h1 className="text-xl sm:text-2xl font-serif font-semibold text-white mb-2">
Processing Payment...
</h1>
<p className="text-gray-400 text-sm sm:text-base">
Please wait while we confirm your payment.
</p>
</div>
</div>
);
}
if (success) {
return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
border border-gray-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl mx-auto text-center
backdrop-blur-xl shadow-2xl shadow-black/20">
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-green-500/20 to-green-600/20
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
border border-green-500/30 shadow-lg shadow-green-500/20">
<CheckCircle className="w-10 h-10 sm:w-12 sm:h-12 text-green-400" />
</div>
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-green-300 mb-3 tracking-wide">
Payment Successful!
</h1>
<p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide px-2">
Your payment has been confirmed. Redirecting to booking details...
</p>
<button
onClick={() => navigate(`/bookings/${bookingId}`)}
className="bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] px-6 py-2 sm:px-8 sm:py-3 rounded-sm
hover:from-[#f5d76e] hover:to-[#d4af37]
transition-all duration-300 font-medium tracking-wide
shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base w-full sm:w-auto"
>
View Booking
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
border border-red-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl mx-auto text-center
backdrop-blur-xl shadow-2xl shadow-black/20">
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-red-500/20 to-red-600/20
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
border border-red-500/30 shadow-lg shadow-red-500/20">
<XCircle className="w-10 h-10 sm:w-12 sm:h-12 text-red-400" />
</div>
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-red-300 mb-3 tracking-wide">
Payment Failed
</h1>
<p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide px-2">
{error || 'There was an issue processing your payment. Please try again or contact support.'}
</p>
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center">
<button
onClick={() => navigate(`/bookings/${bookingId}`)}
className="bg-gray-700/50 text-gray-300 px-6 py-2 sm:px-8 sm:py-3 rounded-sm
hover:bg-gray-700 transition-all duration-300 font-medium tracking-wide
text-sm sm:text-base w-full sm:w-auto"
>
View Booking
</button>
<button
onClick={() => navigate('/')}
className="bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] px-6 py-2 sm:px-8 sm:py-3 rounded-sm
hover:from-[#f5d76e] hover:to-[#d4af37]
transition-all duration-300 font-medium tracking-wide
shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base w-full sm:w-auto"
>
Go Home
</button>
</div>
</div>
</div>
);
};
export default BoricaReturnPage;

View File

@@ -0,0 +1,179 @@
import React, { useState, useEffect } from 'react';
import { Users, Calendar, Building2, DollarSign, CheckCircle, ArrowRight } from 'lucide-react';
import { groupBookingService, GroupBooking } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { formatDate } from '../../utils/format';
import CreateGroupBookingModal from '../../components/shared/CreateGroupBookingModal';
import { useNavigate } from 'react-router-dom';
const GroupBookingPage: React.FC = () => {
const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency();
const [groupBookings, setGroupBookings] = useState<GroupBooking[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
useEffect(() => {
fetchGroupBookings();
}, []);
const fetchGroupBookings = async () => {
try {
setLoading(true);
const response = await groupBookingService.getMyGroupBookings();
setGroupBookings(response.data.group_bookings);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load group bookings');
} finally {
setLoading(false);
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
draft: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Draft' },
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Pending' },
confirmed: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Confirmed' },
partially_confirmed: { bg: 'bg-purple-100', text: 'text-purple-800', label: 'Partially Confirmed' },
checked_in: { bg: 'bg-green-100', text: 'text-green-800', label: 'Checked In' },
checked_out: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Checked Out' },
cancelled: { bg: 'bg-red-100', text: 'text-red-800', label: 'Cancelled' },
};
const badge = badges[status] || badges.draft;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
{badge.label}
</span>
);
};
if (loading) {
return <Loading />;
}
return (
<div className="container mx-auto px-4 py-8 max-w-7xl">
<div className="mb-8">
<div className="flex justify-between items-start mb-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Group Bookings</h1>
<p className="text-gray-600">
Manage your group bookings and coordinate room blocks for your group
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 shadow-lg"
>
<Users className="w-5 h-5" />
Create Group Booking
</button>
</div>
</div>
{groupBookings.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<Users className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-900 mb-2">No Group Bookings Yet</h3>
<p className="text-gray-600 mb-6">
Create your first group booking to block multiple rooms for your group
</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Create Group Booking
</button>
</div>
) : (
<div className="grid gap-6">
{groupBookings.map((booking) => (
<div
key={booking.id}
className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => navigate(`/admin/group-bookings?view=${booking.id}`)}
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-semibold text-gray-900 mb-1">
{booking.group_name || booking.group_booking_number}
</h3>
<p className="text-sm text-gray-500">Booking #{booking.group_booking_number}</p>
{booking.group_type && (
<span className="inline-block mt-2 px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-xs">
{booking.group_type}
</span>
)}
</div>
{getStatusBadge(booking.status)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="flex items-center gap-3">
<Calendar className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm text-gray-500">Check-in</p>
<p className="font-medium text-gray-900">
{formatDate(booking.check_in_date, 'short')}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Calendar className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm text-gray-500">Check-out</p>
<p className="font-medium text-gray-900">
{formatDate(booking.check_out_date, 'short')}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Building2 className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm text-gray-500">Rooms / Guests</p>
<p className="font-medium text-gray-900">
{booking.total_rooms} rooms {booking.total_guests} guests
</p>
</div>
</div>
</div>
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
<div>
<p className="text-sm text-gray-500">Total Price</p>
<p className="text-2xl font-bold text-gray-900">
{formatCurrency(booking.total_price)}
</p>
{booking.discount_amount > 0 && (
<p className="text-sm text-green-600">
Saved {formatCurrency(booking.discount_amount)} with group discount
</p>
)}
</div>
<div className="flex items-center gap-2 text-blue-600">
<span className="text-sm font-medium">View Details</span>
<ArrowRight className="w-4 h-4" />
</div>
</div>
</div>
))}
</div>
)}
{/* Create Group Booking Modal */}
<CreateGroupBookingModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
setShowCreateModal(false);
fetchGroupBookings();
}}
/>
</div>
);
};
export default GroupBookingPage;

View File

@@ -0,0 +1,24 @@
/**
* Customer Pages
*
* All pages accessible to customers
*/
export { default as CustomerDashboardPage } from './DashboardPage';
export { default as RoomListPage } from './RoomListPage';
export { default as RoomDetailPage } from './RoomDetailPage';
export { default as SearchResultsPage } from './SearchResultsPage';
export { default as FavoritesPage } from './FavoritesPage';
export { default as MyBookingsPage } from './MyBookingsPage';
export { default as BookingSuccessPage } from './BookingSuccessPage';
export { default as BookingDetailPage } from './BookingDetailPage';
export { default as FullPaymentPage } from './FullPaymentPage';
export { default as PaymentConfirmationPage } from './PaymentConfirmationPage';
export { default as PaymentResultPage } from './PaymentResultPage';
export { default as PayPalReturnPage } from './PayPalReturnPage';
export { default as PayPalCancelPage } from './PayPalCancelPage';
export { default as InvoicePage } from './InvoicePage';
export { default as ProfilePage } from './ProfilePage';
export { default as LoyaltyPage } from './LoyaltyPage';
export { default as GroupBookingPage } from './GroupBookingPage';

View File

@@ -0,0 +1,394 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Hotel,
Wrench,
Sparkles,
ClipboardCheck,
Filter,
RefreshCw,
CheckCircle,
AlertTriangle,
Users,
ChevronDown,
ChevronUp,
Crown,
Calendar,
Clock,
MapPin,
} from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import advancedRoomService, {
RoomStatusBoardItem,
} from '../../services/api/advancedRoomService';
import { roomService } from '../../services/api';
import MaintenanceManagement from '../../components/shared/MaintenanceManagement';
import HousekeepingManagement from '../../components/shared/HousekeepingManagement';
import InspectionManagement from '../../components/shared/InspectionManagement';
type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections';
const AdvancedRoomManagementPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<Tab>('status-board');
const [loading, setLoading] = useState(true);
const [rooms, setRooms] = useState<RoomStatusBoardItem[]>([]);
const [selectedFloor, setSelectedFloor] = useState<number | null>(null);
const [floors, setFloors] = useState<number[]>([]);
const [expandedRooms, setExpandedRooms] = useState<Set<number>>(new Set());
useEffect(() => {
fetchRoomStatusBoard();
fetchFloors();
}, [selectedFloor]);
const fetchRoomStatusBoard = async () => {
try {
setLoading(true);
const response = await advancedRoomService.getRoomStatusBoard(selectedFloor || undefined);
if (response.status === 'success') {
setRooms(response.data.rooms);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to fetch room status board');
} finally {
setLoading(false);
}
};
const fetchFloors = async () => {
try {
const response = await roomService.getRooms({ limit: 1000, page: 1 });
if (response.data?.rooms) {
const uniqueFloors = Array.from(
new Set(response.data.rooms.map((r: any) => r.floor).filter((f: any) => f != null))
).sort((a: any, b: any) => a - b) as number[];
setFloors(uniqueFloors);
}
} catch (error) {
console.error('Failed to fetch floors:', error);
}
};
const toggleRoomExpansion = (roomId: number) => {
const newExpanded = new Set(expandedRooms);
if (newExpanded.has(roomId)) {
newExpanded.delete(roomId);
} else {
newExpanded.add(roomId);
}
setExpandedRooms(newExpanded);
};
// Group rooms by floor
const roomsByFloor = useMemo(() => {
const grouped: Record<number, RoomStatusBoardItem[]> = {};
rooms.forEach(room => {
if (!grouped[room.floor]) {
grouped[room.floor] = [];
}
grouped[room.floor].push(room);
});
return grouped;
}, [rooms]);
const getStatusColor = (status: string) => {
switch (status) {
case 'available':
return {
bg: 'bg-gradient-to-br from-emerald-50 via-green-50 to-emerald-100',
border: 'border-emerald-300/50',
text: 'text-emerald-800',
badge: 'bg-gradient-to-r from-emerald-500 to-green-600 text-white',
shadow: 'shadow-emerald-200/50'
};
case 'occupied':
return {
bg: 'bg-gradient-to-br from-blue-50 via-indigo-50 to-blue-100',
border: 'border-blue-300/50',
text: 'text-blue-800',
badge: 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white',
shadow: 'shadow-blue-200/50'
};
case 'maintenance':
return {
bg: 'bg-gradient-to-br from-red-50 via-rose-50 to-red-100',
border: 'border-red-300/50',
text: 'text-red-800',
badge: 'bg-gradient-to-r from-red-500 to-rose-600 text-white',
shadow: 'shadow-red-200/50'
};
case 'cleaning':
return {
bg: 'bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-100',
border: 'border-amber-300/50',
text: 'text-amber-800',
badge: 'bg-gradient-to-r from-amber-500 to-yellow-600 text-white',
shadow: 'shadow-amber-200/50'
};
default:
return {
bg: 'bg-gradient-to-br from-gray-50 via-slate-50 to-gray-100',
border: 'border-gray-300/50',
text: 'text-gray-800',
badge: 'bg-gradient-to-r from-gray-500 to-slate-600 text-white',
shadow: 'shadow-gray-200/50'
};
}
};
const getStatusIcon = (status: string) => {
const iconClass = "w-5 h-5";
switch (status) {
case 'available':
return <CheckCircle className={iconClass} />;
case 'occupied':
return <Users className={iconClass} />;
case 'maintenance':
return <Wrench className={iconClass} />;
case 'cleaning':
return <Sparkles className={iconClass} />;
default:
return <AlertTriangle className={iconClass} />;
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'available':
return 'Available';
case 'occupied':
return 'Occupied';
case 'maintenance':
return 'Maintenance';
case 'cleaning':
return 'Cleaning';
default:
return 'Unknown';
}
};
if (loading && rooms.length === 0) {
return <Loading />;
}
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Advanced Room Management</h1>
<p className="text-gray-600">Manage room status, maintenance, housekeeping, and inspections</p>
</div>
{/* Tabs */}
<div className="mb-6 border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
{[
{ id: 'status-board' as Tab, label: 'Room Status Board', icon: Hotel },
{ id: 'maintenance' as Tab, label: 'Maintenance', icon: Wrench },
{ id: 'housekeeping' as Tab, label: 'Housekeeping', icon: Sparkles },
{ id: 'inspections' as Tab, label: 'Inspections', icon: ClipboardCheck },
].map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
flex items-center space-x-2 py-4 px-1 border-b-2 font-medium text-sm
${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<Icon className="w-5 h-5" />
<span>{tab.label}</span>
</button>
);
})}
</nav>
</div>
{/* Status Board Tab */}
{activeTab === 'status-board' && (
<div className="space-y-8">
{/* Header Controls */}
<div className="flex items-center justify-between bg-gradient-to-r from-slate-50 to-gray-50 rounded-xl p-4 border border-slate-200/50 shadow-sm">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-3 bg-white rounded-lg px-4 py-2 shadow-sm border border-slate-200">
<Filter className="w-5 h-5 text-slate-600" />
<select
value={selectedFloor || ''}
onChange={(e) => setSelectedFloor(e.target.value ? parseInt(e.target.value) : null)}
className="border-0 bg-transparent text-sm font-medium text-slate-700 focus:outline-none focus:ring-0 cursor-pointer"
>
<option value="">All Floors</option>
{floors.map((floor) => (
<option key={floor} value={floor}>
Floor {floor}
</option>
))}
</select>
</div>
<div className="text-sm text-slate-600">
<span className="font-semibold text-slate-900">{rooms.length}</span> rooms
</div>
</div>
<button
onClick={fetchRoomStatusBoard}
className="flex items-center space-x-2 px-5 py-2.5 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg hover:from-blue-700 hover:to-indigo-700 transition-all duration-200 shadow-md hover:shadow-lg font-medium"
>
<RefreshCw className="w-4 h-4" />
<span>Refresh</span>
</button>
</div>
{/* Floors Display */}
{Object.keys(roomsByFloor).length === 0 ? (
<div className="text-center py-16 bg-gradient-to-br from-slate-50 to-gray-50 rounded-2xl border border-slate-200">
<Hotel className="w-20 h-20 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500 text-lg font-medium">No rooms found</p>
</div>
) : (
<div className="space-y-8">
{Object.entries(roomsByFloor)
.sort(([a], [b]) => parseInt(b) - parseInt(a))
.map(([floor, floorRooms]) => (
<div key={floor} className="space-y-4">
{/* Floor Header */}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-3">
<div className="w-1 h-12 bg-gradient-to-b from-blue-600 to-indigo-600 rounded-full"></div>
<div>
<h2 className="text-2xl font-bold text-slate-900 flex items-center space-x-2">
<MapPin className="w-6 h-6 text-blue-600" />
<span>Floor {floor}</span>
</h2>
<p className="text-sm text-slate-500 mt-0.5">
{floorRooms.length} {floorRooms.length === 1 ? 'room' : 'rooms'}
</p>
</div>
</div>
<div className="flex-1 h-px bg-gradient-to-r from-slate-200 via-slate-300 to-transparent"></div>
</div>
{/* Rooms Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{floorRooms.map((room) => {
const statusColors = getStatusColor(room.status);
return (
<div
key={room.id}
className={`
group relative overflow-hidden rounded-xl border-2 transition-all duration-300
${statusColors.bg} ${statusColors.border}
hover:shadow-xl hover:scale-[1.02] cursor-pointer
${expandedRooms.has(room.id) ? 'shadow-lg' : 'shadow-md'}
`}
onClick={() => toggleRoomExpansion(room.id)}
>
{/* Status Badge */}
<div className={`absolute top-3 right-3 px-3 py-1 rounded-full text-xs font-semibold shadow-lg ${statusColors.badge} flex items-center space-x-1.5`}>
{getStatusIcon(room.status)}
<span>{getStatusLabel(room.status)}</span>
</div>
{/* Room Content */}
<div className="p-5 pt-4">
<div className="mb-4">
<div className="flex items-center space-x-2 mb-2">
<h3 className="text-2xl font-bold text-slate-900 font-mono">{room.room_number}</h3>
{room.room_type && (
<span className="text-xs font-medium text-slate-600 bg-white/60 px-2 py-1 rounded">
{room.room_type}
</span>
)}
</div>
</div>
{/* Expanded Details */}
{expandedRooms.has(room.id) && (
<div className="mt-4 pt-4 border-t border-slate-300/30 space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
{room.current_booking && (
<div className="bg-white/60 rounded-lg p-3 space-y-2">
<div className="flex items-center space-x-2 text-sm font-semibold text-slate-700">
<Users className="w-4 h-4" />
<span>Guest</span>
</div>
<p className="text-sm font-medium text-slate-900">{room.current_booking.guest_name}</p>
<div className="flex items-center space-x-1 text-xs text-slate-600">
<Calendar className="w-3 h-3" />
<span>Check-out: {new Date(room.current_booking.check_out).toLocaleDateString()}</span>
</div>
</div>
)}
{room.active_maintenance && (
<div className="bg-red-50/80 rounded-lg p-3 space-y-2 border border-red-200/50">
<div className="flex items-center space-x-2 text-sm font-semibold text-red-800">
<Wrench className="w-4 h-4" />
<span>Maintenance</span>
</div>
<p className="text-sm font-medium text-red-900">{room.active_maintenance.title}</p>
<p className="text-xs text-red-700 capitalize">{room.active_maintenance.type}</p>
</div>
)}
{room.pending_housekeeping_count > 0 && (
<div className="bg-amber-50/80 rounded-lg p-3 space-y-2 border border-amber-200/50">
<div className="flex items-center space-x-2 text-sm font-semibold text-amber-800">
<Sparkles className="w-4 h-4" />
<span>Housekeeping</span>
</div>
<p className="text-sm font-medium text-amber-900">
{room.pending_housekeeping_count} pending {room.pending_housekeeping_count === 1 ? 'task' : 'tasks'}
</p>
</div>
)}
{!room.current_booking && !room.active_maintenance && room.pending_housekeeping_count === 0 && (
<div className="text-center py-3">
<CheckCircle className="w-8 h-8 text-emerald-500 mx-auto mb-2" />
<p className="text-sm text-slate-600 font-medium">All Clear</p>
</div>
)}
</div>
)}
{/* Collapse Indicator */}
<div className="mt-4 flex justify-center">
{expandedRooms.has(room.id) ? (
<ChevronUp className="w-5 h-5 text-slate-500 group-hover:text-slate-700 transition-colors" />
) : (
<ChevronDown className="w-5 h-5 text-slate-500 group-hover:text-slate-700 transition-colors" />
)}
</div>
</div>
{/* Decorative Corner */}
<div className={`absolute bottom-0 right-0 w-20 h-20 ${statusColors.bg} opacity-10 rounded-tl-full`}></div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Maintenance Tab */}
{activeTab === 'maintenance' && <MaintenanceManagement />}
{/* Housekeeping Tab */}
{activeTab === 'housekeeping' && <HousekeepingManagement />}
{/* Inspections Tab */}
{activeTab === 'inspections' && <InspectionManagement />}
</div>
);
};
export default AdvancedRoomManagementPage;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,707 @@
import React, { useEffect, useState } from 'react';
import { Search, Eye, XCircle, CheckCircle, Loader2, FileText, Plus } from 'lucide-react';
import { bookingService, Booking, invoiceService } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
import { useNavigate } from 'react-router-dom';
import CreateBookingModal from '../../components/shared/CreateBookingModal';
const BookingManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const navigate = useNavigate();
const [bookings, setBookings] = useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [updatingBookingId, setUpdatingBookingId] = useState<number | null>(null);
const [cancellingBookingId, setCancellingBookingId] = useState<number | null>(null);
const [creatingInvoice, setCreatingInvoice] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [filters, setFilters] = useState({
search: '',
status: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchBookings();
}, [filters, currentPage]);
const fetchBookings = async () => {
try {
setLoading(true);
const response = await bookingService.getAllBookings({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setBookings(response.data.bookings);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load bookings list');
} finally {
setLoading(false);
}
};
const handleUpdateStatus = async (id: number, status: string) => {
try {
setUpdatingBookingId(id);
await bookingService.updateBooking(id, { status } as any);
toast.success('Status updated successfully');
await fetchBookings();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
} finally {
setUpdatingBookingId(null);
}
};
const handleCancelBooking = async (id: number) => {
if (!window.confirm('Are you sure you want to cancel this booking?')) return;
try {
setCancellingBookingId(id);
await bookingService.cancelBooking(id);
toast.success('Booking cancelled successfully');
await fetchBookings();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to cancel booking');
} finally {
setCancellingBookingId(null);
}
};
const handleCreateInvoice = async (bookingId: number) => {
try {
setCreatingInvoice(true);
const invoiceData = {
booking_id: Number(bookingId),
};
const response = await invoiceService.createInvoice(invoiceData);
if (response.status === 'success' && response.data?.invoice) {
toast.success('Invoice created successfully!');
setShowDetailModal(false);
navigate(`/staff/invoices/${response.data.invoice.id}`);
} else {
throw new Error('Failed to create invoice');
}
} catch (error: any) {
const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || 'Unable to create invoice';
toast.error(errorMessage);
console.error('Invoice creation error:', error);
} finally {
setCreatingInvoice(false);
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
pending: {
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
text: 'text-amber-800',
label: 'Pending confirmation',
border: 'border-amber-200'
},
confirmed: {
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
text: 'text-blue-800',
label: 'Confirmed',
border: 'border-blue-200'
},
checked_in: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Checked in',
border: 'border-emerald-200'
},
checked_out: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: 'Checked out',
border: 'border-slate-200'
},
cancelled: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: '❌ Canceled',
border: 'border-rose-200'
},
};
const badge = badges[status] || badges.pending;
return (
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border ${badge.bg} ${badge.text} ${badge.border} shadow-sm`}>
{badge.label}
</span>
);
};
if (loading) {
return <Loading />;
}
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 with Create Button */}
<div className="animate-fade-in">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
<div className="flex-1">
<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-3xl sm:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Booking Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-base sm:text-lg font-light">Manage and track all hotel bookings with precision</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center justify-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 whitespace-nowrap w-full sm:w-auto"
>
<Plus className="w-5 h-5" />
Create Booking
</button>
</div>
</div>
{}
<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.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search by booking number, guest name..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="w-full px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All statuses</option>
<option value="pending">Pending confirmation</option>
<option value="confirmed">Confirmed</option>
<option value="checked_in">Checked in</option>
<option value="checked_out">Checked out</option>
<option value="cancelled">Canceled</option>
</select>
</div>
</div>
{}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Booking Number</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Customer</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Room</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Check-in/out</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Total Price</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Status</th>
<th className="px-8 py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{bookings.map((booking, index) => (
<tr
key={booking.id}
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
style={{ animationDelay: `${index * 0.05}s` }}
>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold text-slate-900 group-hover:text-amber-700 transition-colors font-mono">
{booking.booking_number}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-semibold text-slate-900">{booking.guest_info?.full_name || booking.user?.name}</div>
<div className="text-xs text-slate-500 mt-0.5">{booking.guest_info?.email || booking.user?.email}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-medium text-slate-800">
<span className="text-amber-600 font-semibold">Room {booking.room?.room_number}</span>
<span className="text-slate-400 mx-2"></span>
<span className="text-slate-600">{booking.room?.room_type?.name}</span>
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-medium text-slate-900">
{parseDateLocal(booking.check_in_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</div>
<div className="text-xs text-slate-500 mt-0.5">
{parseDateLocal(booking.check_out_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
{(() => {
const completedPayments = booking.payments?.filter(
(p) => p.payment_status === 'completed'
) || [];
const amountPaid = completedPayments.reduce(
(sum, p) => sum + (p.amount || 0),
0
);
const remainingDue = booking.total_price - amountPaid;
const hasPayments = completedPayments.length > 0;
return (
<div>
<div className="text-sm font-bold text-slate-900 bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">
{formatCurrency(booking.total_price)}
</div>
{hasPayments && (
<div className="text-xs mt-1">
<div className="text-green-600 font-medium">
Paid: {formatCurrency(amountPaid)}
</div>
{remainingDue > 0 && (
<div className="text-amber-600 font-medium">
Due: {formatCurrency(remainingDue)}
</div>
)}
</div>
)}
</div>
);
})()}
</td>
<td className="px-8 py-5 whitespace-nowrap">
{getStatusBadge(booking.status)}
</td>
<td className="px-8 py-5 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => {
setSelectedBooking(booking);
setShowDetailModal(true);
}}
className="p-2 rounded-lg text-slate-600 hover:text-amber-600 hover:bg-amber-50 transition-all duration-200 shadow-sm hover:shadow-md border border-slate-200 hover:border-amber-300"
title="View details"
>
<Eye className="w-5 h-5" />
</button>
{booking.status === 'pending' && (
<>
<button
onClick={() => handleUpdateStatus(booking.id, 'confirmed')}
disabled={updatingBookingId === booking.id || cancellingBookingId === booking.id}
className="p-2 rounded-lg text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow-md border border-emerald-200 hover:border-emerald-300"
title="Confirm"
>
{updatingBookingId === booking.id ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<CheckCircle className="w-5 h-5" />
)}
</button>
<button
onClick={() => handleCancelBooking(booking.id)}
disabled={updatingBookingId === booking.id || cancellingBookingId === booking.id}
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
title="Cancel"
>
{cancellingBookingId === booking.id ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<XCircle className="w-5 h-5" />
)}
</button>
</>
)}
{booking.status === 'confirmed' && (
<button
onClick={() => handleUpdateStatus(booking.id, 'checked_in')}
disabled={updatingBookingId === booking.id || cancellingBookingId === booking.id}
className="p-2 rounded-lg text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow-md border border-emerald-200 hover:border-emerald-300"
title="Check-in"
>
{updatingBookingId === booking.id ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<CheckCircle className="w-5 h-5" />
)}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
{}
{showDetailModal && selectedBooking && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden animate-scale-in border border-slate-200">
{}
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold text-amber-100 mb-1">Booking Details</h2>
<p className="text-amber-200/80 text-sm font-light">Comprehensive booking information</p>
</div>
<button
onClick={() => setShowDetailModal(false)}
className="w-10 h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
</button>
</div>
</div>
{}
<div className="p-8 overflow-y-auto max-h-[calc(90vh-120px)]">
<div className="space-y-6">
{}
<div className="grid grid-cols-2 gap-6">
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Booking Number</label>
<p className="text-xl font-bold text-slate-900 font-mono">{selectedBooking.booking_number}</p>
</div>
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Status</label>
<div className="mt-1">{getStatusBadge(selectedBooking.status)}</div>
</div>
</div>
{}
<div className="bg-gradient-to-br from-amber-50/50 to-yellow-50/50 p-6 rounded-xl border border-amber-100">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
<div className="w-1 h-4 bg-gradient-to-b from-amber-400 to-amber-600 rounded-full"></div>
Customer Information
</label>
<div className="space-y-2">
<p className="text-lg font-bold text-slate-900">{selectedBooking.guest_info?.full_name || selectedBooking.user?.name}</p>
<p className="text-slate-600">{selectedBooking.guest_info?.email || selectedBooking.user?.email}</p>
<p className="text-slate-600">{selectedBooking.guest_info?.phone || selectedBooking.user?.phone}</p>
</div>
</div>
{}
<div className="bg-gradient-to-br from-blue-50/50 to-indigo-50/50 p-6 rounded-xl border border-blue-100">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
<div className="w-1 h-4 bg-gradient-to-b from-blue-400 to-blue-600 rounded-full"></div>
Room Information
</label>
<p className="text-lg font-semibold text-slate-900">
<span className="text-amber-600">Room {selectedBooking.room?.room_number}</span>
<span className="text-slate-400 mx-2"></span>
<span className="text-slate-700">{selectedBooking.room?.room_type?.name}</span>
</p>
</div>
{}
<div className="grid grid-cols-2 gap-6">
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Check-in Date</label>
<p className="text-base font-semibold text-slate-900">{parseDateLocal(selectedBooking.check_in_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
</div>
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Check-out Date</label>
<p className="text-base font-semibold text-slate-900">{parseDateLocal(selectedBooking.check_out_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
</div>
</div>
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Number of Guests</label>
<p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p>
</div>
{}
<div className="bg-gradient-to-br from-indigo-50/50 to-purple-50/50 p-6 rounded-xl border border-indigo-100">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
<div className="w-1 h-4 bg-gradient-to-b from-indigo-400 to-indigo-600 rounded-full"></div>
Payment Information
</label>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-slate-500 mb-1">Payment Method</p>
<p className="text-base font-semibold text-slate-900">
{selectedBooking.payment_method === 'cash'
? '💵 Pay at Hotel'
: selectedBooking.payment_method === 'stripe'
? '💳 Stripe (Card)'
: selectedBooking.payment_method === 'paypal'
? '💳 PayPal'
: selectedBooking.payment_method || 'N/A'}
</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Payment Status</p>
<p className={`text-base font-semibold ${
selectedBooking.payment_status === 'paid'
? 'text-green-600'
: selectedBooking.payment_status === 'refunded'
? 'text-orange-600'
: 'text-red-600'
}`}>
{selectedBooking.payment_status === 'paid'
? '✅ Paid'
: selectedBooking.payment_status === 'refunded'
? '💰 Refunded'
: '❌ Unpaid'}
</p>
</div>
</div>
</div>
{}
{(selectedBooking as any).service_usages && (selectedBooking as any).service_usages.length > 0 && (
<div className="bg-gradient-to-br from-purple-50/50 to-pink-50/50 p-6 rounded-xl border border-purple-100">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
<div className="w-1 h-4 bg-gradient-to-b from-purple-400 to-purple-600 rounded-full"></div>
Additional Services
</label>
<div className="space-y-2">
{(selectedBooking as any).service_usages.map((service: any, idx: number) => (
<div key={service.id || idx} className="flex justify-between items-center py-2 border-b border-purple-100 last:border-0">
<div>
<p className="text-sm font-medium text-slate-900">{service.service_name || service.name || 'Service'}</p>
<p className="text-xs text-slate-500">
{formatCurrency(service.unit_price || service.price || 0)} × {service.quantity || 1}
</p>
</div>
<p className="text-sm font-semibold text-slate-900">
{formatCurrency(service.total_price || (service.unit_price || service.price || 0) * (service.quantity || 1))}
</p>
</div>
))}
</div>
</div>
)}
{}
{(() => {
const completedPayments = selectedBooking.payments?.filter(
(p) => p.payment_status === 'completed'
) || [];
const allPayments = selectedBooking.payments || [];
const amountPaid = completedPayments.reduce(
(sum, p) => sum + (p.amount || 0),
0
);
const remainingDue = selectedBooking.total_price - amountPaid;
const hasPayments = allPayments.length > 0;
return (
<>
{hasPayments && (
<div className="bg-gradient-to-br from-teal-50/50 to-cyan-50/50 p-6 rounded-xl border border-teal-100">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
<div className="w-1 h-4 bg-gradient-to-b from-teal-400 to-teal-600 rounded-full"></div>
Payment History
</label>
<div className="space-y-3">
{allPayments.map((payment: any, idx: number) => (
<div key={payment.id || idx} className="p-3 bg-white rounded-lg border border-teal-100">
<div className="flex justify-between items-start mb-2">
<div>
<p className="text-sm font-semibold text-slate-900">
{formatCurrency(payment.amount || 0)}
</p>
<p className="text-xs text-slate-500 mt-1">
{payment.payment_type === 'deposit' ? 'Deposit (20%)' : payment.payment_type === 'remaining' ? 'Remaining Payment' : 'Full Payment'}
{' • '}
{payment.payment_method === 'stripe' ? 'Stripe' : payment.payment_method === 'paypal' ? 'PayPal' : payment.payment_method || 'Cash'}
</p>
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
payment.payment_status === 'completed' || payment.payment_status === 'paid'
? 'bg-green-100 text-green-700'
: payment.payment_status === 'pending'
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}>
{payment.payment_status === 'completed' || payment.payment_status === 'paid' ? 'Paid' : payment.payment_status || 'Pending'}
</span>
</div>
{payment.transaction_id && (
<p className="text-xs text-slate-400 font-mono">ID: {payment.transaction_id}</p>
)}
{payment.payment_date && (
<p className="text-xs text-slate-400 mt-1">
{new Date(payment.payment_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
</p>
)}
</div>
))}
</div>
</div>
)}
{}
<div className="bg-gradient-to-br from-green-50 via-emerald-50 to-green-50 p-6 rounded-xl border-2 border-green-200 shadow-lg mb-4">
<label className="text-xs font-semibold text-green-700 uppercase tracking-wider mb-2 block">Amount Paid</label>
<p className="text-3xl font-bold bg-gradient-to-r from-green-600 via-emerald-700 to-green-600 bg-clip-text text-transparent">
{formatCurrency(amountPaid)}
</p>
{hasPayments && completedPayments.length > 0 && (
<p className="text-xs text-green-600 mt-2">
{completedPayments.length} payment{completedPayments.length !== 1 ? 's' : ''} completed
{amountPaid > 0 && selectedBooking.total_price > 0 && (
<span className="ml-2">
({((amountPaid / selectedBooking.total_price) * 100).toFixed(0)}% of total)
</span>
)}
</p>
)}
{amountPaid === 0 && !hasPayments && (
<p className="text-sm text-gray-500 mt-2">No payments made yet</p>
)}
</div>
{}
{remainingDue > 0 && (
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg mb-4">
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Remaining Due (To be paid)</label>
<p className="text-3xl font-bold text-amber-600">
{formatCurrency(remainingDue)}
</p>
{selectedBooking.total_price > 0 && (
<p className="text-xs text-amber-600 mt-2">
({((remainingDue / selectedBooking.total_price) * 100).toFixed(0)}% of total)
</p>
)}
</div>
)}
{}
<div className="bg-gradient-to-br from-slate-50 to-gray-50 p-6 rounded-xl border-2 border-slate-200 shadow-lg">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2 block">Total Booking Price</label>
<p className="text-2xl font-bold text-slate-700">
{formatCurrency(selectedBooking.total_price)}
</p>
<p className="text-xs text-slate-500 mt-2">
This is the total amount for the booking
</p>
</div>
</>
);
})()}
{}
<div className="bg-gradient-to-br from-slate-50 to-white p-6 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-4 block flex items-center gap-2">
<div className="w-1 h-4 bg-gradient-to-b from-slate-400 to-slate-600 rounded-full"></div>
Booking Metadata
</label>
<div className="grid grid-cols-2 gap-4">
{selectedBooking.createdAt && (
<div>
<p className="text-xs text-slate-500 mb-1">Created At</p>
<p className="text-sm font-medium text-slate-900">
{new Date(selectedBooking.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</p>
</div>
)}
{selectedBooking.updatedAt && (
<div>
<p className="text-xs text-slate-500 mb-1">Last Updated</p>
<p className="text-sm font-medium text-slate-900">
{new Date(selectedBooking.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</p>
</div>
)}
{selectedBooking.requires_deposit !== undefined && (
<div>
<p className="text-xs text-slate-500 mb-1">Deposit Required</p>
<p className="text-sm font-medium text-slate-900">
{selectedBooking.requires_deposit ? 'Yes (20%)' : 'No'}
</p>
</div>
)}
{selectedBooking.deposit_paid !== undefined && (
<div>
<p className="text-xs text-slate-500 mb-1">Deposit Paid</p>
<p className={`text-sm font-medium ${selectedBooking.deposit_paid ? 'text-green-600' : 'text-amber-600'}`}>
{selectedBooking.deposit_paid ? '✅ Yes' : '❌ No'}
</p>
</div>
)}
</div>
</div>
{}
{selectedBooking.notes && (
<div className="bg-gradient-to-br from-slate-50 to-white p-6 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3 block">Special Notes</label>
<p className="text-slate-700 leading-relaxed">{selectedBooking.notes}</p>
</div>
)}
</div>
{}
<div className="mt-8 pt-6 border-t border-slate-200 flex justify-between items-center">
<button
onClick={() => handleCreateInvoice(selectedBooking.id)}
disabled={creatingInvoice}
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 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl"
>
{creatingInvoice ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Creating Invoice...
</>
) : (
<>
<FileText className="w-5 h-5" />
Create Invoice
</>
)}
</button>
<button
onClick={() => setShowDetailModal(false)}
className="px-8 py-3 bg-gradient-to-r from-slate-700 to-slate-800 text-white rounded-xl font-semibold hover:from-slate-800 hover:to-slate-900 transition-all duration-200 shadow-lg hover:shadow-xl border border-slate-600"
>
Close
</button>
</div>
</div>
</div>
</div>
)}
{/* Create Booking Modal */}
<CreateBookingModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
setShowCreateModal(false);
fetchBookings();
}}
/>
</div>
);
};
export default BookingManagementPage;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,317 @@
import React, { useEffect, useState } from 'react';
import { Search } from 'lucide-react';
import { paymentService } from '../../services/api';
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();
const [payments, setPayments] = useState<Payment[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({
search: '',
method: '',
from: '',
to: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchPayments();
}, [filters, currentPage]);
const fetchPayments = async () => {
try {
setLoading(true);
const response = await paymentService.getPayments({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setPayments(response.data.payments);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load payments list');
} finally {
setLoading(false);
}
};
const getMethodBadge = (method: string) => {
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
cash: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Cash',
border: 'border-emerald-200'
},
bank_transfer: {
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
text: 'text-blue-800',
label: 'Bank transfer',
border: 'border-blue-200'
},
stripe: {
bg: 'bg-gradient-to-r from-indigo-50 to-purple-50',
text: 'text-indigo-800',
label: 'Stripe',
border: 'border-indigo-200'
},
paypal: {
bg: 'bg-gradient-to-r from-blue-50 to-cyan-50',
text: 'text-blue-800',
label: 'PayPal',
border: 'border-blue-200'
},
credit_card: {
bg: 'bg-gradient-to-r from-purple-50 to-pink-50',
text: 'text-purple-800',
label: 'Credit card',
border: 'border-purple-200'
},
};
const badge = badges[method] || badges.cash;
return (
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
{badge.label}
</span>
);
};
const getPaymentStatusBadge = (status: string) => {
const statusConfig: Record<string, { bg: string; text: string; label: string; border: string }> = {
completed: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: '✅ Paid',
border: 'border-emerald-200'
},
pending: {
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
text: 'text-amber-800',
label: '⏳ Pending',
border: 'border-amber-200'
},
failed: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: '❌ Failed',
border: 'border-rose-200'
},
refunded: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: '💰 Refunded',
border: 'border-slate-200'
},
};
const config = statusConfig[status] || statusConfig.pending;
return (
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${config.bg} ${config.text} ${config.border}`}>
{config.label}
</span>
);
};
if (loading) {
return <Loading />;
}
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">
{}
<div className="animate-fade-in">
<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>
</div>
{}
<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.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.method}
onChange={(e) => setFilters({ ...filters, method: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All methods</option>
<option value="cash">Cash</option>
<option value="stripe">Stripe</option>
<option value="credit_card">Credit card</option>
</select>
<input
type="date"
value={filters.from}
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
placeholder="From date"
/>
<input
type="date"
value={filters.to}
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
placeholder="To date"
/>
</div>
</div>
{}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Transaction ID</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Booking Number</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Customer</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Method</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Type</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Amount</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Payment Date</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{payments.map((payment, index) => (
<tr
key={payment.id}
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
style={{ animationDelay: `${index * 0.05}s` }}
>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold text-slate-900 font-mono">{payment.transaction_id || `PAY-${payment.id}`}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-semibold text-amber-600">{payment.booking?.booking_number}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-medium text-slate-900">{payment.booking?.user?.name}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
{getMethodBadge(payment.payment_method)}
</td>
<td className="px-8 py-5 whitespace-nowrap">
{payment.payment_type === 'deposit' ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
Deposit (20%)
</span>
) : payment.payment_type === 'remaining' ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 border border-blue-200">
Remaining
</span>
) : (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
Full Payment
</span>
)}
</td>
<td className="px-8 py-5 whitespace-nowrap">
{getPaymentStatusBadge(payment.payment_status)}
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
{formatCurrency(payment.amount)}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm text-slate-600">
{new Date(payment.payment_date || payment.createdAt || '').toLocaleDateString('en-US')}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
{}
<div className="bg-gradient-to-r from-amber-500 via-amber-600 to-amber-700 rounded-2xl shadow-2xl p-8 text-white animate-fade-in" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold mb-2 text-amber-100">Total Revenue</h3>
<p className="text-4xl font-bold">
{formatCurrency(payments
.filter(p => p.payment_status === 'completed')
.reduce((sum, p) => sum + p.amount, 0))}
</p>
<p className="text-sm mt-3 text-amber-100/90">
Total {payments.filter(p => p.payment_status === 'completed').length} paid transaction{payments.filter(p => p.payment_status === 'completed').length !== 1 ? 's' : ''}
</p>
</div>
<div className="bg-white/20 backdrop-blur-sm p-6 rounded-2xl">
<div className="text-5xl font-bold text-white/80">{payments.filter(p => p.payment_status === 'completed').length}</div>
</div>
</div>
</div>
</div>
);
};
export default PaymentManagementPage;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
/**
* Staff Pages
*
* All pages accessible only to staff members
*/
export { default as StaffDashboardPage } from './DashboardPage';
export { default as ChatManagementPage } from './ChatManagementPage';
export { default as BookingManagementPage } from './BookingManagementPage';
export { default as ReceptionDashboardPage } from './ReceptionDashboardPage';
export { default as PaymentManagementPage } from './PaymentManagementPage';
export { default as AnalyticsDashboardPage } from './AnalyticsDashboardPage';
export { default as LoyaltyManagementPage } from './LoyaltyManagementPage';
export { default as GuestProfilePage } from './GuestProfilePage';
export { default as AdvancedRoomManagementPage } from './AdvancedRoomManagementPage';