updates
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
1803
Frontend/src/pages/accountant/AnalyticsDashboardPage.tsx
Normal file
1803
Frontend/src/pages/accountant/AnalyticsDashboardPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
353
Frontend/src/pages/accountant/InvoiceManagementPage.tsx
Normal file
353
Frontend/src/pages/accountant/InvoiceManagementPage.tsx
Normal 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;
|
||||
|
||||
317
Frontend/src/pages/accountant/PaymentManagementPage.tsx
Normal file
317
Frontend/src/pages/accountant/PaymentManagementPage.tsx
Normal 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;
|
||||
11
Frontend/src/pages/accountant/index.ts
Normal file
11
Frontend/src/pages/accountant/index.ts
Normal 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';
|
||||
|
||||
679
Frontend/src/pages/admin/AdvancedAnalyticsPage.tsx
Normal file
679
Frontend/src/pages/admin/AdvancedAnalyticsPage.tsx
Normal 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;
|
||||
|
||||
1475
Frontend/src/pages/admin/AdvancedRoomManagementPage.tsx
Normal file
1475
Frontend/src/pages/admin/AdvancedRoomManagementPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
947
Frontend/src/pages/admin/EmailCampaignManagementPage.tsx
Normal file
947
Frontend/src/pages/admin/EmailCampaignManagementPage.tsx
Normal 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;
|
||||
|
||||
538
Frontend/src/pages/admin/GroupBookingManagementPage.tsx
Normal file
538
Frontend/src/pages/admin/GroupBookingManagementPage.tsx
Normal 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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
271
Frontend/src/pages/admin/NotificationManagementPage.tsx
Normal file
271
Frontend/src/pages/admin/NotificationManagementPage.tsx
Normal 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;
|
||||
|
||||
708
Frontend/src/pages/admin/PackageManagementPage.tsx
Normal file
708
Frontend/src/pages/admin/PackageManagementPage.tsx
Normal 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;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
774
Frontend/src/pages/admin/RatePlanManagementPage.tsx
Normal file
774
Frontend/src/pages/admin/RatePlanManagementPage.tsx
Normal 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
@@ -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" />
|
||||
|
||||
1563
Frontend/src/pages/admin/SecurityManagementPage.tsx
Normal file
1563
Frontend/src/pages/admin/SecurityManagementPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
398
Frontend/src/pages/admin/TaskManagementPage.tsx
Normal file
398
Frontend/src/pages/admin/TaskManagementPage.tsx
Normal 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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
215
Frontend/src/pages/admin/WorkflowManagementPage.tsx
Normal file
215
Frontend/src/pages/admin/WorkflowManagementPage.tsx
Normal 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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
163
Frontend/src/pages/customer/BoricaReturnPage.tsx
Normal file
163
Frontend/src/pages/customer/BoricaReturnPage.tsx
Normal 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;
|
||||
|
||||
179
Frontend/src/pages/customer/GroupBookingPage.tsx
Normal file
179
Frontend/src/pages/customer/GroupBookingPage.tsx
Normal 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;
|
||||
|
||||
24
Frontend/src/pages/customer/index.ts
Normal file
24
Frontend/src/pages/customer/index.ts
Normal 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';
|
||||
|
||||
394
Frontend/src/pages/staff/AdvancedRoomManagementPage.tsx
Normal file
394
Frontend/src/pages/staff/AdvancedRoomManagementPage.tsx
Normal 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;
|
||||
|
||||
1803
Frontend/src/pages/staff/AnalyticsDashboardPage.tsx
Normal file
1803
Frontend/src/pages/staff/AnalyticsDashboardPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
707
Frontend/src/pages/staff/BookingManagementPage.tsx
Normal file
707
Frontend/src/pages/staff/BookingManagementPage.tsx
Normal 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;
|
||||
1319
Frontend/src/pages/staff/GuestProfilePage.tsx
Normal file
1319
Frontend/src/pages/staff/GuestProfilePage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1161
Frontend/src/pages/staff/LoyaltyManagementPage.tsx
Normal file
1161
Frontend/src/pages/staff/LoyaltyManagementPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
317
Frontend/src/pages/staff/PaymentManagementPage.tsx
Normal file
317
Frontend/src/pages/staff/PaymentManagementPage.tsx
Normal 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;
|
||||
3262
Frontend/src/pages/staff/ReceptionDashboardPage.tsx
Normal file
3262
Frontend/src/pages/staff/ReceptionDashboardPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
16
Frontend/src/pages/staff/index.ts
Normal file
16
Frontend/src/pages/staff/index.ts
Normal 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';
|
||||
|
||||
Reference in New Issue
Block a user