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

File diff suppressed because it is too large Load Diff

View File

@@ -12,14 +12,16 @@ import { CookieConsentProvider } from './contexts/CookieConsentContext';
import { CurrencyProvider } from './contexts/CurrencyContext';
import { CompanySettingsProvider } from './contexts/CompanySettingsContext';
import { AuthModalProvider } from './contexts/AuthModalContext';
import { NavigationLoadingProvider, useNavigationLoading } from './contexts/NavigationLoadingContext';
import OfflineIndicator from './components/common/OfflineIndicator';
import CookieConsentBanner from './components/common/CookieConsentBanner';
import CookiePreferencesModal from './components/common/CookiePreferencesModal';
import AnalyticsLoader from './components/common/AnalyticsLoader';
import Loading from './components/common/Loading';
import Preloader from './components/common/Preloader';
import ScrollToTop from './components/common/ScrollToTop';
import AuthModalManager from './components/modals/AuthModalManager';
import ResetPasswordRouteHandler from './components/ResetPasswordRouteHandler';
import ResetPasswordRouteHandler from './components/auth/ResetPasswordRouteHandler';
import useAuthStore from './store/useAuthStore';
import useFavoritesStore from './store/useFavoritesStore';
@@ -49,9 +51,11 @@ const PaymentConfirmationPage = lazy(() => import('./pages/customer/PaymentConfi
const PaymentResultPage = lazy(() => import('./pages/customer/PaymentResultPage'));
const PayPalReturnPage = lazy(() => import('./pages/customer/PayPalReturnPage'));
const PayPalCancelPage = lazy(() => import('./pages/customer/PayPalCancelPage'));
const BoricaReturnPage = lazy(() => import('./pages/customer/BoricaReturnPage'));
const InvoicePage = lazy(() => import('./pages/customer/InvoicePage'));
const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
const LoyaltyPage = lazy(() => import('./pages/customer/LoyaltyPage'));
const GroupBookingPage = lazy(() => import('./pages/customer/GroupBookingPage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const ContactPage = lazy(() => import('./pages/ContactPage'));
const PrivacyPolicyPage = lazy(() => import('./pages/PrivacyPolicyPage'));
@@ -67,22 +71,47 @@ const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagement
const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage'));
const GuestProfilePage = lazy(() => import('./pages/admin/GuestProfilePage'));
const BookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage'));
const GroupBookingManagementPage = lazy(() => import('./pages/admin/GroupBookingManagementPage'));
const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard'));
const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage'));
const BusinessDashboardPage = lazy(() => import('./pages/admin/BusinessDashboardPage'));
const SettingsPage = lazy(() => import('./pages/admin/SettingsPage'));
const TaskManagementPage = lazy(() => import('./pages/admin/TaskManagementPage'));
const WorkflowManagementPage = lazy(() => import('./pages/admin/WorkflowManagementPage'));
const NotificationManagementPage = lazy(() => import('./pages/admin/NotificationManagementPage'));
const ReceptionDashboardPage = lazy(() => import('./pages/admin/ReceptionDashboardPage'));
const LoyaltyManagementPage = lazy(() => import('./pages/admin/LoyaltyManagementPage'));
const AdvancedRoomManagementPage = lazy(() => import('./pages/admin/AdvancedRoomManagementPage'));
const RatePlanManagementPage = lazy(() => import('./pages/admin/RatePlanManagementPage'));
const PackageManagementPage = lazy(() => import('./pages/admin/PackageManagementPage'));
const SecurityManagementPage = lazy(() => import('./pages/admin/SecurityManagementPage'));
const EmailCampaignManagementPage = lazy(() => import('./pages/admin/EmailCampaignManagementPage'));
const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage'));
const StaffBookingManagementPage = lazy(() => import('./pages/staff/BookingManagementPage'));
const StaffReceptionDashboardPage = lazy(() => import('./pages/staff/ReceptionDashboardPage'));
const StaffPaymentManagementPage = lazy(() => import('./pages/staff/PaymentManagementPage'));
const StaffAnalyticsDashboardPage = lazy(() => import('./pages/staff/AnalyticsDashboardPage'));
const StaffLoyaltyManagementPage = lazy(() => import('./pages/staff/LoyaltyManagementPage'));
const StaffGuestProfilePage = lazy(() => import('./pages/staff/GuestProfilePage'));
const StaffAdvancedRoomManagementPage = lazy(() => import('./pages/staff/AdvancedRoomManagementPage'));
const ChatManagementPage = lazy(() => import('./pages/staff/ChatManagementPage'));
const StaffLayout = lazy(() => import('./pages/StaffLayout'));
const AccountantDashboardPage = lazy(() => import('./pages/accountant/DashboardPage'));
const AccountantPaymentManagementPage = lazy(() => import('./pages/accountant/PaymentManagementPage'));
const AccountantInvoiceManagementPage = lazy(() => import('./pages/accountant/InvoiceManagementPage'));
const AccountantAnalyticsDashboardPage = lazy(() => import('./pages/accountant/AnalyticsDashboardPage'));
const AccountantLayout = lazy(() => import('./pages/AccountantLayout'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
// Wrapper component to use navigation loading hook inside CompanySettingsProvider
const PreloaderWrapper: React.FC = () => {
const { isLoading } = useNavigationLoading();
return <Preloader isLoading={isLoading} />;
};
function App() {
const {
@@ -138,8 +167,10 @@ function App() {
v7_relativeSplatPath: true,
}}
>
<ScrollToTop />
<Suspense fallback={<Loading fullScreen text="Loading page..." />}>
<NavigationLoadingProvider>
<PreloaderWrapper />
<ScrollToTop />
<Suspense fallback={<Loading fullScreen text="Loading page..." />}>
<Routes>
{}
<Route
@@ -185,6 +216,10 @@ function App() {
path="payment/paypal/cancel"
element={<PayPalCancelPage />}
/>
<Route
path="payment/borica/return"
element={<BoricaReturnPage />}
/>
<Route
path="invoices/:id"
element={
@@ -291,6 +326,14 @@ function App() {
</CustomerRoute>
}
/>
<Route
path="group-bookings"
element={
<CustomerRoute>
<GroupBookingPage />
</CustomerRoute>
}
/>
</Route>
{}
@@ -325,6 +368,10 @@ function App() {
path="reception"
element={<ReceptionDashboardPage />}
/>
<Route
path="advanced-rooms"
element={<AdvancedRoomManagementPage />}
/>
<Route
path="page-content"
element={<PageContentDashboardPage />}
@@ -333,6 +380,18 @@ function App() {
path="analytics"
element={<AnalyticsDashboardPage />}
/>
<Route
path="tasks"
element={<TaskManagementPage />}
/>
<Route
path="workflows"
element={<WorkflowManagementPage />}
/>
<Route
path="notifications"
element={<NotificationManagementPage />}
/>
<Route
path="settings"
element={<SettingsPage />}
@@ -357,6 +416,26 @@ function App() {
path="guest-profiles"
element={<GuestProfilePage />}
/>
<Route
path="group-bookings"
element={<GroupBookingManagementPage />}
/>
<Route
path="rate-plans"
element={<RatePlanManagementPage />}
/>
<Route
path="packages"
element={<PackageManagementPage />}
/>
<Route
path="security"
element={<SecurityManagementPage />}
/>
<Route
path="email-campaigns"
element={<EmailCampaignManagementPage />}
/>
</Route>
{}
@@ -375,19 +454,19 @@ function App() {
<Route path="dashboard" element={<StaffDashboardPage />} />
<Route
path="bookings"
element={<BookingManagementPage />}
element={<StaffBookingManagementPage />}
/>
<Route
path="reception"
element={<ReceptionDashboardPage />}
element={<StaffReceptionDashboardPage />}
/>
<Route
path="payments"
element={<PaymentManagementPage />}
element={<StaffPaymentManagementPage />}
/>
<Route
path="reports"
element={<AnalyticsDashboardPage />}
element={<StaffAnalyticsDashboardPage />}
/>
<Route
path="chats"
@@ -395,11 +474,15 @@ function App() {
/>
<Route
path="loyalty"
element={<LoyaltyManagementPage />}
element={<StaffLoyaltyManagementPage />}
/>
<Route
path="guest-profiles"
element={<GuestProfilePage />}
element={<StaffGuestProfilePage />}
/>
<Route
path="advanced-rooms"
element={<StaffAdvancedRoomManagementPage />}
/>
</Route>
@@ -419,15 +502,15 @@ function App() {
<Route path="dashboard" element={<AccountantDashboardPage />} />
<Route
path="payments"
element={<PaymentManagementPage />}
element={<AccountantPaymentManagementPage />}
/>
<Route
path="invoices"
element={<InvoiceManagementPage />}
element={<AccountantInvoiceManagementPage />}
/>
<Route
path="reports"
element={<AnalyticsDashboardPage />}
element={<AccountantAnalyticsDashboardPage />}
/>
</Route>
@@ -458,6 +541,7 @@ function App() {
<AnalyticsLoader />
<AuthModalManager />
</Suspense>
</NavigationLoadingProvider>
</BrowserRouter>
</AuthModalProvider>
</CompanySettingsProvider>

View File

@@ -0,0 +1,377 @@
import React, { useState } from 'react';
import {
FileText,
Plus,
X,
Save,
Download,
Calendar,
CheckSquare,
Square,
Filter,
BarChart3,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { exportData } from '../../utils/exportUtils';
import analyticsService from '../../services/api/analyticsService';
interface ReportMetric {
id: string;
category: 'revenue' | 'operational' | 'guest' | 'financial';
metric: string;
label: string;
}
interface CustomReportBuilderProps {
onClose?: () => void;
}
const AVAILABLE_METRICS: ReportMetric[] = [
// Revenue Metrics
{ id: 'revpar', category: 'revenue', metric: 'revpar', label: 'RevPAR (Revenue Per Available Room)' },
{ id: 'adr', category: 'revenue', metric: 'adr', label: 'ADR (Average Daily Rate)' },
{ id: 'occupancy', category: 'revenue', metric: 'occupancy', label: 'Occupancy Rate' },
{ id: 'forecast', category: 'revenue', metric: 'forecast', label: 'Revenue Forecast' },
{ id: 'market-penetration', category: 'revenue', metric: 'market-penetration', label: 'Market Penetration' },
// Operational Metrics
{ id: 'staff-performance', category: 'operational', metric: 'staff-performance', label: 'Staff Performance' },
{ id: 'service-usage', category: 'operational', metric: 'service-usage', label: 'Service Usage' },
{ id: 'efficiency', category: 'operational', metric: 'efficiency', label: 'Operational Efficiency' },
// Guest Metrics
{ id: 'ltv', category: 'guest', metric: 'lifetime-value', label: 'Guest Lifetime Value' },
{ id: 'cac', category: 'guest', metric: 'acquisition-cost', label: 'Customer Acquisition Cost' },
{ id: 'repeat-rate', category: 'guest', metric: 'repeat-rate', label: 'Repeat Guest Rate' },
{ id: 'satisfaction', category: 'guest', metric: 'satisfaction-trends', label: 'Guest Satisfaction Trends' },
// Financial Metrics
{ id: 'profit-loss', category: 'financial', metric: 'profit-loss', label: 'Profit & Loss' },
{ id: 'payment-methods', category: 'financial', metric: 'payment-methods', label: 'Payment Methods' },
{ id: 'refunds', category: 'financial', metric: 'refunds', label: 'Refund Analysis' },
];
const CustomReportBuilder: React.FC<CustomReportBuilderProps> = ({ onClose }) => {
const [reportName, setReportName] = useState('');
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([]);
const [dateRange, setDateRange] = useState({
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
to: new Date().toISOString().split('T')[0],
});
const [loading, setLoading] = useState(false);
const [activeCategory, setActiveCategory] = useState<'revenue' | 'operational' | 'guest' | 'financial' | 'all'>('all');
const toggleMetric = (metricId: string) => {
setSelectedMetrics(prev =>
prev.includes(metricId)
? prev.filter(id => id !== metricId)
: [...prev, metricId]
);
};
const selectAllInCategory = (category: string) => {
const categoryMetrics = AVAILABLE_METRICS
.filter(m => category === 'all' || m.category === category)
.map(m => m.id);
setSelectedMetrics(prev => {
const newSelection = [...prev];
categoryMetrics.forEach(id => {
if (!newSelection.includes(id)) {
newSelection.push(id);
}
});
return newSelection;
});
};
const clearSelection = () => {
setSelectedMetrics([]);
};
const fetchMetricData = async (metric: ReportMetric) => {
const params = { from: dateRange.from, to: dateRange.to };
switch (metric.metric) {
case 'revpar':
return await analyticsService.getRevPAR(params);
case 'adr':
return await analyticsService.getADR(params);
case 'occupancy':
return await analyticsService.getOccupancyRate(params);
case 'forecast':
return await analyticsService.getRevenueForecast(30);
case 'market-penetration':
return await analyticsService.getMarketPenetration(params);
case 'staff-performance':
return await analyticsService.getStaffPerformance(params);
case 'service-usage':
return await analyticsService.getServiceUsageAnalytics(params);
case 'efficiency':
return await analyticsService.getOperationalEfficiency(params);
case 'lifetime-value':
return await analyticsService.getGuestLifetimeValue(params);
case 'acquisition-cost':
return await analyticsService.getCustomerAcquisitionCost(params);
case 'repeat-rate':
return await analyticsService.getRepeatGuestRate(params);
case 'satisfaction-trends':
return await analyticsService.getGuestSatisfactionTrends(params);
case 'profit-loss':
return await analyticsService.getProfitLoss(params);
case 'payment-methods':
return await analyticsService.getPaymentMethodAnalytics(params);
case 'refunds':
return await analyticsService.getRefundAnalysis(params);
default:
return null;
}
};
const generateReport = async (format: 'csv' | 'xlsx' | 'pdf' | 'json') => {
if (selectedMetrics.length === 0) {
toast.error('Please select at least one metric');
return;
}
setLoading(true);
try {
const reportData: any[] = [];
const selectedMetricObjects = AVAILABLE_METRICS.filter(m => selectedMetrics.includes(m.id));
for (const metric of selectedMetricObjects) {
const response = await fetchMetricData(metric);
if (response && response.data) {
// Flatten the data for export
const flattened = flattenMetricData(metric.label, response.data);
reportData.push(...flattened);
}
}
if (reportData.length === 0) {
toast.error('No data available for selected metrics');
return;
}
const filename = reportName || `custom-report-${new Date().toISOString().split('T')[0]}`;
exportData({
filename,
title: reportName || 'Custom Analytics Report',
data: reportData,
format,
});
toast.success(`Report exported as ${format.toUpperCase()} successfully`);
if (onClose) onClose();
} catch (error: any) {
toast.error(`Failed to generate report: ${error.message}`);
} finally {
setLoading(false);
}
};
const flattenMetricData = (metricLabel: string, data: any): any[] => {
const result: any[] = [];
// Handle different data structures
if (Array.isArray(data)) {
return data.map(item => ({ Metric: metricLabel, ...item }));
}
if (typeof data === 'object' && data !== null) {
// Try to find array properties
const arrayKeys = Object.keys(data).filter(key => Array.isArray(data[key]));
if (arrayKeys.length > 0) {
// Use first array found
const arrayData = data[arrayKeys[0]];
return arrayData.map((item: any) => ({
Metric: metricLabel,
...item,
}));
}
// Flatten object
return [{
Metric: metricLabel,
...data,
}];
}
return [{ Metric: metricLabel, Value: data }];
};
const filteredMetrics = activeCategory === 'all'
? AVAILABLE_METRICS
: AVAILABLE_METRICS.filter(m => m.category === activeCategory);
const categories = [
{ id: 'all' as const, label: 'All Metrics', count: AVAILABLE_METRICS.length },
{ id: 'revenue' as const, label: 'Revenue', count: AVAILABLE_METRICS.filter(m => m.category === 'revenue').length },
{ id: 'operational' as const, label: 'Operational', count: AVAILABLE_METRICS.filter(m => m.category === 'operational').length },
{ id: 'guest' as const, label: 'Guest', count: AVAILABLE_METRICS.filter(m => m.category === 'guest').length },
{ id: 'financial' as const, label: 'Financial', count: AVAILABLE_METRICS.filter(m => m.category === 'financial').length },
];
return (
<div className="bg-white rounded-xl shadow-xl border border-gray-200 p-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900">Custom Report Builder</h2>
<p className="text-gray-600 mt-1">Select metrics and generate custom analytics reports</p>
</div>
{onClose && (
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
)}
</div>
{/* Report Name */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Report Name (Optional)
</label>
<input
type="text"
value={reportName}
onChange={(e) => setReportName(e.target.value)}
placeholder="e.g., Monthly Revenue Report"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
{/* Date Range */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">Date Range</label>
<div className="flex items-center gap-3">
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
<span className="text-gray-500">to</span>
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
{/* Category Filter */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">Filter by Category</label>
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<button
key={category.id}
onClick={() => setActiveCategory(category.id)}
className={`px-4 py-2 rounded-lg font-medium transition-all ${
activeCategory === category.id
? 'bg-indigo-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{category.label} ({category.count})
</button>
))}
</div>
</div>
{/* Metrics Selection */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-medium text-gray-700">
Select Metrics ({selectedMetrics.length} selected)
</label>
<div className="flex gap-2">
<button
onClick={() => selectAllInCategory(activeCategory)}
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium"
>
Select All
</button>
<button
onClick={clearSelection}
className="text-sm text-gray-600 hover:text-gray-700 font-medium"
>
Clear
</button>
</div>
</div>
<div className="border border-gray-200 rounded-lg p-4 max-h-96 overflow-y-auto">
<div className="space-y-2">
{filteredMetrics.map((metric) => {
const isSelected = selectedMetrics.includes(metric.id);
return (
<button
key={metric.id}
onClick={() => toggleMetric(metric.id)}
className={`w-full flex items-center gap-3 p-3 rounded-lg transition-all ${
isSelected
? 'bg-indigo-50 border-2 border-indigo-500'
: 'bg-gray-50 border-2 border-transparent hover:bg-gray-100'
}`}
>
{isSelected ? (
<CheckSquare className="w-5 h-5 text-indigo-600" />
) : (
<Square className="w-5 h-5 text-gray-400" />
)}
<div className="flex-1 text-left">
<div className="font-medium text-gray-900">{metric.label}</div>
<div className="text-xs text-gray-500 capitalize">{metric.category}</div>
</div>
</button>
);
})}
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
<button
onClick={() => generateReport('csv')}
disabled={loading || selectedMetrics.length === 0}
className="px-6 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Download className="w-4 h-4" />
Export CSV
</button>
<button
onClick={() => generateReport('xlsx')}
disabled={loading || selectedMetrics.length === 0}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Download className="w-4 h-4" />
Export Excel
</button>
<button
onClick={() => generateReport('pdf')}
disabled={loading || selectedMetrics.length === 0}
className="px-6 py-2 bg-rose-600 text-white rounded-lg hover:bg-rose-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Download className="w-4 h-4" />
Export PDF
</button>
</div>
{loading && (
<div className="mt-4 text-center text-gray-600">
Generating report...
</div>
)}
</div>
);
};
export default CustomReportBuilder;

View File

@@ -0,0 +1,224 @@
import React from 'react';
import { TrendingUp } from 'lucide-react';
// Simple chart components using CSS/SVG (no external library dependency)
// These provide basic visualizations without requiring recharts
interface SimpleBarChartProps {
data: Array<{ label: string; value: number; color?: string }>;
height?: number;
title?: string;
}
export const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, height = 200, title }) => {
const maxValue = Math.max(...data.map(d => d.value), 1);
return (
<div className="w-full">
{title && <h3 className="text-sm font-semibold text-gray-700 mb-3">{title}</h3>}
<div className="space-y-2" style={{ height: `${height}px` }}>
{data.map((item, index) => (
<div key={index} className="flex items-center gap-3">
<div className="w-20 text-xs text-gray-600 truncate">{item.label}</div>
<div className="flex-1 relative">
<div
className={`h-6 rounded-md transition-all ${item.color || 'bg-gradient-to-r from-blue-500 to-indigo-600'}`}
style={{ width: `${(item.value / maxValue) * 100}%` }}
/>
<span className="absolute right-2 top-0.5 text-xs font-semibold text-gray-700">
{typeof item.value === 'number' ? item.value.toLocaleString() : item.value}
</span>
</div>
</div>
))}
</div>
</div>
);
};
interface SimpleLineChartProps {
data: Array<{ label: string; value: number }>;
height?: number;
title?: string;
color?: string;
}
export const SimpleLineChart: React.FC<SimpleLineChartProps> = ({ data, height = 200, title, color = '#3b82f6' }) => {
if (!data || data.length === 0) return null;
const maxValue = Math.max(...data.map(d => d.value), 1);
const minValue = Math.min(...data.map(d => d.value), 0);
const range = maxValue - minValue || 1;
const points = data.map((item, index) => {
const x = (index / (data.length - 1 || 1)) * 100;
const y = 100 - ((item.value - minValue) / range) * 100;
return `${x},${y}`;
}).join(' ');
return (
<div className="w-full">
{title && <h3 className="text-sm font-semibold text-gray-700 mb-3">{title}</h3>}
<div className="relative" style={{ height: `${height}px` }}>
<svg viewBox="0 0 100 100" className="w-full h-full" preserveAspectRatio="none">
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
{data.map((item, index) => {
const x = (index / (data.length - 1 || 1)) * 100;
const y = 100 - ((item.value - minValue) / range) * 100;
return (
<circle
key={index}
cx={x}
cy={y}
r="1"
fill={color}
/>
);
})}
</svg>
<div className="absolute bottom-0 left-0 right-0 flex justify-between text-xs text-gray-500">
{data.map((item, index) => (
<span key={index} className="truncate" style={{ maxWidth: `${100 / data.length}%` }}>
{item.label}
</span>
))}
</div>
</div>
</div>
);
};
interface SimplePieChartProps {
data: Array<{ label: string; value: number; color?: string }>;
size?: number;
title?: string;
}
export const SimplePieChart: React.FC<SimplePieChartProps> = ({ data, size = 200, title }) => {
const total = data.reduce((sum, item) => sum + item.value, 0);
if (total === 0) return null;
const colors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1'
];
let currentAngle = -90;
const segments = data.map((item, index) => {
const percentage = (item.value / total) * 100;
const angle = (percentage / 100) * 360;
const startAngle = currentAngle;
currentAngle += angle;
const x1 = 50 + 50 * Math.cos((startAngle * Math.PI) / 180);
const y1 = 50 + 50 * Math.sin((startAngle * Math.PI) / 180);
const x2 = 50 + 50 * Math.cos((currentAngle * Math.PI) / 180);
const y2 = 50 + 50 * Math.sin((currentAngle * Math.PI) / 180);
const largeArc = angle > 180 ? 1 : 0;
return {
path: `M 50 50 L ${x1} ${y1} A 50 50 0 ${largeArc} 1 ${x2} ${y2} Z`,
color: item.color || colors[index % colors.length],
label: item.label,
value: item.value,
percentage: percentage.toFixed(1)
};
});
return (
<div className="w-full">
{title && <h3 className="text-sm font-semibold text-gray-700 mb-3">{title}</h3>}
<div className="flex flex-col md:flex-row items-center gap-6">
<div className="flex-shrink-0">
<svg viewBox="0 0 100 100" width={size} height={size} className="transform -rotate-90">
{segments.map((segment, index) => (
<path
key={index}
d={segment.path}
fill={segment.color}
stroke="white"
strokeWidth="0.5"
/>
))}
</svg>
</div>
<div className="flex-1 space-y-2">
{segments.map((segment, index) => (
<div key={index} className="flex items-center gap-2">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: segment.color }}
/>
<span className="text-sm text-gray-700 flex-1">{segment.label}</span>
<span className="text-sm font-semibold text-gray-900">
{segment.percentage}%
</span>
</div>
))}
</div>
</div>
</div>
);
};
interface KPICardProps {
title: string;
value: string | number;
change?: number;
icon?: React.ReactNode;
color?: string;
subtitle?: string;
}
export const KPICard: React.FC<KPICardProps> = ({
title,
value,
change,
icon,
color = 'blue',
subtitle
}) => {
const colorClasses = {
blue: 'from-blue-500 to-indigo-600',
green: 'from-emerald-500 to-green-600',
orange: 'from-orange-500 to-amber-600',
purple: 'from-purple-500 to-indigo-600',
red: 'from-rose-500 to-red-600',
};
return (
<div 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>
<p className="text-sm font-medium text-gray-600 mb-1">{title}</p>
<p className="text-3xl font-bold text-gray-900">
{typeof value === 'number' ? value.toLocaleString() : value}
</p>
{subtitle && <p className="text-xs text-gray-500 mt-1">{subtitle}</p>}
</div>
{icon && (
<div className={`p-3 rounded-lg bg-gradient-to-br ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue} text-white`}>
{icon}
</div>
)}
</div>
{change !== undefined && (
<div className={`flex items-center gap-1 text-sm font-medium ${
change >= 0 ? 'text-emerald-600' : 'text-rose-600'
}`}>
<TrendingUp className={`w-4 h-4 ${change < 0 ? 'rotate-180' : ''}`} />
<span>{Math.abs(change).toFixed(1)}%</span>
<span className="text-gray-500">vs previous period</span>
</div>
)}
</div>
);
};

View File

@@ -47,6 +47,12 @@ const AccountantRoute: React.FC<AccountantRouteProps> = ({
// Check if user is accountant
const isAccountant = userInfo?.role === 'accountant';
if (!isAccountant) {
// Redirect to appropriate dashboard based on role
if (userInfo?.role === 'admin') {
return <Navigate to="/admin/dashboard" replace />;
} else if (userInfo?.role === 'staff') {
return <Navigate to="/staff/dashboard" replace />;
}
return <Navigate to="/" replace />;
}

View File

@@ -47,6 +47,12 @@ const AdminRoute: React.FC<AdminRouteProps> = ({
const isAdmin = userInfo?.role === 'admin';
if (!isAdmin) {
// Redirect to appropriate dashboard based on role
if (userInfo?.role === 'staff') {
return <Navigate to="/staff/dashboard" replace />;
} else if (userInfo?.role === 'accountant') {
return <Navigate to="/accountant/dashboard" replace />;
}
return <Navigate to="/" replace />;
}

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuthModal } from '../contexts/AuthModalContext';
import { useAuthModal } from '../../contexts/AuthModalContext';
const ResetPasswordRouteHandler: React.FC = () => {
const { token } = useParams<{ token: string }>();

View File

@@ -47,6 +47,12 @@ const StaffRoute: React.FC<StaffRouteProps> = ({
const isStaff = userInfo?.role === 'staff';
if (!isStaff) {
// Redirect to appropriate dashboard based on role
if (userInfo?.role === 'admin') {
return <Navigate to="/admin/dashboard" replace />;
} else if (userInfo?.role === 'accountant') {
return <Navigate to="/accountant/dashboard" replace />;
}
return <Navigate to="/" replace />;
}

View File

@@ -3,3 +3,4 @@ export { default as AdminRoute } from './AdminRoute';
export { default as StaffRoute } from './StaffRoute';
export { default as AccountantRoute } from './AccountantRoute';
export { default as CustomerRoute } from './CustomerRoute';
export { default as ResetPasswordRouteHandler } from './ResetPasswordRouteHandler';

View File

@@ -92,6 +92,16 @@ const StaffChatNotification: React.FC = () => {
},
autoClose: 10000
});
} else if (data.type === 'housekeeping_task_assigned') {
const taskData = data.data;
const taskTypeLabel = taskData.task_type ? taskData.task_type.charAt(0).toUpperCase() + taskData.task_type.slice(1) : 'Housekeeping';
toast.success(`New ${taskTypeLabel} task assigned: Room ${taskData.room_number}`, {
onClick: () => {
navigate('/staff/advanced-rooms?tab=housekeeping');
},
autoClose: 10000
});
}
} catch (error) {
console.error('Error parsing notification:', error);

View File

@@ -2,8 +2,8 @@ import React from 'react';
import { CreditCard } from 'lucide-react';
interface PaymentMethodSelectorProps {
value: 'cash' | 'stripe';
onChange: (value: 'cash' | 'stripe') => void;
value: 'cash' | 'stripe' | 'borica' | 'paypal';
onChange: (value: 'cash' | 'stripe' | 'borica' | 'paypal') => void;
error?: string;
disabled?: boolean;
}
@@ -110,6 +110,55 @@ const PaymentMethodSelector: React.FC<
</div>
</div>
</label>
{/* Borica Payment */}
<label
className={`flex items-start p-4 border-2
rounded-lg cursor-pointer transition-all
${
value === 'borica'
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:border-indigo-300'
}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<input
type="radio"
name="payment_method"
value="borica"
checked={value === 'borica'}
onChange={(e) =>
onChange(e.target.value as 'borica')
}
disabled={disabled}
className="mt-1 mr-3"
/>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<CreditCard
className="w-5 h-5 text-indigo-600"
/>
<span className="font-medium text-gray-900">
Pay with Borica
</span>
<span className="text-xs bg-indigo-100
text-indigo-700 px-2 py-0.5 rounded-full
font-medium"
>
BG
</span>
</div>
<p className="text-sm text-gray-600">
Secure payment through Borica payment gateway.
Bulgarian payment system.
</p>
<div className="mt-2 text-xs text-gray-500
bg-white rounded px-2 py-1 inline-block"
>
🇧🇬 Bulgarian payment gateway
</div>
</div>
</label>
</div>
{error && (
@@ -126,7 +175,11 @@ const PaymentMethodSelector: React.FC<
💡 <strong>Note:</strong> {' '}
{value === 'cash'
? 'You will pay when checking in. Cash and card accepted at the hotel.'
: 'Your payment will be processed securely through Stripe.'}
: value === 'stripe'
? 'Your payment will be processed securely through Stripe.'
: value === 'borica'
? 'Your payment will be processed securely through Borica payment gateway.'
: 'Your payment will be processed securely.'}
</p>
</div>
</div>

View File

@@ -0,0 +1,87 @@
import React, { useState } from 'react';
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
import { Loader2 } from 'lucide-react';
interface PreloaderProps {
isLoading?: boolean;
}
const Preloader: React.FC<PreloaderProps> = ({ isLoading = true }) => {
const { settings } = useCompanySettings();
const [logoError, setLogoError] = useState(false);
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
// Get logo URL - handle both absolute and relative URLs
const logoUrl = settings.company_logo_url && !logoError
? settings.company_logo_url.startsWith('http')
? settings.company_logo_url
: `${baseUrl}${settings.company_logo_url}`
: null;
if (!isLoading) return null;
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-gradient-to-br from-slate-50/95 via-white/95 to-slate-50/95 backdrop-blur-sm">
<div className="text-center space-y-6 px-4">
<div className="relative">
{/* Animated background glow */}
<div className="absolute inset-0 bg-gradient-to-r from-amber-400 via-amber-500 to-amber-600 rounded-3xl blur-2xl opacity-40 animate-pulse"></div>
{/* Main container */}
<div className="relative bg-white/90 backdrop-blur-md p-8 rounded-3xl shadow-2xl border border-amber-200/50 min-w-[200px] min-h-[200px] flex flex-col items-center justify-center">
{/* Logo or fallback */}
{logoUrl && !logoError ? (
<div className="mb-6">
<img
src={logoUrl}
alt={settings.company_name || 'Logo'}
className="max-w-[120px] max-h-[120px] object-contain animate-pulse"
onError={() => setLogoError(true)}
/>
</div>
) : (
<div className="mb-6">
<Loader2 className="w-16 h-16 text-amber-600 animate-spin mx-auto" />
</div>
)}
{/* Loading bar */}
<div className="w-48 h-1 bg-amber-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-amber-400 via-amber-500 to-amber-600 rounded-full animate-shimmer"
style={{
animation: 'shimmer 1.5s infinite'
}}
></div>
</div>
</div>
</div>
{/* Loading text */}
<p className="text-slate-600 font-medium text-lg tracking-wide">
{settings.company_name ? `Loading ${settings.company_name}...` : 'Loading...'}
</p>
</div>
{/* CSS for shimmer animation */}
<style>{`
@keyframes shimmer {
0% {
transform: translateX(-100%);
width: 0%;
}
50% {
width: 70%;
}
100% {
transform: translateX(200%);
width: 100%;
}
}
`}</style>
</div>
);
};
export default Preloader;

View File

@@ -13,11 +13,13 @@ import {
Mail,
Calendar,
Star,
Users,
} from 'lucide-react';
import { useClickOutside } from '../../hooks/useClickOutside';
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
import { useAuthModal } from '../../contexts/AuthModalContext';
import { normalizeImageUrl } from '../../utils/imageUtils';
import InAppNotificationBell from '../notifications/InAppNotificationBell';
interface HeaderProps {
isAuthenticated?: boolean;
@@ -76,7 +78,6 @@ const Header: React.FC<HeaderProps> = ({
return (
<header className="bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f] sticky top-0 z-50 border-b border-[#d4af37]/20 shadow-2xl">
{}
<div className="hidden lg:block bg-[#0a0a0a]/50 border-b border-[#d4af37]/10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2">
<div className="flex items-center justify-end space-x-6 text-sm">
@@ -96,10 +97,8 @@ const Header: React.FC<HeaderProps> = ({
</div>
</div>
{}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
{}
<Link
to="/"
className="flex items-center space-x-3
@@ -203,41 +202,42 @@ const Header: React.FC<HeaderProps> = ({
</button>
</>
) : (
<div className="relative" ref={userMenuRef}>
<button
onClick={toggleUserMenu}
className="flex items-center space-x-3
px-3 py-2 rounded-sm hover:bg-white/10
transition-all duration-300 border border-transparent
hover:border-[#d4af37]/30"
>
{userInfo?.avatar ? (
<img
src={normalizeImageUrl(userInfo.avatar)}
alt={userInfo.name}
className="w-9 h-9 rounded-full
object-cover ring-2 ring-[#d4af37]/50"
/>
) : (
<div className="w-9 h-9 bg-gradient-to-br from-[#d4af37] to-[#c9a227]
rounded-full flex items-center
justify-center ring-2 ring-[#d4af37]/50 shadow-lg"
>
<span className="text-[#0f0f0f]
font-semibold text-sm"
<div className="flex items-center gap-3">
<InAppNotificationBell />
<div className="relative" ref={userMenuRef}>
<button
onClick={toggleUserMenu}
className="flex items-center space-x-3
px-3 py-2 rounded-sm hover:bg-white/10
transition-all duration-300 border border-transparent
hover:border-[#d4af37]/30"
>
{userInfo?.avatar ? (
<img
src={normalizeImageUrl(userInfo.avatar)}
alt={userInfo.name}
className="w-9 h-9 rounded-full
object-cover ring-2 ring-[#d4af37]/50"
/>
) : (
<div className="w-9 h-9 bg-gradient-to-br from-[#d4af37] to-[#c9a227]
rounded-full flex items-center
justify-center ring-2 ring-[#d4af37]/50 shadow-lg"
>
{userInfo?.name?.charAt(0)
.toUpperCase()}
</span>
</div>
)}
<span className="font-light text-white/90 tracking-wide">
{userInfo?.name}
</span>
</button>
<span className="text-[#0f0f0f]
font-semibold text-sm"
>
{userInfo?.name?.charAt(0)
.toUpperCase()}
</span>
</div>
)}
<span className="font-light text-white/90 tracking-wide">
{userInfo?.name}
</span>
</button>
{}
{isUserMenuOpen && (
{isUserMenuOpen && (
<div className="absolute right-0 mt-2
w-52 bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f]
rounded-sm shadow-2xl py-2 border border-[#d4af37]/20
@@ -293,6 +293,18 @@ const Header: React.FC<HeaderProps> = ({
<Star className="w-4 h-4" />
<span className="font-light tracking-wide">Loyalty Program</span>
</Link>
<Link
to="/group-bookings"
onClick={() => setIsUserMenuOpen(false)}
className="flex items-center space-x-3
px-4 py-2.5 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
transition-all duration-300 border-l-2 border-transparent
hover:border-[#d4af37]"
>
<Users className="w-4 h-4" />
<span className="font-light tracking-wide">Group Bookings</span>
</Link>
</>
)}
{userInfo?.role === 'admin' && (
@@ -356,79 +368,74 @@ const Header: React.FC<HeaderProps> = ({
<span className="font-light tracking-wide">Logout</span>
</button>
</div>
)}
)}
</div>
</div>
)}
<button
onClick={toggleMobileMenu}
className="md:hidden p-2 rounded-sm
hover:bg-white/10 border border-transparent
hover:border-[#d4af37]/30 transition-all duration-300"
>
{isMobileMenuOpen ? (
<X className="w-6 h-6 text-[#d4af37]" />
) : (
<Menu className="w-6 h-6 text-white/90" />
)}
</button>
</div>
{}
<button
onClick={toggleMobileMenu}
className="md:hidden p-2 rounded-sm
hover:bg-white/10 border border-transparent
hover:border-[#d4af37]/30 transition-all duration-300"
>
{isMobileMenuOpen ? (
<X className="w-6 h-6 text-[#d4af37]" />
) : (
<Menu className="w-6 h-6 text-white/90" />
)}
</button>
</div>
{}
{isMobileMenuOpen && (
<div className="md:hidden py-4 border-t
border-[#d4af37]/20 mt-4 bg-[#0a0a0a]/50
backdrop-blur-xl animate-fade-in rounded-b-sm"
>
<div className="flex flex-col space-y-1">
<Link
to="/"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
Home
</Link>
<Link
to="/rooms"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
Rooms
</Link>
<Link
to="/about"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
About
</Link>
<Link
to="/contact"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
Contact
</Link>
<div className="border-t border-[#d4af37]/20
{isMobileMenuOpen && (
<div className="md:hidden py-4 border-t border-[#d4af37]/20 mt-4 bg-[#0a0a0a]/50 backdrop-blur-xl animate-fade-in rounded-b-sm">
<div className="flex flex-col space-y-1">
<Link
to="/"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
Home
</Link>
<Link
to="/rooms"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
Rooms
</Link>
<Link
to="/about"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
About
</Link>
<Link
to="/contact"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
Contact
</Link>
<div className="border-t border-[#d4af37]/20
pt-3 mt-3"
>
{!isAuthenticated ? (
@@ -570,10 +577,11 @@ const Header: React.FC<HeaderProps> = ({
</button>
</>
)}
</div>
</div>
</div>
</div>
)}
)}
</div>
</div>
</header>
);

View File

@@ -13,6 +13,7 @@ import {
Receipt
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useResponsive } from '../../hooks';
interface SidebarAccountantProps {
isCollapsed?: boolean;
@@ -24,11 +25,11 @@ const SidebarAccountant: React.FC<SidebarAccountantProps> = ({
onToggle
}) => {
const [internalCollapsed, setInternalCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { logout } = useAuthStore();
const { isMobile, isTablet, isDesktop } = useResponsive();
const handleLogout = async () => {
try {
@@ -42,19 +43,12 @@ const SidebarAccountant: React.FC<SidebarAccountantProps> = ({
}
};
// Handle mobile responsiveness
// Close mobile menu when screen becomes desktop
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024);
if (window.innerWidth >= 1024) {
setIsMobileOpen(false);
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
if (isDesktop) {
setIsMobileOpen(false);
}
}, [isDesktop]);
const isCollapsed =
controlledCollapsed !== undefined

View File

@@ -14,25 +14,48 @@ import {
Menu,
X,
Award,
User
User,
Workflow,
CheckSquare,
Bell,
UserCheck,
Hotel,
Tag,
Package,
Shield,
Mail,
TrendingUp,
Building2,
Crown
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useResponsive } from '../../hooks';
interface SidebarAdminProps {
isCollapsed?: boolean;
onToggle?: () => void;
}
interface MenuGroup {
title: string;
icon?: React.ComponentType<{ className?: string }>;
items: Array<{
path: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
}>;
}
const SidebarAdmin: React.FC<SidebarAdminProps> = ({
isCollapsed: controlledCollapsed,
onToggle
}) => {
const [internalCollapsed, setInternalCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { logout } = useAuthStore();
const { isMobile, isTablet, isDesktop } = useResponsive();
const handleLogout = async () => {
try {
@@ -46,19 +69,12 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
}
};
// Close mobile menu when screen becomes desktop
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024);
if (window.innerWidth >= 1024) {
setIsMobileOpen(false);
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
if (isDesktop) {
setIsMobileOpen(false);
}
}, [isDesktop]);
const isCollapsed =
controlledCollapsed !== undefined
@@ -83,63 +99,161 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
}
};
const menuItems = [
{
path: '/admin/dashboard',
icon: LayoutDashboard,
label: 'Dashboard'
const menuGroups: MenuGroup[] = [
{
title: 'Overview',
icon: LayoutDashboard,
items: [
{
path: '/admin/dashboard',
icon: LayoutDashboard,
label: 'Dashboard'
},
]
},
{
path: '/admin/users',
icon: Users,
label: 'Users'
{
title: 'Operations',
icon: Building2,
items: [
{
path: '/admin/reception',
icon: LogIn,
label: 'Reception'
},
{
path: '/admin/advanced-rooms',
icon: Hotel,
label: 'Room Management'
},
]
},
{
path: '/admin/guest-profiles',
icon: User,
label: 'Guest Profiles'
{
title: 'Business',
icon: TrendingUp,
items: [
{
path: '/admin/business',
icon: FileText,
label: 'Business Dashboard'
},
]
},
{
path: '/admin/loyalty',
icon: Award,
label: 'Loyalty Program'
{
title: 'Analytics & Reports',
icon: BarChart3,
items: [
{
path: '/admin/analytics',
icon: BarChart3,
label: 'Analytics'
},
]
},
{
path: '/admin/business',
icon: FileText,
label: 'Business'
{
title: 'Users & Guests',
icon: Users,
items: [
{
path: '/admin/users',
icon: Users,
label: 'Users'
},
{
path: '/admin/guest-profiles',
icon: User,
label: 'Guest Profiles'
},
{
path: '/admin/group-bookings',
icon: UserCheck,
label: 'Group Bookings'
},
{
path: '/admin/loyalty',
icon: Award,
label: 'Loyalty Program'
},
]
},
{
path: '/admin/reception',
icon: LogIn,
label: 'Reception'
{
title: 'Products & Pricing',
icon: Tag,
items: [
{
path: '/admin/rate-plans',
icon: Tag,
label: 'Rate Plans'
},
{
path: '/admin/packages',
icon: Package,
label: 'Packages'
},
]
},
{
path: '/admin/page-content',
icon: Globe,
label: 'Page Content'
{
title: 'Marketing',
icon: Mail,
items: [
{
path: '/admin/email-campaigns',
icon: Mail,
label: 'Email Campaigns'
},
]
},
{
path: '/admin/analytics',
icon: BarChart3,
label: 'Analytics'
{
title: 'Content Management',
icon: Globe,
items: [
{
path: '/admin/page-content',
icon: Globe,
label: 'Page Content'
},
]
},
{
path: '/admin/settings',
icon: Settings,
label: 'Settings'
{
title: 'System',
icon: Settings,
items: [
{
path: '/admin/security',
icon: Shield,
label: 'Security'
},
{
path: '/admin/tasks',
icon: CheckSquare,
label: 'Tasks'
},
{
path: '/admin/workflows',
icon: Workflow,
label: 'Workflows'
},
{
path: '/admin/notifications',
icon: Bell,
label: 'Notifications'
},
{
path: '/admin/settings',
icon: Settings,
label: 'Settings'
},
]
},
];
const isActive = (path: string) => {
if (location.pathname === path) return true;
if (path === '/admin/settings' || path === '/admin/analytics' || path === '/admin/business' || path === '/admin/reception' || path === '/admin/page-content' || path === '/admin/loyalty') {
if (path === '/admin/settings' || path === '/admin/analytics' || path === '/admin/business' || path === '/admin/reception' || path === '/admin/advanced-rooms' || path === '/admin/page-content' || path === '/admin/loyalty') {
return location.pathname === path;
}
if (path === '/admin/reception') {
if (path === '/admin/reception' || path === '/admin/advanced-rooms') {
return location.pathname === path || location.pathname.startsWith(`${path}/`);
}
@@ -148,168 +262,242 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
return (
<>
{}
{isMobile && (
<button
onClick={handleMobileToggle}
className="fixed top-4 left-4 z-50 lg:hidden p-3 bg-gradient-to-r from-slate-900 to-slate-800 text-white rounded-xl shadow-2xl border border-slate-700 hover:from-slate-800 hover:to-slate-700 transition-all duration-200"
aria-label="Toggle menu"
>
{isMobileOpen ? (
<X className="w-6 h-6" />
) : (
<Menu className="w-6 h-6" />
)}
</button>
)}
{/* Mobile Menu Button - Always visible on mobile screens */}
<button
onClick={handleMobileToggle}
className="fixed top-2 left-2 sm:top-3 sm:left-3 z-50 lg:hidden p-2.5 sm:p-3 bg-gradient-to-br from-amber-500 via-amber-600 to-amber-700 text-white rounded-xl sm:rounded-2xl shadow-2xl border border-amber-400/30 hover:from-amber-600 hover:via-amber-700 hover:to-amber-800 transition-all duration-300 backdrop-blur-sm hover:scale-110"
aria-label="Toggle menu"
>
{isMobileOpen ? (
<X className="w-6 h-6" />
) : (
<Menu className="w-6 h-6" />
)}
</button>
{}
{isMobile && isMobileOpen && (
{/* Mobile Overlay */}
{isMobileOpen && (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden"
className="fixed inset-0 bg-black/80 backdrop-blur-md z-30 lg:hidden"
onClick={handleMobileToggle}
/>
)}
{}
<aside
className={`
fixed lg:static inset-y-0 left-0 z-40
bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900
bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950
text-white shadow-2xl
transition-all duration-300 ease-in-out flex flex-col
border-r border-amber-500/20
overflow-hidden
${isMobile
? (isMobileOpen ? 'translate-x-0' : '-translate-x-full')
: ''
? `fixed inset-y-0 left-0 z-40 w-80 ${isMobileOpen ? 'translate-x-0' : '-translate-x-full'}`
: `relative ${isCollapsed ? 'w-20' : 'w-80'} translate-x-0`
}
${!isMobile && (isCollapsed ? 'w-20' : 'w-72')}
${isMobile ? 'w-72' : ''}
border-r border-slate-700/50
`}
>
{}
<div className="p-6 border-b border-slate-700/50 flex items-center justify-between bg-gradient-to-r from-slate-800/50 to-slate-900/50 backdrop-blur-sm">
{/* Luxury background pattern */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
<div className="absolute inset-0" style={{
backgroundImage: `radial-gradient(circle at 2px 2px, rgba(251, 191, 36, 0.4) 1px, transparent 0)`,
backgroundSize: '50px 50px'
}}></div>
</div>
{/* Animated gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-amber-900/5 via-transparent to-amber-800/5 pointer-events-none"></div>
{/* Header */}
<div className="relative p-6 border-b border-amber-500/20 flex items-center justify-between bg-gradient-to-r from-amber-900/30 via-amber-800/20 to-transparent backdrop-blur-sm">
{!isCollapsed && (
<div className="flex items-center gap-3">
<div className="h-1 w-12 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h2 className="text-xl font-bold bg-gradient-to-r from-amber-100 to-amber-200 bg-clip-text text-transparent">
Admin Panel
</h2>
<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-2xl blur-lg opacity-60 animate-pulse"></div>
<div className="relative h-12 w-12 bg-gradient-to-br from-amber-500 via-amber-600 to-amber-700 rounded-2xl flex items-center justify-center shadow-2xl border border-amber-400/40">
<Crown className="w-6 h-6 text-white" />
<div className="absolute -top-1 -right-1 w-3 h-3 bg-gradient-to-br from-amber-300 to-amber-500 rounded-full shadow-lg animate-ping"></div>
</div>
</div>
<div>
<h2 className="text-lg font-bold bg-gradient-to-r from-amber-200 via-amber-100 to-amber-200 bg-clip-text text-transparent tracking-wide">
Admin Panel
</h2>
<p className="text-xs text-amber-300/60 font-medium mt-0.5">Luxury Hotel</p>
</div>
</div>
)}
{isCollapsed && !isMobile && (
<div className="w-full flex justify-center">
<div className="h-8 w-8 bg-gradient-to-br from-amber-400 to-amber-600 rounded-xl flex items-center justify-center shadow-lg">
<span className="text-slate-900 font-bold text-sm">A</span>
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-amber-400 to-amber-600 rounded-2xl blur-lg opacity-60 animate-pulse"></div>
<div className="relative h-12 w-12 bg-gradient-to-br from-amber-500 via-amber-600 to-amber-700 rounded-2xl flex items-center justify-center shadow-2xl border border-amber-400/40">
<Crown className="w-6 h-6 text-white" />
</div>
</div>
</div>
)}
{!isMobile && (
<button
onClick={handleToggle}
className="p-2.5 rounded-xl bg-slate-800/50 hover:bg-slate-700/50 border border-slate-700/50 hover:border-amber-500/50 transition-all duration-200 ml-auto shadow-lg hover:shadow-xl"
className="p-2.5 rounded-xl bg-slate-800/70 hover:bg-amber-500/20 border border-slate-700/50 hover:border-amber-500/50 transition-all duration-300 ml-auto shadow-lg hover:shadow-xl backdrop-blur-sm hover:scale-110"
aria-label="Toggle sidebar"
>
{isCollapsed ? (
<ChevronRight className="w-5 h-5 text-amber-200" />
<ChevronRight className="w-5 h-5 text-amber-300" />
) : (
<ChevronLeft className="w-5 h-5 text-amber-200" />
<ChevronLeft className="w-5 h-5 text-amber-300" />
)}
</button>
)}
</div>
{}
<nav className="flex-1 overflow-y-auto py-4 px-3 custom-scrollbar">
<ul className="space-y-2">
{menuItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-6 px-4 custom-scrollbar relative">
<ul className="space-y-6">
{menuGroups.map((group, groupIndex) => {
return (
<li key={item.path}>
<Link
to={item.path}
onClick={handleLinkClick}
className={`
flex items-center
space-x-3 px-4 py-3.5 rounded-xl
transition-all duration-200 group relative
${active
? 'bg-gradient-to-r from-amber-500/20 to-amber-600/20 text-amber-100 shadow-lg border border-amber-500/30'
: 'text-slate-300 hover:bg-slate-800/50 hover:text-amber-100 border border-transparent hover:border-slate-700/50'
}
${isCollapsed && !isMobile ? 'justify-center' : ''}
`}
title={isCollapsed && !isMobile ? item.label : undefined}
>
{active && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-gradient-to-b from-amber-400 to-amber-600 rounded-r-full"></div>
)}
<Icon className={`
flex-shrink-0 transition-transform duration-200
${active ? 'text-amber-400' : 'text-slate-400 group-hover:text-amber-400'}
${isCollapsed && !isMobile ? 'w-6 h-6' : 'w-5 h-5'}
`} />
{(!isCollapsed || isMobile) && (
<span className={`
font-semibold transition-all duration-200
${active ? 'text-amber-100' : 'group-hover:text-amber-100'}
`}>
{item.label}
</span>
)}
{active && !isCollapsed && (
<div className="ml-auto w-2 h-2 bg-amber-400 rounded-full animate-pulse"></div>
)}
</Link>
<li key={groupIndex} className="space-y-2">
{!isCollapsed && (
<div className="px-4 mb-3">
<div className="flex items-center gap-2.5">
{group.icon && (
<group.icon className="w-3.5 h-3.5 text-amber-400/70" />
)}
<span className="text-[10px] font-bold uppercase tracking-widest text-amber-300/40 letter-spacing-wider">
{group.title}
</span>
</div>
<div className="h-px bg-gradient-to-r from-amber-500/30 via-amber-500/15 to-transparent mt-2.5"></div>
</div>
)}
<ul className="space-y-1.5">
{group.items.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<li key={item.path}>
<Link
to={item.path}
onClick={handleLinkClick}
className={`
flex items-center
space-x-3 px-4 py-3.5 rounded-xl
transition-all duration-300 group relative overflow-hidden
${active
? 'bg-gradient-to-r from-amber-500/30 via-amber-600/25 to-amber-500/30 text-amber-50 shadow-xl shadow-amber-500/25 border border-amber-500/50'
: 'text-slate-300 hover:bg-gradient-to-r hover:from-slate-800/70 hover:via-slate-800/50 hover:to-slate-800/70 hover:text-amber-100 border border-transparent hover:border-amber-500/20'
}
${isCollapsed && !isMobile ? 'justify-center px-3' : ''}
`}
title={isCollapsed && !isMobile ? item.label : undefined}
>
{active && (
<>
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-12 bg-gradient-to-b from-amber-400 via-amber-500 to-amber-600 rounded-r-full shadow-lg shadow-amber-500/60"></div>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-amber-500/10 to-transparent"></div>
</>
)}
<div className={`
relative flex items-center justify-center z-10
${active
? 'bg-gradient-to-br from-amber-500/40 to-amber-600/30 p-2.5 rounded-xl shadow-lg'
: 'p-2.5 rounded-xl group-hover:bg-amber-500/15 transition-all duration-300'
}
`}>
<Icon className={`
flex-shrink-0 transition-all duration-300
${active
? 'text-amber-200 scale-110 drop-shadow-lg'
: 'text-slate-400 group-hover:text-amber-400 group-hover:scale-110'
}
${isCollapsed && !isMobile ? 'w-5 h-5' : 'w-4 h-4'}
`} />
</div>
{(!isCollapsed || isMobile) && (
<span className={`
font-medium text-sm transition-all duration-300 relative z-10
${active
? 'text-amber-50 font-semibold tracking-wide'
: 'group-hover:text-amber-100'
}
`}>
{item.label}
</span>
)}
{active && !isCollapsed && (
<div className="ml-auto relative z-10">
<div className="w-2 h-2 bg-amber-400 rounded-full animate-pulse shadow-lg shadow-amber-400/60"></div>
</div>
)}
</Link>
</li>
);
})}
</ul>
</li>
);
})}
</ul>
</nav>
{}
<div className="p-4 border-t border-slate-700/50">
<button
onClick={handleLogout}
className={`
w-full flex items-center
space-x-3 px-4 py-3.5 rounded-xl
transition-all duration-200 group relative
text-slate-300 hover:bg-gradient-to-r hover:from-rose-600/20 hover:to-rose-700/20
hover:text-rose-100 border border-transparent hover:border-rose-500/30
${isCollapsed && !isMobile ? 'justify-center' : ''}
`}
title={isCollapsed && !isMobile ? 'Logout' : undefined}
>
<LogOut className={`
flex-shrink-0 transition-transform duration-200
text-slate-400 group-hover:text-rose-400 group-hover:rotate-12
${isCollapsed && !isMobile ? 'w-6 h-6' : 'w-5 h-5'}
`} />
{(!isCollapsed || isMobile) && (
<span className="font-semibold transition-all duration-200 group-hover:text-rose-100">
Logout
</span>
)}
</button>
</div>
{/* Footer */}
<div className="relative border-t border-amber-500/20 bg-gradient-to-r from-slate-900/60 to-slate-950/60 backdrop-blur-sm">
<div className="p-4">
<button
onClick={handleLogout}
className={`
w-full flex items-center
space-x-3 px-4 py-3.5 rounded-xl
transition-all duration-300 group relative overflow-hidden
text-slate-300 hover:bg-gradient-to-r hover:from-rose-600/25 hover:via-rose-700/20 hover:to-rose-600/25
hover:text-rose-100 border border-transparent hover:border-rose-500/40
${isCollapsed && !isMobile ? 'justify-center px-3' : ''}
`}
title={isCollapsed && !isMobile ? 'Logout' : undefined}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-rose-500/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div className={`
relative flex items-center justify-center z-10
p-2.5 rounded-xl group-hover:bg-rose-500/15 transition-all duration-300
`}>
<LogOut className={`
flex-shrink-0 transition-all duration-300
text-slate-400 group-hover:text-rose-400 group-hover:rotate-12
${isCollapsed && !isMobile ? 'w-5 h-5' : 'w-4 h-4'}
`} />
</div>
{(!isCollapsed || isMobile) && (
<span className="font-medium text-sm transition-all duration-300 relative z-10 group-hover:text-rose-100">
Logout
</span>
)}
</button>
</div>
{}
<div className="p-4 border-t border-slate-700/50 bg-gradient-to-r from-slate-800/50 to-slate-900/50 backdrop-blur-sm">
{(!isCollapsed || isMobile) ? (
<div className="text-xs text-slate-400 text-center space-y-1">
<p className="font-semibold text-amber-200/80">Admin Dashboard</p>
<p className="text-slate-500">
© {new Date().getFullYear()} Luxury Hotel
</p>
</div>
) : (
<div className="flex justify-center">
<div className="w-3 h-3 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full shadow-lg animate-pulse"></div>
</div>
)}
<div className="px-4 pb-4">
{(!isCollapsed || isMobile) ? (
<div className="text-xs text-center space-y-2.5 p-3.5 rounded-xl bg-gradient-to-r from-amber-900/30 to-amber-800/20 border border-amber-500/15 backdrop-blur-sm">
<div className="flex items-center justify-center gap-2">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full blur-md opacity-50 animate-pulse"></div>
<div className="relative w-2 h-2 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full shadow-lg"></div>
</div>
<p className="font-semibold text-amber-200/90 tracking-wide">System Active</p>
</div>
<p className="text-amber-300/50 text-[10px] font-medium tracking-wider">
© {new Date().getFullYear()} Luxury Hotel Management
</p>
</div>
) : (
<div className="flex justify-center">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full blur-md opacity-50 animate-pulse"></div>
<div className="relative w-3 h-3 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full shadow-lg"></div>
</div>
</div>
)}
</div>
</div>
</aside>
</>

View File

@@ -13,10 +13,12 @@ import {
CreditCard,
MessageCircle,
Award,
Users
Users,
Wrench
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useChatNotifications } from '../../contexts/ChatNotificationContext';
import { useResponsive } from '../../hooks';
interface SidebarStaffProps {
isCollapsed?: boolean;
@@ -28,12 +30,12 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
onToggle
}) => {
const [internalCollapsed, setInternalCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { logout } = useAuthStore();
const { unreadCount } = useChatNotifications();
const { isMobile, isTablet, isDesktop } = useResponsive();
const handleLogout = async () => {
try {
@@ -47,19 +49,12 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
}
};
// Close mobile menu when screen becomes desktop
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024);
if (window.innerWidth >= 1024) {
setIsMobileOpen(false);
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
if (isDesktop) {
setIsMobileOpen(false);
}
}, [isDesktop]);
const isCollapsed =
controlledCollapsed !== undefined
@@ -116,6 +111,11 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
icon: Users,
label: 'Guest Profiles'
},
{
path: '/staff/advanced-rooms',
icon: Wrench,
label: 'Room Management'
},
{
path: '/staff/chats',
icon: MessageCircle,

View File

@@ -0,0 +1,139 @@
import React, { useState, useEffect } from 'react';
import { Bell, X } from 'lucide-react';
import { toast } from 'react-toastify';
import notificationService, { Notification } from '../../services/api/notificationService';
import { formatDate } from '../../utils/format';
const InAppNotificationBell: React.FC = () => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showDropdown, setShowDropdown] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
loadNotifications();
// Poll for new notifications every 30 seconds
const interval = setInterval(loadNotifications, 30000);
return () => clearInterval(interval);
}, []);
const loadNotifications = async () => {
try {
const response = await notificationService.getMyNotifications({
status: 'delivered',
limit: 10,
});
const notifs = response.data.data || [];
setNotifications(notifs);
setUnreadCount(notifs.filter(n => !n.read_at).length);
} catch (error) {
// Silently fail
}
};
const handleMarkAsRead = async (notificationId: number) => {
try {
await notificationService.markAsRead(notificationId);
setNotifications(notifications.map(n =>
n.id === notificationId ? { ...n, status: 'read' as any, read_at: new Date().toISOString() } : n
));
setUnreadCount(Math.max(0, unreadCount - 1));
} catch (error: any) {
toast.error(error.message || 'Failed to mark as read');
}
};
const handleMarkAllAsRead = async () => {
try {
setLoading(true);
const unread = notifications.filter(n => !n.read_at);
await Promise.all(unread.map(n => notificationService.markAsRead(n.id)));
setNotifications(notifications.map(n => ({ ...n, status: 'read' as any, read_at: new Date().toISOString() })));
setUnreadCount(0);
} catch (error: any) {
toast.error(error.message || 'Failed to mark all as read');
} finally {
setLoading(false);
}
};
return (
<div className="relative">
<button
onClick={() => setShowDropdown(!showDropdown)}
className="relative p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
<Bell className="w-6 h-6" />
{unreadCount > 0 && (
<span className="absolute top-0 right-0 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{showDropdown && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowDropdown(false)}
/>
<div className="absolute right-0 mt-2 w-80 bg-white rounded-xl shadow-2xl border border-gray-200 z-50 max-h-96 overflow-y-auto">
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="font-semibold text-gray-900">Notifications</h3>
{unreadCount > 0 && (
<button
onClick={handleMarkAllAsRead}
disabled={loading}
className="text-xs text-indigo-600 hover:text-indigo-700 font-medium"
>
Mark all read
</button>
)}
</div>
<div className="divide-y divide-gray-200">
{notifications.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500">
No notifications
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${
!notification.read_at ? 'bg-blue-50' : ''
}`}
onClick={() => {
if (!notification.read_at) {
handleMarkAsRead(notification.id);
}
}}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900">
{notification.subject || notification.notification_type.replace('_', ' ')}
</p>
<p className="text-xs text-gray-600 mt-1 line-clamp-2">
{notification.content}
</p>
<p className="text-xs text-gray-400 mt-2">
{formatDate(new Date(notification.created_at), 'short')}
</p>
</div>
{!notification.read_at && (
<div className="w-2 h-2 bg-blue-500 rounded-full ml-2 mt-1" />
)}
</div>
</div>
))
)}
</div>
</div>
</>
)}
</div>
);
};
export default InAppNotificationBell;

View File

@@ -0,0 +1,302 @@
import React, { useState, useEffect } from 'react';
import { Bell, Mail, MessageSquare, Smartphone, Save } from 'lucide-react';
import { toast } from 'react-toastify';
import { Loading } from '../common';
import notificationService, { NotificationPreferences } from '../../services/api/notificationService';
const NotificationPreferences: React.FC = () => {
const [preferences, setPreferences] = useState<NotificationPreferences | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
loadPreferences();
}, []);
const loadPreferences = async () => {
try {
setLoading(true);
const response = await notificationService.getPreferences();
setPreferences(response.data.data);
} catch (error: any) {
toast.error(error.message || 'Failed to load preferences');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!preferences) return;
try {
setSaving(true);
await notificationService.updatePreferences(preferences);
toast.success('Preferences saved successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to save preferences');
} finally {
setSaving(false);
}
};
const updatePreference = (key: keyof NotificationPreferences, value: boolean) => {
if (preferences) {
setPreferences({ ...preferences, [key]: value });
}
};
if (loading) {
return <Loading text="Loading preferences..." />;
}
if (!preferences) {
return <div>Failed to load preferences</div>;
}
return (
<div className="space-y-6">
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Notification Preferences</h2>
{/* Global Channel Preferences */}
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Channel Preferences</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.email_enabled}
onChange={(e) => updatePreference('email_enabled', e.target.checked)}
className="w-5 h-5 text-indigo-600 rounded focus:ring-indigo-500"
/>
<Mail className="w-5 h-5 text-blue-500" />
<span className="font-medium text-gray-900">Email</span>
</label>
<label className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.sms_enabled}
onChange={(e) => updatePreference('sms_enabled', e.target.checked)}
className="w-5 h-5 text-indigo-600 rounded focus:ring-indigo-500"
/>
<MessageSquare className="w-5 h-5 text-green-500" />
<span className="font-medium text-gray-900">SMS</span>
</label>
<label className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.push_enabled}
onChange={(e) => updatePreference('push_enabled', e.target.checked)}
className="w-5 h-5 text-indigo-600 rounded focus:ring-indigo-500"
/>
<Bell className="w-5 h-5 text-purple-500" />
<span className="font-medium text-gray-900">Push Notifications</span>
</label>
<label className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.in_app_enabled}
onChange={(e) => updatePreference('in_app_enabled', e.target.checked)}
className="w-5 h-5 text-indigo-600 rounded focus:ring-indigo-500"
/>
<Smartphone className="w-5 h-5 text-indigo-500" />
<span className="font-medium text-gray-900">In-App</span>
</label>
</div>
</div>
{/* Type-Specific Preferences */}
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Booking Confirmations</h3>
<div className="grid grid-cols-2 gap-4">
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.booking_confirmation_email}
onChange={(e) => updatePreference('booking_confirmation_email', e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700">Email</span>
</label>
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.booking_confirmation_sms}
onChange={(e) => updatePreference('booking_confirmation_sms', e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700">SMS</span>
</label>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Payment Receipts</h3>
<div className="grid grid-cols-2 gap-4">
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.payment_receipt_email}
onChange={(e) => updatePreference('payment_receipt_email', e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700">Email</span>
</label>
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.payment_receipt_sms}
onChange={(e) => updatePreference('payment_receipt_sms', e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700">SMS</span>
</label>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Reminders</h3>
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-gray-700 mb-2">Pre-Arrival</p>
<div className="grid grid-cols-2 gap-4">
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.pre_arrival_reminder_email}
onChange={(e) => updatePreference('pre_arrival_reminder_email', e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700">Email</span>
</label>
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.pre_arrival_reminder_sms}
onChange={(e) => updatePreference('pre_arrival_reminder_sms', e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700">SMS</span>
</label>
</div>
</div>
<div>
<p className="text-sm font-medium text-gray-700 mb-2">Check-In</p>
<div className="grid grid-cols-2 gap-4">
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.check_in_reminder_email}
onChange={(e) => updatePreference('check_in_reminder_email', e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700">Email</span>
</label>
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.check_in_reminder_sms}
onChange={(e) => updatePreference('check_in_reminder_sms', e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700">SMS</span>
</label>
</div>
</div>
<div>
<p className="text-sm font-medium text-gray-700 mb-2">Check-Out</p>
<div className="grid grid-cols-2 gap-4">
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.check_out_reminder_email}
onChange={(e) => updatePreference('check_out_reminder_email', e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700">Email</span>
</label>
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.check_out_reminder_sms}
onChange={(e) => updatePreference('check_out_reminder_sms', e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700">SMS</span>
</label>
</div>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Marketing & Updates</h3>
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-gray-700 mb-2">Marketing Campaigns</p>
<div className="grid grid-cols-2 gap-4">
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.marketing_campaign_email}
onChange={(e) => updatePreference('marketing_campaign_email', e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700">Email</span>
</label>
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.marketing_campaign_sms}
onChange={(e) => updatePreference('marketing_campaign_sms', e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700">SMS</span>
</label>
</div>
</div>
<div>
<p className="text-sm font-medium text-gray-700 mb-2">Loyalty Updates</p>
<div className="grid grid-cols-2 gap-4">
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.loyalty_update_email}
onChange={(e) => updatePreference('loyalty_update_email', e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700">Email</span>
</label>
<label className="flex items-center gap-3 p-3 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={preferences.loyalty_update_sms}
onChange={(e) => updatePreference('loyalty_update_sms', e.target.checked)}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-gray-700">SMS</span>
</label>
</div>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-6 border-t border-gray-200 mt-6">
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2"
>
<Save className="w-4 h-4" />
{saving ? 'Saving...' : 'Save Preferences'}
</button>
</div>
</div>
</div>
);
};
export default NotificationPreferences;

View File

@@ -0,0 +1,210 @@
import React, { useState, useEffect } from 'react';
import { X, Plus, Trash2, Edit } from 'lucide-react';
import { toast } from 'react-toastify';
import notificationService, { NotificationTemplate } from '../../services/api/notificationService';
interface NotificationTemplatesModalProps {
onClose: () => void;
}
const NotificationTemplatesModal: React.FC<NotificationTemplatesModalProps> = ({ onClose }) => {
const [templates, setTemplates] = useState<NotificationTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [formData, setFormData] = useState({
name: '',
notification_type: 'booking_confirmation',
channel: 'email',
subject: '',
content: '',
});
useEffect(() => {
loadTemplates();
}, []);
const loadTemplates = async () => {
try {
setLoading(true);
const response = await notificationService.getTemplates();
setTemplates(response.data.data || []);
} catch (error: any) {
toast.error(error.message || 'Failed to load templates');
} finally {
setLoading(false);
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || !formData.content.trim()) {
toast.error('Name and content are required');
return;
}
try {
await notificationService.createTemplate(formData);
toast.success('Template created successfully');
setShowCreate(false);
setFormData({
name: '',
notification_type: 'booking_confirmation',
channel: 'email',
subject: '',
content: '',
});
loadTemplates();
} catch (error: any) {
toast.error(error.message || 'Failed to create template');
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-gray-200 p-6 flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">Notification Templates</h2>
<div className="flex items-center gap-2">
<button
onClick={() => setShowCreate(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-4 h-4" />
Create Template
</button>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-6">
{showCreate ? (
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Type</label>
<select
value={formData.notification_type}
onChange={(e) => setFormData({ ...formData, notification_type: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<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={formData.channel}
onChange={(e) => setFormData({ ...formData, channel: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<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>
{(formData.channel === 'email' || formData.channel === 'push') && (
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Subject</label>
<input
type="text"
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Use {{variable_name}} for variables"
/>
</div>
)}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Content *</label>
<textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
rows={8}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Use {{variable_name}} for variables (e.g., {{booking_number}}, {{guest_name}})"
required
/>
<p className="text-xs text-gray-500 mt-1">
Available variables: booking_number, guest_name, check_in, check_out, total_price, payment_amount, etc.
</p>
</div>
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={() => setShowCreate(false)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
Create Template
</button>
</div>
</form>
) : (
<>
{loading ? (
<div className="text-center py-8">Loading templates...</div>
) : templates.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No templates found. Create your first template.
</div>
) : (
<div className="space-y-4">
{templates.map((template) => (
<div key={template.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="font-semibold text-gray-900">{template.name}</h3>
<p className="text-sm text-gray-500">
{template.notification_type.replace('_', ' ')} {template.channel}
</p>
</div>
</div>
{template.subject && (
<p className="text-sm font-medium text-gray-700 mb-2">Subject: {template.subject}</p>
)}
<p className="text-sm text-gray-600 line-clamp-3">{template.content}</p>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
);
};
export default NotificationTemplatesModal;

View File

@@ -0,0 +1,241 @@
import React, { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import { toast } from 'react-toastify';
import notificationService from '../../services/api/notificationService';
interface SendNotificationModalProps {
onClose: () => void;
onSuccess: () => void;
initialData?: {
user_id?: number;
booking_id?: number;
payment_id?: number;
};
}
const SendNotificationModal: React.FC<SendNotificationModalProps> = ({ onClose, onSuccess, initialData }) => {
const [formData, setFormData] = useState({
user_id: initialData?.user_id?.toString() || '',
notification_type: 'custom',
channel: 'email',
subject: '',
content: '',
priority: 'normal',
scheduled_at: '',
booking_id: initialData?.booking_id?.toString() || '',
payment_id: initialData?.payment_id?.toString() || '',
});
const [loading, setLoading] = useState(false);
const [templates, setTemplates] = useState<any[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
useEffect(() => {
loadTemplates();
}, [formData.notification_type, formData.channel]);
const loadTemplates = async () => {
try {
const response = await notificationService.getTemplates({
notification_type: formData.notification_type,
channel: formData.channel,
});
setTemplates(response.data.data || []);
} catch (error) {
// Ignore errors
}
};
const handleTemplateSelect = (templateId: string) => {
const template = templates.find(t => t.id.toString() === templateId);
if (template) {
setFormData({
...formData,
subject: template.subject || '',
content: template.content || '',
});
setSelectedTemplate(templateId);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.content.trim()) {
toast.error('Content is required');
return;
}
try {
setLoading(true);
await notificationService.sendNotification({
user_id: formData.user_id ? parseInt(formData.user_id) : undefined,
notification_type: formData.notification_type,
channel: formData.channel,
content: formData.content,
subject: formData.subject || undefined,
priority: formData.priority,
scheduled_at: formData.scheduled_at || undefined,
booking_id: formData.booking_id ? parseInt(formData.booking_id) : undefined,
payment_id: formData.payment_id ? parseInt(formData.payment_id) : undefined,
});
toast.success('Notification sent successfully');
onSuccess();
} catch (error: any) {
toast.error(error.message || 'Failed to send notification');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-gray-200 p-6 flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">Send Notification</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Type</label>
<select
value={formData.notification_type}
onChange={(e) => setFormData({ ...formData, notification_type: e.target.value, selectedTemplate: '' })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<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>
<option value="custom">Custom</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Channel</label>
<select
value={formData.channel}
onChange={(e) => setFormData({ ...formData, channel: e.target.value, selectedTemplate: '' })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<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>
{templates.length > 0 && (
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Use Template</label>
<select
value={selectedTemplate}
onChange={(e) => handleTemplateSelect(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">Select a template...</option>
{templates.map((template) => (
<option key={template.id} value={template.id.toString()}>
{template.name}
</option>
))}
</select>
</div>
)}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">User ID</label>
<input
type="number"
value={formData.user_id}
onChange={(e) => setFormData({ ...formData, user_id: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Leave empty for system-wide"
/>
</div>
{(formData.channel === 'email' || formData.channel === 'push') && (
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Subject</label>
<input
type="text"
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
)}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Content <span className="text-red-500">*</span>
</label>
<textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
rows={6}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Priority</label>
<select
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Schedule (optional)</label>
<input
type="datetime-local"
value={formData.scheduled_at}
onChange={(e) => setFormData({ ...formData, scheduled_at: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
{loading ? 'Sending...' : 'Send Notification'}
</button>
</div>
</form>
</div>
</div>
);
};
export default SendNotificationModal;

View File

@@ -0,0 +1,227 @@
import React, { useState, useEffect } from 'react';
import { createBoricaPayment } from '../../services/api/paymentService';
import { X, Loader2, AlertCircle, CheckCircle, CreditCard } from 'lucide-react';
import { toast } from 'react-toastify';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
interface BoricaPaymentModalProps {
isOpen: boolean;
bookingId: number;
amount: number;
currency?: string;
onSuccess: () => void;
onClose: () => void;
}
const BoricaPaymentModal: React.FC<BoricaPaymentModalProps> = ({
isOpen,
bookingId,
amount,
currency: propCurrency,
onSuccess,
onClose,
}) => {
const { currency: contextCurrency } = useFormatCurrency();
const currency = propCurrency || contextCurrency || 'BGN';
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [paymentRequest, setPaymentRequest] = useState<any>(null);
useEffect(() => {
if (!isOpen) return;
const initializeBorica = async () => {
try {
setLoading(true);
setError(null);
const currentUrl = window.location.origin;
const returnUrl = `${currentUrl}/payment/borica/return?bookingId=${bookingId}`;
const response = await createBoricaPayment(
bookingId,
amount,
currency,
returnUrl
);
if (response.success && response.data) {
const { payment_request } = response.data;
if (!payment_request) {
throw new Error('Payment request not received from server');
}
setPaymentRequest(payment_request);
} else {
throw new Error(response.message || 'Failed to initialize Borica payment');
}
} catch (err: any) {
console.error('Error initializing Borica:', err);
const errorMessage = err.response?.data?.message || err.message || 'Failed to initialize Borica payment';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
initializeBorica();
}, [isOpen, bookingId, amount, currency]);
const handleSubmitPayment = () => {
if (!paymentRequest) return;
// Create a form and submit it to Borica gateway
const form = document.createElement('form');
form.method = 'POST';
form.action = paymentRequest.gateway_url;
form.style.display = 'none';
// Add all form fields
const fields = {
TERMINAL: paymentRequest.terminal_id,
MERCHANT: paymentRequest.merchant_id,
ORDER: paymentRequest.order_id,
AMOUNT: paymentRequest.amount,
CURRENCY: paymentRequest.currency,
DESC: paymentRequest.description,
TIMESTAMP: paymentRequest.timestamp,
P_SIGN: paymentRequest.signature,
TRTYPE: paymentRequest.trtype,
BACKREF: paymentRequest.return_url,
};
Object.entries(fields).forEach(([key, value]) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = value as string;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-2 sm:p-4">
<div
className="fixed inset-0 bg-black/90 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative w-full max-w-md max-h-[95vh] bg-gradient-to-br from-[#0a0a0a] via-[#1a1a1a] to-[#0a0a0a] rounded-xl sm:rounded-2xl border border-[#d4af37]/30 shadow-2xl shadow-[#d4af37]/20 overflow-hidden flex flex-col">
{/* Header */}
<div className="px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-[#d4af37]/20 bg-gradient-to-r from-[#1a1a1a] to-[#0a0a0a] flex-shrink-0">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base sm:text-lg md:text-xl font-serif font-bold text-white tracking-tight truncate flex-1">
Borica Payment
</h2>
<button
onClick={onClose}
className="p-1.5 sm:p-2 hover:bg-[#d4af37]/10 rounded-lg transition-colors text-gray-400 hover:text-white flex-shrink-0"
>
<X className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 sm:p-4 md:p-6">
{loading ? (
<div className="flex flex-col items-center justify-center py-8 sm:py-12">
<Loader2 className="w-10 h-10 sm:w-12 sm:h-12 animate-spin text-[#d4af37] mb-3 sm:mb-4" />
<p className="text-xs sm:text-sm text-gray-400">Initializing Borica payment...</p>
</div>
) : error ? (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3 sm:p-4">
<div className="flex items-start gap-2 sm:gap-3">
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<h3 className="text-xs sm:text-sm font-semibold text-red-300 mb-1">
Payment Initialization Failed
</h3>
<p className="text-xs text-red-200/80 leading-relaxed">
{error || 'Unable to initialize Borica payment. Please try again.'}
</p>
</div>
</div>
</div>
) : paymentRequest ? (
<div className="space-y-4 sm:space-y-6">
<div className="text-center">
<div className="mb-3 sm:mb-4 flex justify-center">
<CreditCard className="w-12 h-12 sm:w-16 sm:h-16 text-[#d4af37]" />
</div>
<h3 className="text-base sm:text-lg font-serif font-semibold text-white mb-1.5 sm:mb-2">
Complete Payment with Borica
</h3>
<p className="text-xs sm:text-sm text-gray-400 mb-3 sm:mb-4 leading-relaxed px-2">
You will be redirected to Borica payment gateway to securely complete your payment of{' '}
<span className="font-semibold text-[#d4af37]">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount)}
</span>
</p>
</div>
<div className="bg-[#1a1a1a]/50 border border-[#d4af37]/20 rounded-lg p-3 sm:p-4 space-y-2 sm:space-y-3">
<div className="flex justify-between items-center text-xs sm:text-sm">
<span className="text-gray-400">Order ID:</span>
<span className="text-white font-mono">{paymentRequest.order_id}</span>
</div>
<div className="flex justify-between items-center text-xs sm:text-sm">
<span className="text-gray-400">Amount:</span>
<span className="text-[#d4af37] font-semibold">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount)}
</span>
</div>
</div>
<div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-3 sm:p-4">
<div className="flex items-start gap-2 sm:gap-3">
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-blue-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-blue-200/80 leading-relaxed">
Your payment will be processed securely through Borica payment gateway.
You will be redirected to complete the transaction.
</p>
</div>
</div>
</div>
</div>
) : null}
</div>
{/* Footer */}
{!loading && !error && paymentRequest && (
<div className="px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-t border-[#d4af37]/20 bg-gradient-to-r from-[#1a1a1a] to-[#0a0a0a] flex-shrink-0 flex gap-2 sm:gap-3">
<button
onClick={onClose}
className="flex-1 px-3 sm:px-4 py-2 sm:py-2.5 text-xs sm:text-sm font-medium text-gray-300 bg-[#1a1a1a] border border-gray-700 rounded-lg hover:bg-[#2a2a2a] hover:border-gray-600 transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmitPayment}
className="flex-1 px-3 sm:px-4 py-2 sm:py-2.5 text-xs sm:text-sm font-medium text-white bg-gradient-to-r from-[#d4af37] to-[#b8941f] rounded-lg hover:from-[#e5c048] hover:to-[#c9a428] transition-all shadow-lg shadow-[#d4af37]/20 flex items-center justify-center gap-2"
>
<CreditCard className="w-4 h-4" />
Proceed to Payment
</button>
</div>
)}
</div>
</div>
);
};
export default BoricaPaymentModal;

View File

@@ -0,0 +1,632 @@
import React, { useState, useEffect } from 'react';
import { X, Plus, Trash2, Calendar, Users, DollarSign, FileText, Building2, Loader2 } from 'lucide-react';
import { groupBookingService, roomService, Room } from '../../services/api';
import { toast } from 'react-toastify';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
interface RoomBlock {
room_type_id: number;
num_rooms: number;
rate_per_room: number;
}
interface CreateGroupBookingModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
const CreateGroupBookingModal: React.FC<CreateGroupBookingModalProps> = ({
isOpen,
onClose,
onSuccess,
}) => {
const { formatCurrency } = useFormatCurrency();
const [loading, setLoading] = useState(false);
const [loadingRoomTypes, setLoadingRoomTypes] = useState(false);
// Form state
const [checkInDate, setCheckInDate] = useState<Date | null>(null);
const [checkOutDate, setCheckOutDate] = useState<Date | null>(null);
const [groupName, setGroupName] = useState('');
const [groupType, setGroupType] = useState('');
const [roomBlocks, setRoomBlocks] = useState<RoomBlock[]>([]);
const [paymentOption, setPaymentOption] = useState<'coordinator_pays_all' | 'individual_payments' | 'split_payment'>('coordinator_pays_all');
const [depositRequired, setDepositRequired] = useState(false);
const [depositPercentage, setDepositPercentage] = useState<number>(20);
const [specialRequests, setSpecialRequests] = useState('');
const [notes, setNotes] = useState('');
const [cancellationPolicy, setCancellationPolicy] = useState('');
const [cancellationDeadline, setCancellationDeadline] = useState<Date | null>(null);
const [cancellationPenaltyPercentage, setCancellationPenaltyPercentage] = useState<number>(0);
const [groupDiscountPercentage, setGroupDiscountPercentage] = useState<number | undefined>(undefined);
// Room types
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string; base_price: number }>>([]);
const [availableRooms, setAvailableRooms] = useState<Room[]>([]);
// Pricing summary
const [pricingSummary, setPricingSummary] = useState<{
originalTotal: number;
discountAmount: number;
totalPrice: number;
discountPercentage: number;
} | null>(null);
// Fetch room types
useEffect(() => {
const fetchRoomTypes = async () => {
try {
setLoadingRoomTypes(true);
const response = await roomService.getRooms({ limit: 100, page: 1 });
const uniqueRoomTypes = new Map<number, { id: number; name: string; base_price: number }>();
response.data.rooms.forEach((room: Room) => {
if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) {
uniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
name: room.room_type.name,
base_price: room.room_type.base_price,
});
}
});
// Fetch additional pages if needed
if (response.data.pagination && response.data.pagination.totalPages > 1) {
const totalPages = response.data.pagination.totalPages;
for (let page = 2; page <= totalPages; page++) {
try {
const pageResponse = await roomService.getRooms({ limit: 100, page });
pageResponse.data.rooms.forEach((room: Room) => {
if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) {
uniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
name: room.room_type.name,
base_price: room.room_type.base_price,
});
}
});
} catch (err) {
console.error(`Failed to fetch page ${page}:`, err);
}
}
}
setRoomTypes(Array.from(uniqueRoomTypes.values()));
} catch (error: any) {
console.error('Error fetching room types:', error);
toast.error('Failed to load room types');
} finally {
setLoadingRoomTypes(false);
}
};
if (isOpen) {
fetchRoomTypes();
}
}, [isOpen]);
// Calculate pricing when room blocks or dates change
useEffect(() => {
if (roomBlocks.length > 0 && checkInDate && checkOutDate) {
calculatePricing();
} else {
setPricingSummary(null);
}
}, [roomBlocks, checkInDate, checkOutDate, groupDiscountPercentage]);
const calculatePricing = () => {
if (!checkInDate || !checkOutDate || roomBlocks.length === 0) return;
const nights = Math.ceil((checkOutDate.getTime() - checkInDate.getTime()) / (1000 * 60 * 60 * 24));
if (nights <= 0) return;
let originalTotal = 0;
roomBlocks.forEach(block => {
originalTotal += block.rate_per_room * block.num_rooms * nights;
});
// Calculate discount
const totalRooms = roomBlocks.reduce((sum, block) => sum + block.num_rooms, 0);
let discountPercentage = groupDiscountPercentage;
if (discountPercentage === undefined) {
// Auto-calculate based on room count
if (totalRooms >= 20) {
discountPercentage = 15;
} else if (totalRooms >= 10) {
discountPercentage = 10;
} else if (totalRooms >= 5) {
discountPercentage = 5;
} else {
discountPercentage = 0;
}
}
const discountAmount = originalTotal * (discountPercentage / 100);
const totalPrice = originalTotal - discountAmount;
setPricingSummary({
originalTotal,
discountAmount,
totalPrice,
discountPercentage,
});
};
const addRoomBlock = () => {
if (roomTypes.length === 0) {
toast.error('Please wait for room types to load');
return;
}
const firstRoomType = roomTypes[0];
setRoomBlocks([
...roomBlocks,
{
room_type_id: firstRoomType.id,
num_rooms: 1,
rate_per_room: firstRoomType.base_price,
},
]);
};
const removeRoomBlock = (index: number) => {
setRoomBlocks(roomBlocks.filter((_, i) => i !== index));
};
const updateRoomBlock = (index: number, field: keyof RoomBlock, value: number) => {
const updated = [...roomBlocks];
updated[index] = { ...updated[index], [field]: value };
// Auto-fill rate if room type changes
if (field === 'room_type_id') {
const roomType = roomTypes.find(rt => rt.id === value);
if (roomType) {
updated[index].rate_per_room = roomType.base_price;
}
}
setRoomBlocks(updated);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!checkInDate || !checkOutDate) {
toast.error('Please select check-in and check-out dates');
return;
}
if (checkInDate >= checkOutDate) {
toast.error('Check-out date must be after check-in date');
return;
}
if (roomBlocks.length === 0) {
toast.error('Please add at least one room block');
return;
}
if (roomBlocks.some(block => block.num_rooms <= 0)) {
toast.error('Number of rooms must be greater than 0');
return;
}
try {
setLoading(true);
const bookingData = {
check_in_date: checkInDate.toISOString(),
check_out_date: checkOutDate.toISOString(),
room_blocks: roomBlocks,
group_name: groupName || undefined,
group_type: groupType || undefined,
payment_option: paymentOption,
deposit_required: depositRequired,
deposit_percentage: depositRequired ? depositPercentage : undefined,
special_requests: specialRequests || undefined,
notes: notes || undefined,
cancellation_policy: cancellationPolicy || undefined,
cancellation_deadline: cancellationDeadline ? cancellationDeadline.toISOString() : undefined,
cancellation_penalty_percentage: cancellationPenaltyPercentage > 0 ? cancellationPenaltyPercentage : undefined,
group_discount_percentage: groupDiscountPercentage,
};
await groupBookingService.createGroupBooking(bookingData);
toast.success('Group booking created successfully!');
handleClose();
onSuccess();
} catch (error: any) {
toast.error(error.response?.data?.detail || error.response?.data?.message || 'Failed to create group booking');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setCheckInDate(null);
setCheckOutDate(null);
setGroupName('');
setGroupType('');
setRoomBlocks([]);
setPaymentOption('coordinator_pays_all');
setDepositRequired(false);
setDepositPercentage(20);
setSpecialRequests('');
setNotes('');
setCancellationPolicy('');
setCancellationDeadline(null);
setCancellationPenaltyPercentage(0);
setGroupDiscountPercentage(undefined);
setPricingSummary(null);
onClose();
};
if (!isOpen) return null;
return (
<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="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-900">Create Group Booking</h2>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Calendar className="w-4 h-4 inline mr-2" />
Check-in Date
</label>
<DatePicker
selected={checkInDate}
onChange={(date) => setCheckInDate(date)}
selectsStart
startDate={checkInDate}
endDate={checkOutDate}
minDate={new Date()}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
dateFormat="yyyy-MM-dd"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Calendar className="w-4 h-4 inline mr-2" />
Check-out Date
</label>
<DatePicker
selected={checkOutDate}
onChange={(date) => setCheckOutDate(date)}
selectsEnd
startDate={checkInDate}
endDate={checkOutDate}
minDate={checkInDate || new Date()}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
dateFormat="yyyy-MM-dd"
required
/>
</div>
</div>
{/* Group Information */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Group Name
</label>
<input
type="text"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
placeholder="e.g., Corporate Retreat 2024"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Group Type
</label>
<select
value={groupType}
onChange={(e) => setGroupType(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">Select type...</option>
<option value="corporate">Corporate</option>
<option value="wedding">Wedding</option>
<option value="conference">Conference</option>
<option value="tour">Tour Group</option>
<option value="family">Family Reunion</option>
<option value="other">Other</option>
</select>
</div>
</div>
{/* Room Blocks */}
<div>
<div className="flex justify-between items-center mb-4">
<label className="block text-sm font-medium text-gray-700">
<Building2 className="w-4 h-4 inline mr-2" />
Room Blocks
</label>
<button
type="button"
onClick={addRoomBlock}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
Add Room Block
</button>
</div>
{loadingRoomTypes ? (
<div className="text-center py-8">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-gray-400" />
<p className="text-gray-500 mt-2">Loading room types...</p>
</div>
) : roomBlocks.length === 0 ? (
<div className="text-center py-8 border-2 border-dashed border-gray-300 rounded-lg">
<p className="text-gray-500">No room blocks added. Click "Add Room Block" to get started.</p>
</div>
) : (
<div className="space-y-4">
{roomBlocks.map((block, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4">
<div className="flex justify-between items-start mb-4">
<h4 className="font-medium text-gray-900">Room Block {index + 1}</h4>
<button
type="button"
onClick={() => removeRoomBlock(index)}
className="text-red-600 hover:text-red-800"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Room Type
</label>
<select
value={block.room_type_id}
onChange={(e) => updateRoomBlock(index, 'room_type_id', Number(e.target.value))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
>
{roomTypes.map((rt) => (
<option key={rt.id} value={rt.id}>
{rt.name} - {formatCurrency(rt.base_price)}/night
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Number of Rooms
</label>
<input
type="number"
min="1"
value={block.num_rooms}
onChange={(e) => updateRoomBlock(index, 'num_rooms', Number(e.target.value))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rate per Room (per night)
</label>
<input
type="number"
min="0"
step="0.01"
value={block.rate_per_room}
onChange={(e) => updateRoomBlock(index, 'rate_per_room', Number(e.target.value))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Pricing Summary */}
{pricingSummary && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h3 className="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(pricingSummary.originalTotal)}</span>
</div>
{pricingSummary.discountPercentage > 0 && (
<div className="flex justify-between text-green-600">
<span>Group Discount ({pricingSummary.discountPercentage}%):</span>
<span>-{formatCurrency(pricingSummary.discountAmount)}</span>
</div>
)}
<div className="flex justify-between font-semibold text-lg border-t border-gray-300 pt-2">
<span>Total Price:</span>
<span>{formatCurrency(pricingSummary.totalPrice)}</span>
</div>
</div>
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-2">
Custom Discount Percentage (optional)
</label>
<input
type="number"
min="0"
max="100"
value={groupDiscountPercentage || ''}
onChange={(e) => setGroupDiscountPercentage(e.target.value ? Number(e.target.value) : undefined)}
placeholder="Auto-calculated"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p className="text-xs text-gray-500 mt-1">
Leave empty for automatic discount based on room count
</p>
</div>
</div>
)}
{/* Payment Options */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<DollarSign className="w-4 h-4 inline mr-2" />
Payment Option
</label>
<select
value={paymentOption}
onChange={(e) => setPaymentOption(e.target.value as any)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="coordinator_pays_all">Coordinator Pays All</option>
<option value="individual_payments">Individual Payments</option>
<option value="split_payment">Split Payment</option>
</select>
</div>
{/* Deposit */}
<div className="flex items-center gap-4">
<input
type="checkbox"
id="depositRequired"
checked={depositRequired}
onChange={(e) => setDepositRequired(e.target.checked)}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="depositRequired" className="text-sm font-medium text-gray-700">
Deposit Required
</label>
{depositRequired && (
<div className="flex items-center gap-2">
<input
type="number"
min="0"
max="100"
value={depositPercentage}
onChange={(e) => setDepositPercentage(Number(e.target.value))}
className="w-20 px-3 py-1 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<span className="text-sm text-gray-600">%</span>
</div>
)}
</div>
{/* Cancellation Policy */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<FileText className="w-4 h-4 inline mr-2" />
Cancellation Policy
</label>
<textarea
value={cancellationPolicy}
onChange={(e) => setCancellationPolicy(e.target.value)}
rows={3}
placeholder="Enter cancellation policy details..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<div className="grid grid-cols-2 gap-4 mt-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Cancellation Deadline
</label>
<DatePicker
selected={cancellationDeadline}
onChange={(date) => setCancellationDeadline(date)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
dateFormat="yyyy-MM-dd"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Cancellation Penalty (%)
</label>
<input
type="number"
min="0"
max="100"
value={cancellationPenaltyPercentage}
onChange={(e) => setCancellationPenaltyPercentage(Number(e.target.value))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
</div>
{/* Special Requests & Notes */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Special Requests
</label>
<textarea
value={specialRequests}
onChange={(e) => setSpecialRequests(e.target.value)}
rows={3}
placeholder="Any special requests..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Notes
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
placeholder="Internal notes..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-4 pt-4 border-t border-gray-200">
<button
type="button"
onClick={handleClose}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading || roomBlocks.length === 0}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Creating...
</>
) : (
'Create Group Booking'
)}
</button>
</div>
</form>
</div>
</div>
);
};
export default CreateGroupBookingModal;

View File

@@ -0,0 +1,729 @@
import React, { useState, useEffect } from 'react';
import {
Sparkles,
Plus,
Edit,
Eye,
Search,
Calendar,
X,
CheckCircle,
Clock,
User,
ClipboardList,
} from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../common/Loading';
import Pagination from '../common/Pagination';
import advancedRoomService, { HousekeepingTask, ChecklistItem } from '../../services/api/advancedRoomService';
import { roomService, Room } from '../../services/api';
import { userService, User as UserType } from '../../services/api';
import useAuthStore from '../../store/useAuthStore';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
const HousekeepingManagement: React.FC = () => {
const { userInfo } = useAuthStore();
const isAdmin = userInfo?.role === 'admin';
const [loading, setLoading] = useState(true);
const [tasks, setTasks] = useState<HousekeepingTask[]>([]);
const [rooms, setRooms] = useState<Room[]>([]);
const [staff, setStaff] = useState<UserType[]>([]);
const [showModal, setShowModal] = useState(false);
const [editingTask, setEditingTask] = useState<HousekeepingTask | null>(null);
const [viewingTask, setViewingTask] = useState<HousekeepingTask | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [filters, setFilters] = useState({
room_id: '',
status: '',
task_type: '',
date: '',
});
const [formData, setFormData] = useState({
room_id: '',
booking_id: '',
task_type: 'vacant' as 'checkout' | 'stayover' | 'vacant' | 'inspection' | 'turndown',
scheduled_time: new Date(),
assigned_to: '',
checklist_items: [] as ChecklistItem[],
notes: '',
estimated_duration_minutes: '',
});
const defaultChecklistItems: Record<string, string[]> = {
checkout: ['Bathroom cleaned', 'Beds made', 'Trash emptied', 'Towels replaced', 'Amenities restocked', 'Floor vacuumed'],
stayover: ['Beds made', 'Trash emptied', 'Towels replaced', 'Bathroom cleaned'],
vacant: ['Deep clean bathroom', 'Change linens', 'Vacuum and mop', 'Dust surfaces', 'Check amenities'],
inspection: ['Check all amenities', 'Test electronics', 'Check for damages', 'Verify cleanliness'],
turndown: ['Prepare bed', 'Close curtains', 'Place amenities', 'Adjust lighting'],
};
useEffect(() => {
fetchRooms();
fetchStaff();
}, []);
useEffect(() => {
fetchTasks();
}, [currentPage, filters]);
const fetchTasks = async () => {
try {
setLoading(true);
const params: any = { page: currentPage, limit: 10 };
if (filters.room_id) params.room_id = parseInt(filters.room_id);
if (filters.status) params.status = filters.status;
if (filters.task_type) params.task_type = filters.task_type;
if (filters.date) params.date = filters.date;
const response = await advancedRoomService.getHousekeepingTasks(params);
if (response.status === 'success') {
setTasks(response.data.tasks);
setTotalPages(response.data.pagination?.total_pages || 1);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to fetch housekeeping tasks');
} finally {
setLoading(false);
}
};
const fetchRooms = async () => {
try {
const allRooms: Room[] = [];
let page = 1;
let hasMorePages = true;
while (hasMorePages) {
const response = await roomService.getRooms({ limit: 100, page });
if (response.success || response.status === 'success') {
if (response.data?.rooms) {
allRooms.push(...response.data.rooms);
// Check if there are more pages
if (response.data.pagination) {
hasMorePages = page < response.data.pagination.totalPages;
page++;
} else {
hasMorePages = false;
}
} else {
hasMorePages = false;
}
} else {
console.error('Failed to fetch rooms: Invalid response structure', response);
hasMorePages = false;
}
}
setRooms(allRooms);
} catch (error) {
console.error('Failed to fetch rooms:', error);
toast.error('Failed to load rooms. Please try again.');
}
};
const fetchStaff = async () => {
try {
const response = await userService.getUsers({ role: 'staff', limit: 100 });
if (response.data?.users) {
setStaff(response.data.users);
}
} catch (error) {
console.error('Failed to fetch staff:', error);
}
};
const handleCreate = () => {
setEditingTask(null);
setFormData({
room_id: '',
booking_id: '',
task_type: 'vacant',
scheduled_time: new Date(),
assigned_to: '',
checklist_items: [],
notes: '',
estimated_duration_minutes: '',
});
// Ensure rooms are loaded when opening the modal
if (rooms.length === 0) {
fetchRooms();
}
setShowModal(true);
};
const handleTaskTypeChange = (type: string) => {
const items = defaultChecklistItems[type] || [];
setFormData({
...formData,
task_type: type as any,
checklist_items: items.map(item => ({ item, completed: false, notes: '' })),
});
};
const handleEdit = (task: HousekeepingTask) => {
setEditingTask(task);
setFormData({
room_id: task.room_id.toString(),
booking_id: task.booking_id?.toString() || '',
task_type: task.task_type,
scheduled_time: new Date(task.scheduled_time),
assigned_to: task.assigned_to?.toString() || '',
checklist_items: task.checklist_items || [],
notes: task.notes || '',
estimated_duration_minutes: task.estimated_duration_minutes?.toString() || '',
});
setShowModal(true);
};
const handleMarkAsDone = async (task: HousekeepingTask) => {
// Double check that the task is assigned to the current user
if (!task.assigned_to) {
toast.error('Task must be assigned before it can be marked as done');
return;
}
if (task.assigned_to !== userInfo?.id) {
toast.error('Only the assigned staff member can mark this task as done');
return;
}
try {
await advancedRoomService.updateHousekeepingTask(task.id, {
status: 'completed',
checklist_items: task.checklist_items?.map(item => ({ ...item, completed: true })) || [],
});
toast.success('Task marked as completed successfully');
fetchTasks();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to mark task as done');
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingTask) {
// For staff, only allow updating status and checklist items
if (!isAdmin) {
const data = {
status: editingTask.status,
checklist_items: formData.checklist_items,
notes: formData.notes, // Allow staff to add notes
};
await advancedRoomService.updateHousekeepingTask(editingTask.id, data);
} else {
// Admin can update all fields
const data = {
room_id: parseInt(formData.room_id),
booking_id: formData.booking_id ? parseInt(formData.booking_id) : undefined,
task_type: formData.task_type,
scheduled_time: formData.scheduled_time.toISOString(),
assigned_to: formData.assigned_to ? parseInt(formData.assigned_to) : undefined,
checklist_items: formData.checklist_items,
notes: formData.notes,
estimated_duration_minutes: formData.estimated_duration_minutes ? parseInt(formData.estimated_duration_minutes) : undefined,
status: editingTask.status,
};
await advancedRoomService.updateHousekeepingTask(editingTask.id, data);
}
toast.success('Housekeeping task updated successfully');
} else {
// Only admin can create tasks
if (!isAdmin) {
toast.error('You do not have permission to create tasks');
return;
}
const data = {
room_id: parseInt(formData.room_id),
booking_id: formData.booking_id ? parseInt(formData.booking_id) : undefined,
task_type: formData.task_type,
scheduled_time: formData.scheduled_time.toISOString(),
assigned_to: formData.assigned_to ? parseInt(formData.assigned_to) : undefined,
checklist_items: formData.checklist_items,
notes: formData.notes,
estimated_duration_minutes: formData.estimated_duration_minutes ? parseInt(formData.estimated_duration_minutes) : undefined,
};
await advancedRoomService.createHousekeepingTask(data);
toast.success('Housekeeping task created successfully');
}
setShowModal(false);
fetchTasks();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to save housekeeping task');
}
};
const updateChecklistItem = (index: number, field: 'completed' | 'notes', value: any) => {
const updated = [...formData.checklist_items];
updated[index] = { ...updated[index], [field]: value };
setFormData({ ...formData, checklist_items: updated });
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
case 'in_progress':
return 'bg-blue-100 text-blue-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'skipped':
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
if (loading && tasks.length === 0) {
return <Loading />;
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Search className="w-5 h-5 text-gray-500" />
<input
type="text"
placeholder="Search by room..."
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => setFilters({ ...filters, room_id: e.target.value })}
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
<option value="skipped">Skipped</option>
</select>
<select
value={filters.task_type}
onChange={(e) => setFilters({ ...filters, task_type: e.target.value })}
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Types</option>
<option value="checkout">Checkout</option>
<option value="stayover">Stayover</option>
<option value="vacant">Vacant</option>
<option value="inspection">Inspection</option>
<option value="turndown">Turndown</option>
</select>
<input
type="date"
value={filters.date}
onChange={(e) => setFilters({ ...filters, date: e.target.value })}
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{isAdmin && (
<button
onClick={handleCreate}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
<span>New Task</span>
</button>
)}
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Room</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</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">Scheduled</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Assigned</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Progress</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">
{tasks.map((task) => {
const completedItems = task.checklist_items?.filter(item => item.completed).length || 0;
const totalItems = task.checklist_items?.length || 0;
const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0;
return (
<tr key={task.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{task.room_number || `Room ${task.room_id}`}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500 capitalize">{task.task_type}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(task.status)}`}>
{task.status.replace('_', ' ')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(task.scheduled_time).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{task.assigned_staff_name || 'Unassigned'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="w-16 bg-gray-200 rounded-full h-2 mr-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-gray-600">{progress}%</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center space-x-2">
<button
onClick={() => setViewingTask(task)}
className="text-blue-600 hover:text-blue-900"
title="View task"
>
<Eye className="w-4 h-4" />
</button>
{isAdmin ? (
<button
onClick={() => handleEdit(task)}
className="text-indigo-600 hover:text-indigo-900"
title="Edit task"
>
<Edit className="w-4 h-4" />
</button>
) : (
// Staff can only edit their own assigned tasks
task.assigned_to === userInfo?.id && task.status !== 'completed' && (
<>
<button
onClick={() => handleEdit(task)}
className="text-indigo-600 hover:text-indigo-900"
title="Update task"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleMarkAsDone(task)}
className="text-green-600 hover:text-green-900"
title="Mark as done"
>
<CheckCircle className="w-4 h-4" />
</button>
</>
)
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="mt-4">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<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 shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">
{editingTask
? (isAdmin ? 'Edit Housekeeping Task' : 'Update Task Status')
: 'New Housekeeping Task'}
</h2>
<button
onClick={() => setShowModal(false)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Room *</label>
<select
required
disabled={!isAdmin && editingTask !== null}
value={formData.room_id}
onChange={(e) => setFormData({ ...formData, room_id: e.target.value })}
className={`w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${!isAdmin && editingTask !== null ? 'bg-gray-100 cursor-not-allowed' : ''}`}
>
<option value="">Select Room</option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>
{room.room_number} - Floor {room.floor}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Task Type *</label>
<select
required
disabled={!isAdmin && editingTask !== null}
value={formData.task_type}
onChange={(e) => handleTaskTypeChange(e.target.value)}
className={`w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${!isAdmin && editingTask !== null ? 'bg-gray-100 cursor-not-allowed' : ''}`}
>
<option value="checkout">Checkout</option>
<option value="stayover">Stayover</option>
<option value="vacant">Vacant</option>
<option value="inspection">Inspection</option>
<option value="turndown">Turndown</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Scheduled Time *</label>
<DatePicker
selected={formData.scheduled_time}
onChange={(date: Date | null) => date && setFormData({ ...formData, scheduled_time: date })}
showTimeSelect
dateFormat="yyyy-MM-dd HH:mm"
disabled={!isAdmin && editingTask !== null}
className={`w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${!isAdmin && editingTask !== null ? 'bg-gray-100 cursor-not-allowed' : ''}`}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Assigned To</label>
<select
disabled={!isAdmin}
value={formData.assigned_to}
onChange={(e) => setFormData({ ...formData, assigned_to: e.target.value })}
className={`w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${!isAdmin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
>
<option value="">Unassigned</option>
{staff.map((s) => (
<option key={s.id} value={s.id}>
{s.full_name}
</option>
))}
</select>
</div>
</div>
{/* Status field - staff can update this */}
{editingTask && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status *</label>
<select
required
value={editingTask.status}
onChange={(e) => {
if (editingTask) {
setEditingTask({ ...editingTask, status: e.target.value as any });
}
}}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
<option value="skipped">Skipped</option>
</select>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Checklist Items</label>
<div className="space-y-2 border border-gray-200 rounded-md p-4 max-h-64 overflow-y-auto">
{formData.checklist_items.map((item, index) => (
<div key={index} className="flex items-start space-x-3">
<input
type="checkbox"
checked={item.completed}
onChange={(e) => updateChecklistItem(index, 'completed', e.target.checked)}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<div className="flex-1">
<label className="text-sm text-gray-700">{item.item}</label>
<input
type="text"
placeholder="Notes (optional)"
value={item.notes || ''}
onChange={(e) => updateChecklistItem(index, 'notes', e.target.value)}
className="mt-1 w-full border border-gray-300 rounded-md px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
</div>
))}
{formData.checklist_items.length === 0 && (
<p className="text-sm text-gray-500">Select a task type to load checklist items</p>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Estimated Duration (minutes)</label>
<input
type="number"
disabled={!isAdmin && editingTask !== null}
value={formData.estimated_duration_minutes}
onChange={(e) => setFormData({ ...formData, estimated_duration_minutes: e.target.value })}
className={`w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${!isAdmin && editingTask !== null ? 'bg-gray-100 cursor-not-allowed' : ''}`}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
placeholder={!isAdmin && editingTask ? "Add notes about the task..." : ""}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={() => setShowModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
>
{editingTask ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* View Modal */}
{viewingTask && (
<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 shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">Task Details</h2>
<button
onClick={() => setViewingTask(null)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Room</label>
<p className="mt-1 text-sm text-gray-900">{viewingTask.room_number || `Room ${viewingTask.room_id}`}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Status</label>
<p className="mt-1">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(viewingTask.status)}`}>
{viewingTask.status.replace('_', ' ')}
</span>
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Type</label>
<p className="mt-1 text-sm text-gray-900 capitalize">{viewingTask.task_type}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Scheduled Time</label>
<p className="mt-1 text-sm text-gray-900">{new Date(viewingTask.scheduled_time).toLocaleString()}</p>
</div>
</div>
{viewingTask.assigned_staff_name && (
<div>
<label className="block text-sm font-medium text-gray-500">Assigned To</label>
<p className="mt-1 text-sm text-gray-900">{viewingTask.assigned_staff_name}</p>
</div>
)}
{viewingTask.checklist_items && viewingTask.checklist_items.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-500 mb-2">Checklist</label>
<div className="space-y-2">
{viewingTask.checklist_items.map((item, index) => (
<div key={index} className="flex items-start space-x-2">
{item.completed ? (
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
) : (
<Clock className="w-5 h-5 text-gray-400 mt-0.5" />
)}
<div className="flex-1">
<p className={`text-sm ${item.completed ? 'text-gray-500 line-through' : 'text-gray-900'}`}>
{item.item}
</p>
{item.notes && (
<p className="text-xs text-gray-500 mt-1">{item.notes}</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{viewingTask.notes && (
<div>
<label className="block text-sm font-medium text-gray-500">Notes</label>
<p className="mt-1 text-sm text-gray-900">{viewingTask.notes}</p>
</div>
)}
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
onClick={() => {
setViewingTask(null);
handleEdit(viewingTask);
}}
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
>
Edit
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default HousekeepingManagement;

View File

@@ -0,0 +1,768 @@
import React, { useState, useEffect } from 'react';
import {
ClipboardCheck,
Plus,
Edit,
Eye,
Search,
X,
CheckCircle,
XCircle,
AlertTriangle,
Star,
} from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../common/Loading';
import Pagination from '../common/Pagination';
import advancedRoomService, {
RoomInspection,
InspectionChecklistItem,
Issue,
} from '../../services/api/advancedRoomService';
import { roomService, Room } from '../../services/api';
import { userService, User as UserType } from '../../services/api';
import useAuthStore from '../../store/useAuthStore';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
const InspectionManagement: React.FC = () => {
const { userInfo } = useAuthStore();
const isAdmin = userInfo?.role === 'admin';
const [loading, setLoading] = useState(true);
const [inspections, setInspections] = useState<RoomInspection[]>([]);
const [rooms, setRooms] = useState<Room[]>([]);
const [staff, setStaff] = useState<UserType[]>([]);
const [showModal, setShowModal] = useState(false);
const [editingInspection, setEditingInspection] = useState<RoomInspection | null>(null);
const [viewingInspection, setViewingInspection] = useState<RoomInspection | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [filters, setFilters] = useState({
room_id: '',
inspection_type: '',
status: '',
});
const defaultChecklistCategories = [
{
category: 'Bathroom',
items: ['Toilet', 'Shower', 'Sink', 'Mirror', 'Tiles', 'Ventilation'],
},
{
category: 'Bedroom',
items: ['Bed', 'Linens', 'Pillows', 'Furniture', 'Closet', 'Lighting'],
},
{
category: 'Electronics',
items: ['TV', 'Remote', 'AC', 'WiFi', 'Safe', 'Charging ports'],
},
{
category: 'Amenities',
items: ['Towels', 'Toiletries', 'Coffee/Tea', 'Mini bar', 'Hangers', 'Iron'],
},
{
category: 'General',
items: ['Floor', 'Walls', 'Windows', 'Doors', 'Curtains', 'Smoke detector'],
},
];
const [formData, setFormData] = useState({
room_id: '',
booking_id: '',
inspection_type: 'routine' as 'pre_checkin' | 'post_checkout' | 'routine' | 'maintenance' | 'damage',
scheduled_at: new Date(),
inspected_by: '',
checklist_items: [] as InspectionChecklistItem[],
overall_score: '',
overall_notes: '',
issues_found: [] as Issue[],
requires_followup: false,
});
useEffect(() => {
fetchInspections();
fetchRooms();
fetchStaff();
}, [currentPage, filters]);
const fetchInspections = async () => {
try {
setLoading(true);
const params: any = { page: currentPage, limit: 10 };
if (filters.room_id) params.room_id = parseInt(filters.room_id);
if (filters.inspection_type) params.inspection_type = filters.inspection_type;
if (filters.status) params.status = filters.status;
const response = await advancedRoomService.getRoomInspections(params);
if (response.status === 'success') {
setInspections(response.data.inspections);
setTotalPages(response.data.pagination?.total_pages || 1);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to fetch inspections');
} finally {
setLoading(false);
}
};
const fetchRooms = async () => {
try {
const response = await roomService.getRooms({ limit: 1000, page: 1 });
if (response.data?.rooms) {
setRooms(response.data.rooms);
}
} catch (error) {
console.error('Failed to fetch rooms:', error);
}
};
const fetchStaff = async () => {
try {
const response = await userService.getUsers({ role: 'staff', limit: 100 });
if (response.data?.users) {
setStaff(response.data.users);
}
} catch (error) {
console.error('Failed to fetch staff:', error);
}
};
const initializeChecklist = () => {
const items: InspectionChecklistItem[] = [];
defaultChecklistCategories.forEach((category) => {
category.items.forEach((item) => {
items.push({
category: category.category,
item: item,
status: 'not_applicable',
notes: '',
photos: [],
});
});
});
setFormData({ ...formData, checklist_items: items });
};
const handleCreate = () => {
setEditingInspection(null);
setFormData({
room_id: '',
booking_id: '',
inspection_type: 'routine',
scheduled_at: new Date(),
inspected_by: '',
checklist_items: [],
overall_score: '',
overall_notes: '',
issues_found: [],
requires_followup: false,
});
initializeChecklist();
setShowModal(true);
};
const handleMarkAsDone = async (inspection: RoomInspection) => {
// Double check that the inspection is assigned to the current user
if (!inspection.inspected_by) {
toast.error('Inspection must be assigned before it can be marked as done');
return;
}
if (inspection.inspected_by !== userInfo?.id) {
toast.error('Only the assigned inspector can mark this inspection as done');
return;
}
try {
await advancedRoomService.updateRoomInspection(inspection.id, {
status: 'completed',
completed_at: new Date().toISOString(),
});
toast.success('Inspection marked as completed successfully');
fetchInspections();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to mark inspection as done');
}
};
const handleEdit = (inspection: RoomInspection) => {
setEditingInspection(inspection);
setFormData({
room_id: inspection.room_id.toString(),
booking_id: inspection.booking_id?.toString() || '',
inspection_type: inspection.inspection_type,
scheduled_at: new Date(inspection.scheduled_at),
inspected_by: inspection.inspected_by?.toString() || '',
checklist_items: inspection.checklist_items || [],
overall_score: inspection.overall_score?.toString() || '',
overall_notes: inspection.overall_notes || '',
issues_found: inspection.issues_found || [],
requires_followup: inspection.requires_followup,
});
setShowModal(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const data = {
room_id: parseInt(formData.room_id),
booking_id: formData.booking_id ? parseInt(formData.booking_id) : undefined,
inspection_type: formData.inspection_type,
scheduled_at: formData.scheduled_at.toISOString(),
inspected_by: formData.inspected_by ? parseInt(formData.inspected_by) : undefined,
checklist_items: formData.checklist_items,
overall_score: formData.overall_score ? parseFloat(formData.overall_score) : undefined,
overall_notes: formData.overall_notes,
issues_found: formData.issues_found,
requires_followup: formData.requires_followup,
};
if (editingInspection) {
await advancedRoomService.updateRoomInspection(editingInspection.id, {
status: editingInspection.status,
...data,
});
toast.success('Inspection updated successfully');
} else {
await advancedRoomService.createRoomInspection(data);
toast.success('Inspection created successfully');
}
setShowModal(false);
fetchInspections();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to save inspection');
}
};
const updateChecklistItem = (index: number, field: 'status' | 'notes', value: any) => {
const updated = [...formData.checklist_items];
updated[index] = { ...updated[index], [field]: value };
setFormData({ ...formData, checklist_items: updated });
};
const addIssue = () => {
setFormData({
...formData,
issues_found: [
...formData.issues_found,
{ severity: 'minor', description: '', photo: '' },
],
});
};
const updateIssue = (index: number, field: 'severity' | 'description' | 'photo', value: any) => {
const updated = [...formData.issues_found];
updated[index] = { ...updated[index], [field]: value };
setFormData({ ...formData, issues_found: updated });
};
const removeIssue = (index: number) => {
setFormData({
...formData,
issues_found: formData.issues_found.filter((_, i) => i !== index),
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
case 'in_progress':
return 'bg-blue-100 text-blue-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'failed':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'pass':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'fail':
return <XCircle className="w-4 h-4 text-red-500" />;
case 'needs_attention':
return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
default:
return null;
}
};
if (loading && inspections.length === 0) {
return <Loading />;
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Search className="w-5 h-5 text-gray-500" />
<input
type="text"
placeholder="Search by room..."
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => setFilters({ ...filters, room_id: e.target.value })}
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
<select
value={filters.inspection_type}
onChange={(e) => setFilters({ ...filters, inspection_type: e.target.value })}
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Types</option>
<option value="pre_checkin">Pre Check-in</option>
<option value="post_checkout">Post Check-out</option>
<option value="routine">Routine</option>
<option value="maintenance">Maintenance</option>
<option value="damage">Damage</option>
</select>
</div>
{isAdmin && (
<button
onClick={handleCreate}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
<span>New Inspection</span>
</button>
)}
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Room</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</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">Scheduled</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Score</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Inspector</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">
{inspections.map((inspection) => (
<tr key={inspection.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{inspection.room_number || `Room ${inspection.room_id}`}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500 capitalize">
{inspection.inspection_type.replace('_', ' ')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(inspection.status)}`}>
{inspection.status.replace('_', ' ')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(inspection.scheduled_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{inspection.overall_score ? (
<div className="flex items-center space-x-1">
<Star className="w-4 h-4 text-yellow-500 fill-current" />
<span className="text-sm text-gray-900">{inspection.overall_score.toFixed(1)}</span>
</div>
) : (
<span className="text-sm text-gray-400">N/A</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{inspection.inspector_name || 'Unassigned'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center space-x-2">
<button
onClick={() => setViewingInspection(inspection)}
className="text-blue-600 hover:text-blue-900"
title="View inspection"
>
<Eye className="w-4 h-4" />
</button>
{isAdmin ? (
<button
onClick={() => handleEdit(inspection)}
className="text-indigo-600 hover:text-indigo-900"
title="Edit inspection"
>
<Edit className="w-4 h-4" />
</button>
) : (
// Staff can mark their own assigned inspections as done
inspection.inspected_by === userInfo?.id && inspection.status !== 'completed' && (
<button
onClick={() => handleMarkAsDone(inspection)}
className="text-green-600 hover:text-green-900"
title="Mark as done"
>
<CheckCircle className="w-4 h-4" />
</button>
)
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="mt-4">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
{/* Create/Edit Modal - Simplified for space, full version would be similar to others */}
{showModal && (
<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 shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">
{editingInspection ? 'Edit Inspection' : 'New Inspection'}
</h2>
<button
onClick={() => setShowModal(false)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Room *</label>
<select
required
value={formData.room_id}
onChange={(e) => setFormData({ ...formData, room_id: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select Room</option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>
{room.room_number} - Floor {room.floor}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Inspection Type *</label>
<select
required
value={formData.inspection_type}
onChange={(e) => setFormData({ ...formData, inspection_type: e.target.value as any })}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="pre_checkin">Pre Check-in</option>
<option value="post_checkout">Post Check-out</option>
<option value="routine">Routine</option>
<option value="maintenance">Maintenance</option>
<option value="damage">Damage</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Scheduled At *</label>
<DatePicker
selected={formData.scheduled_at}
onChange={(date: Date | null) => date && setFormData({ ...formData, scheduled_at: date })}
showTimeSelect
dateFormat="yyyy-MM-dd HH:mm"
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Inspector</label>
<select
value={formData.inspected_by}
onChange={(e) => setFormData({ ...formData, inspected_by: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Unassigned</option>
{staff.map((s) => (
<option key={s.id} value={s.id}>
{s.full_name}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Checklist</label>
<div className="space-y-4 border border-gray-200 rounded-md p-4 max-h-96 overflow-y-auto">
{defaultChecklistCategories.map((category, catIndex) => (
<div key={catIndex} className="border-b border-gray-100 pb-3 last:border-0">
<h4 className="font-semibold text-sm text-gray-700 mb-2">{category.category}</h4>
<div className="space-y-2">
{category.items.map((item, itemIndex) => {
const checklistIndex = formData.checklist_items.findIndex(
(ci) => ci.category === category.category && ci.item === item
);
if (checklistIndex === -1) return null;
return (
<div key={itemIndex} className="flex items-center space-x-3">
<select
value={formData.checklist_items[checklistIndex].status}
onChange={(e) => updateChecklistItem(checklistIndex, 'status', e.target.value)}
className="border border-gray-300 rounded-md px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="not_applicable">N/A</option>
<option value="pass">Pass</option>
<option value="fail">Fail</option>
<option value="needs_attention">Needs Attention</option>
</select>
<span className="text-sm text-gray-700 flex-1">{item}</span>
{getStatusIcon(formData.checklist_items[checklistIndex].status)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Overall Score (0-5)</label>
<input
type="number"
min="0"
max="5"
step="0.1"
value={formData.overall_score}
onChange={(e) => setFormData({ ...formData, overall_score: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Overall Notes</label>
<textarea
value={formData.overall_notes}
onChange={(e) => setFormData({ ...formData, overall_notes: e.target.value })}
rows={3}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Issues Found</label>
<div className="space-y-2">
{formData.issues_found.map((issue, index) => (
<div key={index} className="flex items-start space-x-2 border border-gray-200 rounded-md p-3">
<select
value={issue.severity}
onChange={(e) => updateIssue(index, 'severity', e.target.value)}
className="border border-gray-300 rounded-md px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="cosmetic">Cosmetic</option>
<option value="minor">Minor</option>
<option value="major">Major</option>
<option value="critical">Critical</option>
</select>
<input
type="text"
placeholder="Description"
value={issue.description}
onChange={(e) => updateIssue(index, 'description', e.target.value)}
className="flex-1 border border-gray-300 rounded-md px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
type="button"
onClick={() => removeIssue(index)}
className="text-red-600 hover:text-red-800"
>
<X className="w-4 h-4" />
</button>
</div>
))}
<button
type="button"
onClick={addIssue}
className="text-sm text-blue-600 hover:text-blue-800"
>
+ Add Issue
</button>
</div>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="requires_followup"
checked={formData.requires_followup}
onChange={(e) => setFormData({ ...formData, requires_followup: e.target.checked })}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="requires_followup" className="ml-2 block text-sm text-gray-700">
Requires Follow-up
</label>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={() => setShowModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
>
{editingInspection ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* View Modal - Similar structure to others, showing full inspection details */}
{viewingInspection && (
<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 shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">Inspection Details</h2>
<button
onClick={() => setViewingInspection(null)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Room</label>
<p className="mt-1 text-sm text-gray-900">
{viewingInspection.room_number || `Room ${viewingInspection.room_id}`}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Status</label>
<p className="mt-1">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(viewingInspection.status)}`}>
{viewingInspection.status.replace('_', ' ')}
</span>
</p>
</div>
</div>
{viewingInspection.overall_score && (
<div>
<label className="block text-sm font-medium text-gray-500">Overall Score</label>
<div className="mt-1 flex items-center space-x-1">
<Star className="w-5 h-5 text-yellow-500 fill-current" />
<span className="text-lg font-semibold text-gray-900">{viewingInspection.overall_score.toFixed(1)} / 5.0</span>
</div>
</div>
)}
{viewingInspection.checklist_items && viewingInspection.checklist_items.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-500 mb-2">Checklist</label>
<div className="space-y-3">
{defaultChecklistCategories.map((category) => {
const categoryItems = viewingInspection.checklist_items.filter(
(item) => item.category === category.category
);
if (categoryItems.length === 0) return null;
return (
<div key={category.category} className="border border-gray-200 rounded-md p-3">
<h4 className="font-semibold text-sm text-gray-700 mb-2">{category.category}</h4>
<div className="space-y-1">
{categoryItems.map((item, index) => (
<div key={index} className="flex items-center space-x-2">
{getStatusIcon(item.status)}
<span className="text-sm text-gray-700">{item.item}</span>
<span className="text-xs text-gray-500 capitalize">({item.status.replace('_', ' ')})</span>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
)}
{viewingInspection.issues_found && viewingInspection.issues_found.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-500 mb-2">Issues Found</label>
<div className="space-y-2">
{viewingInspection.issues_found.map((issue, index) => (
<div key={index} className="border border-red-200 bg-red-50 rounded-md p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-red-800 capitalize">{issue.severity}</span>
</div>
<p className="text-sm text-gray-900 mt-1">{issue.description}</p>
</div>
))}
</div>
</div>
)}
{isAdmin && (
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
onClick={() => {
setViewingInspection(null);
handleEdit(viewingInspection);
}}
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
>
Edit
</button>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default InspectionManagement;

View File

@@ -0,0 +1,704 @@
import React, { useState, useEffect } from 'react';
import {
Plus,
Edit,
Eye,
Search,
X,
CheckCircle,
} from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../common/Loading';
import Pagination from '../common/Pagination';
import advancedRoomService, { MaintenanceRecord } from '../../services/api/advancedRoomService';
import { roomService, Room } from '../../services/api';
import { userService, User as UserType } from '../../services/api';
import useAuthStore from '../../store/useAuthStore';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
const MaintenanceManagement: React.FC = () => {
const { userInfo } = useAuthStore();
const isAdmin = userInfo?.role === 'admin';
const [loading, setLoading] = useState(true);
const [maintenanceRecords, setMaintenanceRecords] = useState<MaintenanceRecord[]>([]);
const [rooms, setRooms] = useState<Room[]>([]);
const [staff, setStaff] = useState<UserType[]>([]);
const [showModal, setShowModal] = useState(false);
const [editingRecord, setEditingRecord] = useState<MaintenanceRecord | null>(null);
const [viewingRecord, setViewingRecord] = useState<MaintenanceRecord | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [filters, setFilters] = useState({
room_id: '',
status: '',
maintenance_type: '',
});
const [formData, setFormData] = useState({
room_id: '',
maintenance_type: 'preventive' as 'preventive' | 'corrective' | 'emergency' | 'upgrade' | 'inspection',
title: '',
description: '',
scheduled_start: new Date(),
scheduled_end: null as Date | null,
assigned_to: '',
estimated_cost: '',
blocks_room: true,
block_start: null as Date | null,
block_end: null as Date | null,
priority: 'medium' as 'low' | 'medium' | 'high' | 'urgent',
notes: '',
});
useEffect(() => {
fetchMaintenanceRecords();
fetchRooms();
fetchStaff();
}, [currentPage, filters]);
const fetchMaintenanceRecords = async () => {
try {
setLoading(true);
const params: any = { page: currentPage, limit: 10 };
if (filters.room_id) params.room_id = parseInt(filters.room_id);
if (filters.status) params.status = filters.status;
if (filters.maintenance_type) params.maintenance_type = filters.maintenance_type;
const response = await advancedRoomService.getMaintenanceRecords(params);
if (response.status === 'success') {
setMaintenanceRecords(response.data.maintenance_records);
setTotalPages(response.data.pagination?.total_pages || 1);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to fetch maintenance records');
} finally {
setLoading(false);
}
};
const fetchRooms = async () => {
try {
const response = await roomService.getRooms({ limit: 1000, page: 1 });
if (response.data?.rooms) {
setRooms(response.data.rooms);
}
} catch (error) {
console.error('Failed to fetch rooms:', error);
}
};
const fetchStaff = async () => {
try {
const response = await userService.getUsers({ role: 'staff', limit: 100 });
if (response.data?.users) {
setStaff(response.data.users);
}
} catch (error) {
console.error('Failed to fetch staff:', error);
}
};
const handleCreate = () => {
setEditingRecord(null);
setFormData({
room_id: '',
maintenance_type: 'preventive',
title: '',
description: '',
scheduled_start: new Date(),
scheduled_end: null,
assigned_to: '',
estimated_cost: '',
blocks_room: true,
block_start: null,
block_end: null,
priority: 'medium',
notes: '',
});
setShowModal(true);
};
const handleMarkAsDone = async (record: MaintenanceRecord) => {
// Double check that the maintenance is assigned to the current user
if (!record.assigned_to) {
toast.error('Maintenance must be assigned before it can be marked as done');
return;
}
if (record.assigned_to !== userInfo?.id) {
toast.error('Only the assigned staff member can mark this maintenance as done');
return;
}
try {
await advancedRoomService.updateMaintenanceRecord(record.id, {
status: 'completed',
actual_end: new Date().toISOString(),
});
toast.success('Maintenance marked as completed successfully');
fetchMaintenanceRecords();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to mark maintenance as done');
}
};
const handleEdit = (record: MaintenanceRecord) => {
setEditingRecord(record);
setFormData({
room_id: record.room_id.toString(),
maintenance_type: record.maintenance_type,
title: record.title,
description: record.description || '',
scheduled_start: new Date(record.scheduled_start),
scheduled_end: record.scheduled_end ? new Date(record.scheduled_end) : null,
assigned_to: record.assigned_to?.toString() || '',
estimated_cost: record.estimated_cost?.toString() || '',
blocks_room: record.blocks_room,
block_start: record.block_start ? new Date(record.block_start) : null,
block_end: record.block_end ? new Date(record.block_end) : null,
priority: record.priority as any,
notes: record.notes || '',
});
setShowModal(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const data = {
room_id: parseInt(formData.room_id),
maintenance_type: formData.maintenance_type,
title: formData.title,
description: formData.description,
scheduled_start: formData.scheduled_start.toISOString(),
scheduled_end: formData.scheduled_end?.toISOString(),
assigned_to: formData.assigned_to ? parseInt(formData.assigned_to) : undefined,
estimated_cost: formData.estimated_cost ? parseFloat(formData.estimated_cost) : undefined,
blocks_room: formData.blocks_room,
block_start: formData.block_start?.toISOString(),
block_end: formData.block_end?.toISOString(),
priority: formData.priority,
notes: formData.notes,
};
if (editingRecord) {
await advancedRoomService.updateMaintenanceRecord(editingRecord.id, {
status: editingRecord.status,
...data,
});
toast.success('Maintenance record updated successfully');
} else {
await advancedRoomService.createMaintenanceRecord(data);
toast.success('Maintenance record created successfully');
}
setShowModal(false);
fetchMaintenanceRecords();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to save maintenance record');
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
case 'in_progress':
return 'bg-blue-100 text-blue-800';
case 'scheduled':
return 'bg-yellow-100 text-yellow-800';
case 'cancelled':
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent':
return 'bg-red-100 text-red-800';
case 'high':
return 'bg-orange-100 text-orange-800';
case 'medium':
return 'bg-yellow-100 text-yellow-800';
case 'low':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
if (loading && maintenanceRecords.length === 0) {
return <Loading />;
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Search className="w-5 h-5 text-gray-500" />
<input
type="text"
placeholder="Search by room..."
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => setFilters({ ...filters, room_id: e.target.value })}
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Statuses</option>
<option value="scheduled">Scheduled</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
<select
value={filters.maintenance_type}
onChange={(e) => setFilters({ ...filters, maintenance_type: e.target.value })}
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Types</option>
<option value="preventive">Preventive</option>
<option value="corrective">Corrective</option>
<option value="emergency">Emergency</option>
<option value="upgrade">Upgrade</option>
<option value="inspection">Inspection</option>
</select>
</div>
{isAdmin && (
<button
onClick={handleCreate}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
<span>New Maintenance</span>
</button>
)}
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Room</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</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">Priority</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Scheduled</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Assigned</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">
{maintenanceRecords.map((record) => (
<tr key={record.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{record.room_number || `Room ${record.room_id}`}</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">{record.title}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500 capitalize">{record.maintenance_type}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(record.status)}`}>
{record.status.replace('_', ' ')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getPriorityColor(record.priority)}`}>
{record.priority}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(record.scheduled_start).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{record.assigned_staff_name || 'Unassigned'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center space-x-2">
<button
onClick={() => setViewingRecord(record)}
className="text-blue-600 hover:text-blue-900"
title="View maintenance"
>
<Eye className="w-4 h-4" />
</button>
{isAdmin ? (
<button
onClick={() => handleEdit(record)}
className="text-indigo-600 hover:text-indigo-900"
title="Edit maintenance"
>
<Edit className="w-4 h-4" />
</button>
) : (
// Staff can mark their own assigned maintenance as done
record.assigned_to === userInfo?.id && record.status !== 'completed' && (
<button
onClick={() => handleMarkAsDone(record)}
className="text-green-600 hover:text-green-900"
title="Mark as done"
>
<CheckCircle className="w-4 h-4" />
</button>
)
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="mt-4">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<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 shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">
{editingRecord ? 'Edit Maintenance' : 'New Maintenance'}
</h2>
<button
onClick={() => setShowModal(false)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Room *</label>
<select
required
value={formData.room_id}
onChange={(e) => setFormData({ ...formData, room_id: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select Room</option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>
{room.room_number} - Floor {room.floor}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Type *</label>
<select
required
value={formData.maintenance_type}
onChange={(e) => setFormData({ ...formData, maintenance_type: e.target.value as any })}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="preventive">Preventive</option>
<option value="corrective">Corrective</option>
<option value="emergency">Emergency</option>
<option value="upgrade">Upgrade</option>
<option value="inspection">Inspection</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title *</label>
<input
type="text"
required
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Scheduled Start *</label>
<DatePicker
selected={formData.scheduled_start}
onChange={(date: Date | null) => date && setFormData({ ...formData, scheduled_start: date })}
showTimeSelect
dateFormat="yyyy-MM-dd HH:mm"
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Scheduled End</label>
<DatePicker
selected={formData.scheduled_end}
onChange={(date: Date | null) => setFormData({ ...formData, scheduled_end: date })}
showTimeSelect
dateFormat="yyyy-MM-dd HH:mm"
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<select
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value as any })}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Assigned To</label>
<select
value={formData.assigned_to}
onChange={(e) => setFormData({ ...formData, assigned_to: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Unassigned</option>
{staff.map((s) => (
<option key={s.id} value={s.id}>
{s.full_name}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Estimated Cost</label>
<input
type="number"
step="0.01"
value={formData.estimated_cost}
onChange={(e) => setFormData({ ...formData, estimated_cost: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-center pt-6">
<input
type="checkbox"
id="blocks_room"
checked={formData.blocks_room}
onChange={(e) => setFormData({ ...formData, blocks_room: e.target.checked })}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="blocks_room" className="ml-2 block text-sm text-gray-700">
Block room during maintenance
</label>
</div>
</div>
{formData.blocks_room && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Block Start</label>
<DatePicker
selected={formData.block_start}
onChange={(date: Date | null) => setFormData({ ...formData, block_start: date })}
showTimeSelect
dateFormat="yyyy-MM-dd HH:mm"
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Block End</label>
<DatePicker
selected={formData.block_end}
onChange={(date: Date | null) => setFormData({ ...formData, block_end: date })}
showTimeSelect
dateFormat="yyyy-MM-dd HH:mm"
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={2}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={() => setShowModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
>
{editingRecord ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* View Modal */}
{viewingRecord && (
<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 shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">Maintenance Details</h2>
<button
onClick={() => setViewingRecord(null)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Room</label>
<p className="mt-1 text-sm text-gray-900">{viewingRecord.room_number || `Room ${viewingRecord.room_id}`}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Status</label>
<p className="mt-1">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(viewingRecord.status)}`}>
{viewingRecord.status.replace('_', ' ')}
</span>
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Title</label>
<p className="mt-1 text-sm text-gray-900">{viewingRecord.title}</p>
</div>
{viewingRecord.description && (
<div>
<label className="block text-sm font-medium text-gray-500">Description</label>
<p className="mt-1 text-sm text-gray-900">{viewingRecord.description}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Type</label>
<p className="mt-1 text-sm text-gray-900 capitalize">{viewingRecord.maintenance_type}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Priority</label>
<p className="mt-1">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getPriorityColor(viewingRecord.priority)}`}>
{viewingRecord.priority}
</span>
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Scheduled Start</label>
<p className="mt-1 text-sm text-gray-900">{new Date(viewingRecord.scheduled_start).toLocaleString()}</p>
</div>
{viewingRecord.scheduled_end && (
<div>
<label className="block text-sm font-medium text-gray-500">Scheduled End</label>
<p className="mt-1 text-sm text-gray-900">{new Date(viewingRecord.scheduled_end).toLocaleString()}</p>
</div>
)}
</div>
{viewingRecord.assigned_staff_name && (
<div>
<label className="block text-sm font-medium text-gray-500">Assigned To</label>
<p className="mt-1 text-sm text-gray-900">{viewingRecord.assigned_staff_name}</p>
</div>
)}
{(viewingRecord.estimated_cost || viewingRecord.actual_cost) && (
<div className="grid grid-cols-2 gap-4">
{viewingRecord.estimated_cost && (
<div>
<label className="block text-sm font-medium text-gray-500">Estimated Cost</label>
<p className="mt-1 text-sm text-gray-900">${viewingRecord.estimated_cost.toFixed(2)}</p>
</div>
)}
{viewingRecord.actual_cost && (
<div>
<label className="block text-sm font-medium text-gray-500">Actual Cost</label>
<p className="mt-1 text-sm text-gray-900">${viewingRecord.actual_cost.toFixed(2)}</p>
</div>
)}
</div>
)}
{isAdmin && (
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
onClick={() => {
setViewingRecord(null);
handleEdit(viewingRecord);
}}
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
>
Edit
</button>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default MaintenanceManagement;

View File

@@ -0,0 +1,12 @@
/**
* Shared Components
*
* Components that can be used by multiple roles (admin, staff, accountant, customer)
*/
export { default as CreateBookingModal } from './CreateBookingModal';
export { default as CreateGroupBookingModal } from './CreateGroupBookingModal';
export { default as MaintenanceManagement } from './MaintenanceManagement';
export { default as HousekeepingManagement } from './HousekeepingManagement';
export { default as InspectionManagement } from './InspectionManagement';

View File

@@ -0,0 +1,187 @@
import React, { useState } from 'react';
import { X } from 'lucide-react';
import { toast } from 'react-toastify';
import taskService from '../../services/api/taskService';
interface CreateTaskModalProps {
onClose: () => void;
onSuccess: () => void;
initialData?: {
booking_id?: number;
room_id?: number;
workflow_instance_id?: number;
};
}
const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ onClose, onSuccess, initialData }) => {
const [formData, setFormData] = useState({
title: '',
description: '',
task_type: 'general',
priority: 'medium',
assigned_to: '',
due_date: '',
estimated_duration_minutes: '',
booking_id: initialData?.booking_id || '',
room_id: initialData?.room_id || '',
workflow_instance_id: initialData?.workflow_instance_id || '',
});
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.title.trim()) {
toast.error('Title is required');
return;
}
try {
setLoading(true);
await taskService.createTask({
title: formData.title,
description: formData.description || undefined,
task_type: formData.task_type,
priority: formData.priority,
assigned_to: formData.assigned_to ? parseInt(formData.assigned_to) : undefined,
due_date: formData.due_date || undefined,
estimated_duration_minutes: formData.estimated_duration_minutes ? parseInt(formData.estimated_duration_minutes) : undefined,
booking_id: formData.booking_id ? parseInt(formData.booking_id) : undefined,
room_id: formData.room_id ? parseInt(formData.room_id) : undefined,
workflow_instance_id: formData.workflow_instance_id ? parseInt(formData.workflow_instance_id) : undefined,
});
toast.success('Task created successfully');
onSuccess();
} catch (error: any) {
toast.error(error.message || 'Failed to create task');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-gray-200 p-6 flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">Create New Task</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-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-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Task Type</label>
<select
value={formData.task_type}
onChange={(e) => setFormData({ ...formData, task_type: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="general">General</option>
<option value="room_cleaning">Room Cleaning</option>
<option value="maintenance">Maintenance</option>
<option value="guest_communication">Guest Communication</option>
<option value="check_in">Check In</option>
<option value="check_out">Check Out</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Priority</label>
<select
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Due Date</label>
<input
type="datetime-local"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Estimated Duration (minutes)</label>
<input
type="number"
value={formData.estimated_duration_minutes}
onChange={(e) => setFormData({ ...formData, estimated_duration_minutes: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Assigned To (User ID)</label>
<input
type="number"
value={formData.assigned_to}
onChange={(e) => setFormData({ ...formData, assigned_to: e.target.value })}
placeholder="Leave empty for unassigned"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
{loading ? 'Creating...' : 'Create Task'}
</button>
</div>
</form>
</div>
</div>
);
};
export default CreateTaskModal;

View File

@@ -0,0 +1,255 @@
import React, { useState } from 'react';
import { X, CheckCircle2, Clock, User, Calendar, MessageSquare, Send, Play, Pause } from 'lucide-react';
import { toast } from 'react-toastify';
import { Task } from '../../services/api/taskService';
import taskService from '../../services/api/taskService';
import { formatDate } from '../../utils/format';
interface TaskDetailModalProps {
task: Task;
onClose: () => void;
onUpdate: () => void;
}
const TaskDetailModal: React.FC<TaskDetailModalProps> = ({ task, onClose, onUpdate }) => {
const [comment, setComment] = useState('');
const [loading, setLoading] = useState(false);
const [taskData, setTaskData] = useState<Task>(task);
const handleAddComment = async () => {
if (!comment.trim()) return;
try {
setLoading(true);
const response = await taskService.addTaskComment(taskData.id, comment);
const updatedTask = await taskService.getTask(taskData.id);
setTaskData(updatedTask.data.data);
setComment('');
toast.success('Comment added');
} catch (error: any) {
toast.error(error.message || 'Failed to add comment');
} finally {
setLoading(false);
}
};
const handleStartTask = async () => {
try {
setLoading(true);
await taskService.startTask(taskData.id);
const updatedTask = await taskService.getTask(taskData.id);
setTaskData(updatedTask.data.data);
onUpdate();
toast.success('Task started');
} catch (error: any) {
toast.error(error.message || 'Failed to start task');
} finally {
setLoading(false);
}
};
const handleCompleteTask = async () => {
try {
setLoading(true);
await taskService.completeTask(taskData.id);
const updatedTask = await taskService.getTask(taskData.id);
setTaskData(updatedTask.data.data);
onUpdate();
toast.success('Task completed');
} catch (error: any) {
toast.error(error.message || 'Failed to complete task');
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'text-green-600 bg-green-50 border-green-200';
case 'in_progress':
return 'text-blue-600 bg-blue-50 border-blue-200';
case 'overdue':
return 'text-red-600 bg-red-50 border-red-200';
default:
return 'text-amber-600 bg-amber-50 border-amber-200';
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent':
return 'text-red-600 bg-red-50 border-red-200';
case 'high':
return 'text-orange-600 bg-orange-50 border-orange-200';
case 'medium':
return 'text-yellow-600 bg-yellow-50 border-yellow-200';
default:
return 'text-gray-600 bg-gray-50 border-gray-200';
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="sticky top-0 bg-white border-b border-gray-200 p-6 flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">{taskData.title}</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Status and Priority */}
<div className="flex items-center gap-4">
<span className={`px-3 py-1 rounded-full text-sm font-semibold border ${getStatusColor(taskData.status)}`}>
{taskData.status.replace('_', ' ')}
</span>
<span className={`px-3 py-1 rounded-full text-sm font-semibold border ${getPriorityColor(taskData.priority)}`}>
{taskData.priority}
</span>
</div>
{/* Description */}
{taskData.description && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-2">Description</h3>
<p className="text-gray-600">{taskData.description}</p>
</div>
)}
{/* Details Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<User className="w-5 h-5 text-gray-400" />
<div>
<p className="text-xs text-gray-500">Assigned To</p>
<p className="text-sm font-medium text-gray-900">
{taskData.assigned_to_name || 'Unassigned'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-gray-400" />
<div>
<p className="text-xs text-gray-500">Due Date</p>
<p className="text-sm font-medium text-gray-900">
{taskData.due_date ? formatDate(new Date(taskData.due_date), 'short') : 'No due date'}
</p>
</div>
</div>
{taskData.completed_at && (
<div className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="text-xs text-gray-500">Completed At</p>
<p className="text-sm font-medium text-gray-900">
{formatDate(new Date(taskData.completed_at), 'short')}
</p>
</div>
</div>
)}
{taskData.actual_duration_minutes && (
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-gray-400" />
<div>
<p className="text-xs text-gray-500">Duration</p>
<p className="text-sm font-medium text-gray-900">
{taskData.actual_duration_minutes} minutes
</p>
</div>
</div>
)}
</div>
{/* Notes */}
{taskData.notes && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-2">Notes</h3>
<p className="text-gray-600 whitespace-pre-wrap">{taskData.notes}</p>
</div>
)}
{/* Comments */}
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-4">Comments</h3>
<div className="space-y-4">
{taskData.comments && taskData.comments.length > 0 ? (
taskData.comments.map((comment) => (
<div key={comment.id} className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-gray-900">{comment.user_name}</p>
<p className="text-xs text-gray-500">
{formatDate(new Date(comment.created_at), 'short')}
</p>
</div>
<p className="text-sm text-gray-700">{comment.comment}</p>
</div>
))
) : (
<p className="text-sm text-gray-500">No comments yet</p>
)}
</div>
{/* Add Comment */}
<div className="mt-4 flex gap-2">
<input
type="text"
value={comment}
onChange={(e) => setComment(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleAddComment()}
placeholder="Add a comment..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
<button
onClick={handleAddComment}
disabled={loading || !comment.trim()}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Footer Actions */}
<div className="sticky bottom-0 bg-gray-50 border-t border-gray-200 p-6 flex items-center justify-end gap-3">
{taskData.status === 'assigned' && (
<button
onClick={handleStartTask}
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
>
<Play className="w-4 h-4" />
Start Task
</button>
)}
{taskData.status === 'in_progress' && (
<button
onClick={handleCompleteTask}
disabled={loading}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center gap-2"
>
<CheckCircle2 className="w-4 h-4" />
Complete Task
</button>
)}
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Close
</button>
</div>
</div>
</div>
);
};
export default TaskDetailModal;

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { Filter } from 'lucide-react';
interface TaskFiltersProps {
filters: {
status: string;
priority: string;
task_type: string;
assigned_to: string;
search: string;
};
onFiltersChange: (filters: any) => void;
}
const TaskFilters: React.FC<TaskFiltersProps> = ({ filters, onFiltersChange }) => {
return (
<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-5 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Status</label>
<select
value={filters.status}
onChange={(e) => onFiltersChange({ ...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="assigned">Assigned</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
<option value="overdue">Overdue</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Priority</label>
<select
value={filters.priority}
onChange={(e) => onFiltersChange({ ...filters, priority: 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 Priorities</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Task Type</label>
<select
value={filters.task_type}
onChange={(e) => onFiltersChange({ ...filters, task_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="general">General</option>
<option value="room_cleaning">Room Cleaning</option>
<option value="maintenance">Maintenance</option>
<option value="guest_communication">Guest Communication</option>
<option value="check_in">Check In</option>
<option value="check_out">Check Out</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Assigned To (User ID)</label>
<input
type="number"
value={filters.assigned_to}
onChange={(e) => onFiltersChange({ ...filters, assigned_to: e.target.value })}
placeholder="User ID"
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"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Search</label>
<input
type="text"
value={filters.search}
onChange={(e) => onFiltersChange({ ...filters, search: e.target.value })}
placeholder="Search tasks..."
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"
/>
</div>
</div>
</div>
);
};
export default TaskFilters;

View File

@@ -0,0 +1,308 @@
import React, { useState, useEffect } from 'react';
import { X, Plus, Trash2, GripVertical, Save } from 'lucide-react';
import { toast } from 'react-toastify';
import workflowService, { Workflow, WorkflowStep } from '../../services/api/workflowService';
interface WorkflowBuilderProps {
workflow?: Workflow | null;
onClose: () => void;
onSuccess: () => void;
}
const WorkflowBuilder: React.FC<WorkflowBuilderProps> = ({ workflow, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
name: workflow?.name || '',
description: workflow?.description || '',
workflow_type: workflow?.workflow_type || 'custom',
trigger: workflow?.trigger || 'manual',
sla_hours: workflow?.sla_hours?.toString() || '',
trigger_config: workflow?.trigger_config || {},
});
const [steps, setSteps] = useState<WorkflowStep[]>(workflow?.steps || []);
const [loading, setLoading] = useState(false);
const addStep = () => {
setSteps([
...steps,
{
title: '',
description: '',
task_type: 'general',
priority: 'medium',
estimated_duration_minutes: 30,
},
]);
};
const updateStep = (index: number, field: keyof WorkflowStep, value: any) => {
const newSteps = [...steps];
newSteps[index] = { ...newSteps[index], [field]: value };
setSteps(newSteps);
};
const removeStep = (index: number) => {
setSteps(steps.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
toast.error('Workflow name is required');
return;
}
if (steps.length === 0) {
toast.error('At least one step is required');
return;
}
if (steps.some(step => !step.title.trim())) {
toast.error('All steps must have a title');
return;
}
try {
setLoading(true);
if (workflow) {
await workflowService.updateWorkflow(workflow.id, {
name: formData.name,
description: formData.description || undefined,
steps: steps,
trigger_config: formData.trigger_config,
sla_hours: formData.sla_hours ? parseInt(formData.sla_hours) : undefined,
});
toast.success('Workflow updated successfully');
} else {
await workflowService.createWorkflow({
name: formData.name,
description: formData.description || undefined,
workflow_type: formData.workflow_type,
trigger: formData.trigger,
steps: steps,
trigger_config: formData.trigger_config,
sla_hours: formData.sla_hours ? parseInt(formData.sla_hours) : undefined,
});
toast.success('Workflow created successfully');
}
onSuccess();
} catch (error: any) {
toast.error(error.message || 'Failed to save workflow');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-gray-200 p-6 flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">
{workflow ? 'Edit Workflow' : 'Create Workflow'}
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Basic Information */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900">Basic Information</h3>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-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-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Type</label>
<select
value={formData.workflow_type}
onChange={(e) => setFormData({ ...formData, workflow_type: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
disabled={!!workflow}
>
<option value="pre_arrival">Pre-Arrival</option>
<option value="room_preparation">Room Preparation</option>
<option value="maintenance">Maintenance</option>
<option value="guest_communication">Guest Communication</option>
<option value="follow_up">Follow-Up</option>
<option value="custom">Custom</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Trigger</label>
<select
value={formData.trigger}
onChange={(e) => setFormData({ ...formData, trigger: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
disabled={!!workflow}
>
<option value="booking_created">Booking Created</option>
<option value="booking_confirmed">Booking Confirmed</option>
<option value="check_in">Check In</option>
<option value="check_out">Check Out</option>
<option value="maintenance_request">Maintenance Request</option>
<option value="guest_message">Guest Message</option>
<option value="manual">Manual</option>
<option value="scheduled">Scheduled</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">SLA (hours)</label>
<input
type="number"
value={formData.sla_hours}
onChange={(e) => setFormData({ ...formData, sla_hours: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="e.g., 24"
/>
</div>
</div>
{/* Workflow Steps */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Workflow Steps</h3>
<button
type="button"
onClick={addStep}
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-4 h-4" />
Add Step
</button>
</div>
{steps.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No steps added. Click "Add Step" to create workflow steps.
</div>
) : (
<div className="space-y-4">
{steps.map((step, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4 bg-gray-50">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<GripVertical className="w-5 h-5 text-gray-400" />
<span className="text-sm font-semibold text-gray-700">Step {index + 1}</span>
</div>
<button
type="button"
onClick={() => removeStep(index)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Title *</label>
<input
type="text"
value={step.title}
onChange={(e) => updateStep(index, 'title', 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"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Task Type</label>
<select
value={step.task_type}
onChange={(e) => updateStep(index, 'task_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="general">General</option>
<option value="room_cleaning">Room Cleaning</option>
<option value="maintenance">Maintenance</option>
<option value="guest_communication">Guest Communication</option>
<option value="check_in">Check In</option>
<option value="check_out">Check Out</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Priority</label>
<select
value={step.priority}
onChange={(e) => updateStep(index, 'priority', 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="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Duration (minutes)</label>
<input
type="number"
value={step.estimated_duration_minutes || ''}
onChange={(e) => updateStep(index, 'estimated_duration_minutes', parseInt(e.target.value) || 0)}
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"
/>
</div>
</div>
<div className="mt-4">
<label className="block text-xs font-semibold text-gray-700 mb-1">Description</label>
<textarea
value={step.description || ''}
onChange={(e) => updateStep(index, 'description', e.target.value)}
rows={2}
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"
/>
</div>
</div>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2"
>
<Save className="w-4 h-4" />
{loading ? 'Saving...' : workflow ? 'Update Workflow' : 'Create Workflow'}
</button>
</div>
</form>
</div>
</div>
);
};
export default WorkflowBuilder;

View File

@@ -0,0 +1,97 @@
import React from 'react';
import { X, Clock, CheckCircle2, Play } from 'lucide-react';
import { Workflow } from '../../services/api/workflowService';
import { formatDate } from '../../utils/format';
interface WorkflowDetailModalProps {
workflow: Workflow;
onClose: () => void;
}
const WorkflowDetailModal: React.FC<WorkflowDetailModalProps> = ({ workflow, onClose }) => {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-gray-200 p-6 flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">{workflow.name}</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-6">
{workflow.description && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-2">Description</h3>
<p className="text-gray-600">{workflow.description}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500 mb-1">Type</p>
<p className="text-sm font-medium text-gray-900">{workflow.workflow_type.replace('_', ' ')}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Trigger</p>
<p className="text-sm font-medium text-gray-900">{workflow.trigger.replace('_', ' ')}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Status</p>
<p className="text-sm font-medium text-gray-900">{workflow.status}</p>
</div>
{workflow.sla_hours && (
<div>
<p className="text-xs text-gray-500 mb-1">SLA</p>
<p className="text-sm font-medium text-gray-900">{workflow.sla_hours} hours</p>
</div>
)}
</div>
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-4">Workflow Steps ({workflow.steps.length})</h3>
<div className="space-y-3">
{workflow.steps.map((step, index) => (
<div key={index} className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center">
<span className="text-sm font-semibold text-indigo-600">{index + 1}</span>
</div>
<div className="flex-1">
<p className="font-semibold text-gray-900">{step.title}</p>
{step.description && (
<p className="text-sm text-gray-600 mt-1">{step.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 ml-11">
<span>Type: {step.task_type.replace('_', ' ')}</span>
<span>Priority: {step.priority}</span>
{step.estimated_duration_minutes && (
<span>Duration: {step.estimated_duration_minutes} min</span>
)}
</div>
</div>
))}
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Close
</button>
</div>
</div>
</div>
</div>
);
};
export default WorkflowDetailModal;

View File

@@ -0,0 +1,56 @@
import React, {
createContext,
useContext,
useEffect,
useState,
ReactNode,
} from 'react';
import { useLocation } from 'react-router-dom';
type NavigationLoadingContextValue = {
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
};
const NavigationLoadingContext = createContext<NavigationLoadingContextValue | undefined>(undefined);
export const useNavigationLoading = () => {
const context = useContext(NavigationLoadingContext);
if (!context) {
throw new Error('useNavigationLoading must be used within NavigationLoadingProvider');
}
return context;
};
interface NavigationLoadingProviderProps {
children: ReactNode;
}
export const NavigationLoadingProvider: React.FC<NavigationLoadingProviderProps> = ({ children }) => {
const [isLoading, setIsLoading] = useState(false);
const location = useLocation();
useEffect(() => {
// Show preloader when route changes
setIsLoading(true);
// Hide preloader after a short delay to allow page to render
const timer = setTimeout(() => {
setIsLoading(false);
}, 300);
return () => clearTimeout(timer);
}, [location.pathname]);
return (
<NavigationLoadingContext.Provider
value={{
isLoading,
setIsLoading,
}}
>
{children}
</NavigationLoadingContext.Provider>
);
};

View File

@@ -0,0 +1,52 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { useResponsive, type UseResponsiveReturn } from '../hooks/useResponsive';
interface ResponsiveContextType extends UseResponsiveReturn {}
const ResponsiveContext = createContext<ResponsiveContextType | undefined>(undefined);
interface ResponsiveProviderProps {
children: ReactNode;
}
/**
* Responsive Context Provider
* Provides responsive state to all child components
*
* @example
* ```tsx
* // In App.tsx or main layout
* <ResponsiveProvider>
* <App />
* </ResponsiveProvider>
*
* // In any component
* const { isMobile, width } = useResponsiveContext();
* ```
*/
export const ResponsiveProvider: React.FC<ResponsiveProviderProps> = ({ children }) => {
const responsive = useResponsive();
return (
<ResponsiveContext.Provider value={responsive}>
{children}
</ResponsiveContext.Provider>
);
};
/**
* Hook to access responsive context
* Must be used within ResponsiveProvider
*
* @throws Error if used outside ResponsiveProvider
*/
export const useResponsiveContext = (): ResponsiveContextType => {
const context = useContext(ResponsiveContext);
if (context === undefined) {
throw new Error('useResponsiveContext must be used within a ResponsiveProvider');
}
return context;
};

View File

@@ -4,4 +4,6 @@ export { useLocalStorage } from './useLocalStorage';
export { useOffline } from './useOffline';
export { useClickOutside } from './useClickOutside';
export { default as usePagePerformance } from './usePagePerformance';
export { useResponsive } from './useResponsive';
export type { UseResponsiveReturn, ResponsiveState } from './useResponsive';

View File

@@ -0,0 +1,129 @@
import { useState, useEffect, useCallback } from 'react';
import {
BREAKPOINTS,
getDeviceType,
getCurrentBreakpoint,
isBreakpoint,
isBetweenBreakpoints,
isMobile,
isTablet,
isDesktop,
isMobileOrTablet,
type BreakpointKey,
type DeviceType,
} from '../services/responsiveService';
export interface ResponsiveState {
width: number;
height: number;
deviceType: DeviceType;
breakpoint: BreakpointKey;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
isMobileOrTablet: boolean;
}
export interface UseResponsiveReturn extends ResponsiveState {
/**
* Check if current width matches or exceeds a breakpoint
*/
matches: (breakpoint: BreakpointKey) => boolean;
/**
* Check if current width is between two breakpoints
*/
between: (min: BreakpointKey, max: BreakpointKey) => boolean;
/**
* Get the current window dimensions
*/
dimensions: { width: number; height: number };
}
/**
* Custom hook for responsive design
* Tracks window size and provides responsive utilities
*
* @example
* ```tsx
* const { isMobile, isTablet, isDesktop, matches, width } = useResponsive();
*
* if (isMobile) {
* return <MobileComponent />;
* }
*
* if (matches('lg')) {
* return <DesktopComponent />;
* }
* ```
*/
export const useResponsive = (): UseResponsiveReturn => {
const [dimensions, setDimensions] = useState(() => {
// Initialize with current window dimensions
if (typeof window !== 'undefined') {
return {
width: window.innerWidth,
height: window.innerHeight,
};
}
return { width: 0, height: 0 };
});
useEffect(() => {
// Handler to update dimensions
const handleResize = () => {
setDimensions({
width: window.innerWidth,
height: window.innerHeight,
});
};
// Set initial dimensions
handleResize();
// Add event listener
window.addEventListener('resize', handleResize);
// Cleanup
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
// Memoize computed values
const deviceType = getDeviceType(dimensions.width);
const breakpoint = getCurrentBreakpoint(dimensions.width);
const mobile = isMobile(dimensions.width);
const tablet = isTablet(dimensions.width);
const desktop = isDesktop(dimensions.width);
const mobileOrTablet = isMobileOrTablet(dimensions.width);
// Memoize utility functions
const matches = useCallback(
(breakpointKey: BreakpointKey) => {
return isBreakpoint(dimensions.width, breakpointKey);
},
[dimensions.width]
);
const between = useCallback(
(min: BreakpointKey, max: BreakpointKey) => {
return isBetweenBreakpoints(dimensions.width, min, max);
},
[dimensions.width]
);
return {
width: dimensions.width,
height: dimensions.height,
deviceType,
breakpoint,
isMobile: mobile,
isTablet: tablet,
isDesktop: desktop,
isMobileOrTablet: mobileOrTablet,
matches,
between,
dimensions,
};
};

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,29 @@
/**
* Accountant Routes
*
* Routes accessible only to accountants
*/
import { lazy } from 'react';
import { RouteObject, Navigate } from 'react-router-dom';
const AccountantDashboardPage = lazy(() => import('../pages/accountant/DashboardPage'));
const PaymentManagementPage = lazy(() => import('../pages/accountant/PaymentManagementPage'));
const InvoiceManagementPage = lazy(() => import('../pages/accountant/InvoiceManagementPage'));
const AnalyticsDashboardPage = lazy(() => import('../pages/accountant/AnalyticsDashboardPage'));
const accountantRoutes: RouteObject[] = [
{
path: '/accountant',
children: [
{ index: true, element: <Navigate to="dashboard" replace /> },
{ path: 'dashboard', element: <AccountantDashboardPage /> },
{ path: 'payments', element: <PaymentManagementPage /> },
{ path: 'invoices', element: <InvoiceManagementPage /> },
{ path: 'reports', element: <AnalyticsDashboardPage /> },
],
},
];
export default accountantRoutes;

View File

@@ -0,0 +1,65 @@
/**
* Admin Routes
*
* Routes accessible only to administrators
*/
import { lazy } from 'react';
import { RouteObject, Navigate } from 'react-router-dom';
const AdminDashboardPage = lazy(() => import('../pages/admin/DashboardPage'));
const UserManagementPage = lazy(() => import('../pages/admin/UserManagementPage'));
const GuestProfilePage = lazy(() => import('../pages/admin/GuestProfilePage'));
const GroupBookingManagementPage = lazy(() => import('../pages/admin/GroupBookingManagementPage'));
const BusinessDashboardPage = lazy(() => import('../pages/admin/BusinessDashboardPage'));
const ReceptionDashboardPage = lazy(() => import('../pages/admin/ReceptionDashboardPage'));
const AdvancedRoomManagementPage = lazy(() => import('../pages/admin/AdvancedRoomManagementPage'));
const PageContentDashboardPage = lazy(() => import('../pages/admin/PageContentDashboard'));
const AnalyticsDashboardPage = lazy(() => import('../pages/admin/AnalyticsDashboardPage'));
const TaskManagementPage = lazy(() => import('../pages/admin/TaskManagementPage'));
const WorkflowManagementPage = lazy(() => import('../pages/admin/WorkflowManagementPage'));
const NotificationManagementPage = lazy(() => import('../pages/admin/NotificationManagementPage'));
const SettingsPage = lazy(() => import('../pages/admin/SettingsPage'));
const InvoiceManagementPage = lazy(() => import('../pages/admin/InvoiceManagementPage'));
const PaymentManagementPage = lazy(() => import('../pages/admin/PaymentManagementPage'));
const LoyaltyManagementPage = lazy(() => import('../pages/admin/LoyaltyManagementPage'));
const BookingManagementPage = lazy(() => import('../pages/admin/BookingManagementPage'));
const RatePlanManagementPage = lazy(() => import('../pages/admin/RatePlanManagementPage'));
const PackageManagementPage = lazy(() => import('../pages/admin/PackageManagementPage'));
const SecurityManagementPage = lazy(() => import('../pages/admin/SecurityManagementPage'));
const EmailCampaignManagementPage = lazy(() => import('../pages/admin/EmailCampaignManagementPage'));
const InvoicePage = lazy(() => import('../pages/customer/InvoicePage'));
const adminRoutes: RouteObject[] = [
{
path: '/admin',
children: [
{ index: true, element: <Navigate to="dashboard" replace /> },
{ path: 'dashboard', element: <AdminDashboardPage /> },
{ path: 'users', element: <UserManagementPage /> },
{ path: 'guest-profiles', element: <GuestProfilePage /> },
{ path: 'group-bookings', element: <GroupBookingManagementPage /> },
{ path: 'business', element: <BusinessDashboardPage /> },
{ path: 'reception', element: <ReceptionDashboardPage /> },
{ path: 'advanced-rooms', element: <AdvancedRoomManagementPage /> },
{ path: 'page-content', element: <PageContentDashboardPage /> },
{ path: 'analytics', element: <AnalyticsDashboardPage /> },
{ path: 'tasks', element: <TaskManagementPage /> },
{ path: 'workflows', element: <WorkflowManagementPage /> },
{ path: 'notifications', element: <NotificationManagementPage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'invoices', element: <InvoiceManagementPage /> },
{ path: 'invoices/:id', element: <InvoicePage /> },
{ path: 'payments', element: <PaymentManagementPage /> },
{ path: 'loyalty', element: <LoyaltyManagementPage /> },
{ path: 'bookings', element: <BookingManagementPage /> },
{ path: 'rate-plans', element: <RatePlanManagementPage /> },
{ path: 'packages', element: <PackageManagementPage /> },
{ path: 'security', element: <SecurityManagementPage /> },
{ path: 'email-campaigns', element: <EmailCampaignManagementPage /> },
],
},
];
export default adminRoutes;

View File

@@ -0,0 +1,42 @@
/**
* Customer Routes
*
* Routes accessible only to customers (authenticated users with customer role)
*/
import { lazy } from 'react';
import { RouteObject } from 'react-router-dom';
const DashboardPage = lazy(() => import('../pages/customer/DashboardPage'));
const FavoritesPage = lazy(() => import('../pages/customer/FavoritesPage'));
const MyBookingsPage = lazy(() => import('../pages/customer/MyBookingsPage'));
const BookingSuccessPage = lazy(() => import('../pages/customer/BookingSuccessPage'));
const BookingDetailPage = lazy(() => import('../pages/customer/BookingDetailPage'));
const FullPaymentPage = lazy(() => import('../pages/customer/FullPaymentPage'));
const PaymentConfirmationPage = lazy(() => import('../pages/customer/PaymentConfirmationPage'));
const InvoicePage = lazy(() => import('../pages/customer/InvoicePage'));
const ProfilePage = lazy(() => import('../pages/customer/ProfilePage'));
const LoyaltyPage = lazy(() => import('../pages/customer/LoyaltyPage'));
const GroupBookingPage = lazy(() => import('../pages/customer/GroupBookingPage'));
const customerRoutes: RouteObject[] = [
{
path: '/',
children: [
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'favorites', element: <FavoritesPage /> },
{ path: 'booking-success/:id', element: <BookingSuccessPage /> },
{ path: 'bookings', element: <MyBookingsPage /> },
{ path: 'bookings/:id', element: <BookingDetailPage /> },
{ path: 'payment/:bookingId', element: <FullPaymentPage /> },
{ path: 'payment-confirmation/:id', element: <PaymentConfirmationPage /> },
{ path: 'invoices/:id', element: <InvoicePage /> },
{ path: 'profile', element: <ProfilePage /> },
{ path: 'loyalty', element: <LoyaltyPage /> },
{ path: 'group-bookings', element: <GroupBookingPage /> },
],
},
];
export default customerRoutes;

View File

@@ -0,0 +1,13 @@
/**
* Routes Configuration
*
* This file exports all route configurations organized by role.
* Each role has its own route file to maintain clear separation.
*/
export { default as publicRoutes } from './publicRoutes';
export { default as customerRoutes } from './customerRoutes';
export { default as adminRoutes } from './adminRoutes';
export { default as staffRoutes } from './staffRoutes';
export { default as accountantRoutes } from './accountantRoutes';

View File

@@ -0,0 +1,50 @@
/**
* Public Routes
*
* Routes accessible to all users (authenticated and unauthenticated)
*/
import { lazy } from 'react';
import { RouteObject } from 'react-router-dom';
const HomePage = lazy(() => import('../pages/HomePage'));
const RoomListPage = lazy(() => import('../pages/customer/RoomListPage'));
const RoomDetailPage = lazy(() => import('../pages/customer/RoomDetailPage'));
const SearchResultsPage = lazy(() => import('../pages/customer/SearchResultsPage'));
const AboutPage = lazy(() => import('../pages/AboutPage'));
const ContactPage = lazy(() => import('../pages/ContactPage'));
const PrivacyPolicyPage = lazy(() => import('../pages/PrivacyPolicyPage'));
const TermsPage = lazy(() => import('../pages/TermsPage'));
const RefundsPolicyPage = lazy(() => import('../pages/RefundsPolicyPage'));
const CancellationPolicyPage = lazy(() => import('../pages/CancellationPolicyPage'));
const AccessibilityPage = lazy(() => import('../pages/AccessibilityPage'));
const FAQPage = lazy(() => import('../pages/FAQPage'));
const PaymentResultPage = lazy(() => import('../pages/customer/PaymentResultPage'));
const PayPalReturnPage = lazy(() => import('../pages/customer/PayPalReturnPage'));
const PayPalCancelPage = lazy(() => import('../pages/customer/PayPalCancelPage'));
const publicRoutes: RouteObject[] = [
{
path: '/',
children: [
{ index: true, element: <HomePage /> },
{ path: 'rooms', element: <RoomListPage /> },
{ path: 'rooms/search', element: <SearchResultsPage /> },
{ path: 'rooms/:room_number', element: <RoomDetailPage /> },
{ path: 'payment-result', element: <PaymentResultPage /> },
{ path: 'payment/paypal/return', element: <PayPalReturnPage /> },
{ path: 'payment/paypal/cancel', element: <PayPalCancelPage /> },
{ path: 'about', element: <AboutPage /> },
{ path: 'contact', element: <ContactPage /> },
{ path: 'privacy', element: <PrivacyPolicyPage /> },
{ path: 'terms', element: <TermsPage /> },
{ path: 'refunds', element: <RefundsPolicyPage /> },
{ path: 'cancellation', element: <CancellationPolicyPage /> },
{ path: 'accessibility', element: <AccessibilityPage /> },
{ path: 'faq', element: <FAQPage /> },
],
},
];
export default publicRoutes;

View File

@@ -0,0 +1,39 @@
/**
* Staff Routes
*
* Routes accessible only to staff members
*/
import { lazy } from 'react';
import { RouteObject, Navigate } from 'react-router-dom';
const StaffDashboardPage = lazy(() => import('../pages/staff/DashboardPage'));
const ChatManagementPage = lazy(() => import('../pages/staff/ChatManagementPage'));
const BookingManagementPage = lazy(() => import('../pages/staff/BookingManagementPage'));
const ReceptionDashboardPage = lazy(() => import('../pages/staff/ReceptionDashboardPage'));
const PaymentManagementPage = lazy(() => import('../pages/staff/PaymentManagementPage'));
const AnalyticsDashboardPage = lazy(() => import('../pages/staff/AnalyticsDashboardPage'));
const LoyaltyManagementPage = lazy(() => import('../pages/staff/LoyaltyManagementPage'));
const GuestProfilePage = lazy(() => import('../pages/staff/GuestProfilePage'));
const AdvancedRoomManagementPage = lazy(() => import('../pages/staff/AdvancedRoomManagementPage'));
const staffRoutes: RouteObject[] = [
{
path: '/staff',
children: [
{ index: true, element: <Navigate to="dashboard" replace /> },
{ path: 'dashboard', element: <StaffDashboardPage /> },
{ path: 'bookings', element: <BookingManagementPage /> },
{ path: 'reception', element: <ReceptionDashboardPage /> },
{ path: 'payments', element: <PaymentManagementPage /> },
{ path: 'reports', element: <AnalyticsDashboardPage /> },
{ path: 'chats', element: <ChatManagementPage /> },
{ path: 'loyalty', element: <LoyaltyManagementPage /> },
{ path: 'guest-profiles', element: <GuestProfilePage /> },
{ path: 'advanced-rooms', element: <AdvancedRoomManagementPage /> },
],
},
];
export default staffRoutes;

View File

@@ -0,0 +1,268 @@
import apiClient from './apiClient';
// Types
export interface MaintenanceRecord {
id: number;
room_id: number;
room_number?: string;
maintenance_type: 'preventive' | 'corrective' | 'emergency' | 'upgrade' | 'inspection';
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'on_hold';
title: string;
description?: string;
scheduled_start: string;
scheduled_end?: string;
actual_start?: string;
actual_end?: string;
assigned_to?: number;
assigned_staff_name?: string;
priority: 'low' | 'medium' | 'high' | 'urgent';
blocks_room: boolean;
block_start?: string;
block_end?: string;
estimated_cost?: number;
actual_cost?: number;
notes?: string;
created_at: string;
}
export interface HousekeepingTask {
id: number;
room_id: number;
room_number?: string;
booking_id?: number;
task_type: 'checkout' | 'stayover' | 'vacant' | 'inspection' | 'turndown';
status: 'pending' | 'in_progress' | 'completed' | 'skipped' | 'cancelled';
scheduled_time: string;
started_at?: string;
completed_at?: string;
assigned_to?: number;
assigned_staff_name?: string;
checklist_items?: ChecklistItem[];
notes?: string;
quality_score?: number;
estimated_duration_minutes?: number;
actual_duration_minutes?: number;
}
export interface ChecklistItem {
item: string;
completed: boolean;
notes?: string;
}
export interface RoomInspection {
id: number;
room_id: number;
room_number?: string;
booking_id?: number;
inspection_type: 'pre_checkin' | 'post_checkout' | 'routine' | 'maintenance' | 'damage';
status: 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
scheduled_at: string;
started_at?: string;
completed_at?: string;
inspected_by?: number;
inspector_name?: string;
checklist_items: InspectionChecklistItem[];
overall_score?: number;
overall_notes?: string;
issues_found?: Issue[];
requires_followup: boolean;
created_at: string;
}
export interface InspectionChecklistItem {
category: string;
item: string;
status: 'pass' | 'fail' | 'needs_attention' | 'not_applicable';
notes?: string;
photos?: string[];
}
export interface Issue {
severity: 'critical' | 'major' | 'minor' | 'cosmetic';
description: string;
photo?: string;
}
export interface RoomStatusBoardItem {
id: number;
room_number: string;
floor: number;
status: 'available' | 'occupied' | 'maintenance' | 'cleaning';
room_type?: string;
current_booking?: {
id: number;
guest_name: string;
check_out: string;
};
active_maintenance?: {
id: number;
title: string;
type: string;
};
pending_housekeeping_count: number;
}
export interface OptimalRoomAssignmentRequest {
room_type_id: number;
check_in: string;
check_out: string;
num_guests: number;
guest_preferences?: {
view?: string;
floor?: 'high' | 'low' | number;
quiet?: boolean;
accessible?: boolean;
};
exclude_room_ids?: number[];
}
// Service
const advancedRoomService = {
// Room Assignment Optimization
async assignOptimalRoom(request: OptimalRoomAssignmentRequest) {
const response = await apiClient.post('/advanced-rooms/assign-optimal-room', request);
return response.data;
},
async getRoomAvailabilityCalendar(roomId: number, startDate: string, endDate: string) {
const response = await apiClient.get(
`/advanced-rooms/${roomId}/availability-calendar`,
{ params: { start_date: startDate, end_date: endDate } }
);
return response.data;
},
// Maintenance Management
async getMaintenanceRecords(params?: {
room_id?: number;
status?: string;
maintenance_type?: string;
page?: number;
limit?: number;
}) {
const response = await apiClient.get('/advanced-rooms/maintenance', { params });
return response.data;
},
async createMaintenanceRecord(data: {
room_id: number;
maintenance_type: string;
title: string;
description?: string;
scheduled_start: string;
scheduled_end?: string;
assigned_to?: number;
estimated_cost?: number;
blocks_room?: boolean;
block_start?: string;
block_end?: string;
priority?: string;
notes?: string;
}) {
const response = await apiClient.post('/advanced-rooms/maintenance', data);
return response.data;
},
async updateMaintenanceRecord(maintenanceId: number, data: {
status?: string;
actual_start?: string;
actual_end?: string;
completion_notes?: string;
actual_cost?: number;
}) {
const response = await apiClient.put(`/advanced-rooms/maintenance/${maintenanceId}`, data);
return response.data;
},
// Housekeeping Tasks
async getHousekeepingTasks(params?: {
room_id?: number;
status?: string;
task_type?: string;
date?: string;
page?: number;
limit?: number;
}) {
const response = await apiClient.get('/advanced-rooms/housekeeping', { params });
return response.data;
},
async createHousekeepingTask(data: {
room_id: number;
booking_id?: number;
task_type: string;
scheduled_time: string;
assigned_to?: number;
checklist_items?: ChecklistItem[];
notes?: string;
estimated_duration_minutes?: number;
}) {
const response = await apiClient.post('/advanced-rooms/housekeeping', data);
return response.data;
},
async updateHousekeepingTask(taskId: number, data: {
status?: string;
checklist_items?: ChecklistItem[];
notes?: string;
issues_found?: string;
quality_score?: number;
inspected_by?: number;
inspection_notes?: string;
}) {
const response = await apiClient.put(`/advanced-rooms/housekeeping/${taskId}`, data);
return response.data;
},
// Room Inspections
async getRoomInspections(params?: {
room_id?: number;
inspection_type?: string;
status?: string;
page?: number;
limit?: number;
}) {
const response = await apiClient.get('/advanced-rooms/inspections', { params });
return response.data;
},
async createRoomInspection(data: {
room_id: number;
booking_id?: number;
inspection_type: string;
scheduled_at: string;
inspected_by?: number;
checklist_items: InspectionChecklistItem[];
checklist_template_id?: number;
}) {
const response = await apiClient.post('/advanced-rooms/inspections', data);
return response.data;
},
async updateRoomInspection(inspectionId: number, data: {
status?: string;
checklist_items?: InspectionChecklistItem[];
overall_score?: number;
overall_notes?: string;
issues_found?: Issue[];
photos?: string[];
requires_followup?: boolean;
followup_notes?: string;
maintenance_request_id?: number;
}) {
const response = await apiClient.put(`/advanced-rooms/inspections/${inspectionId}`, data);
return response.data;
},
// Room Status Board
async getRoomStatusBoard(floor?: number) {
const response = await apiClient.get('/advanced-rooms/status-board', {
params: floor ? { floor } : {}
});
return response.data;
},
};
export default advancedRoomService;

View File

@@ -0,0 +1,327 @@
import apiClient from './apiClient';
// ==================== REVENUE ANALYTICS ====================
export interface RevPARData {
revpar: number;
total_revenue: number;
available_room_nights: number;
period_days: number;
total_rooms: number;
}
export interface ADRData {
adr: number;
period: {
start: string | null;
end: string | null;
};
}
export interface OccupancyRateData {
occupancy_rate: number;
occupied_room_nights: number;
available_room_nights: number;
period_days: number;
}
export interface RevenueForecastData {
forecast: Array<{
date: string;
forecasted_revenue: number;
confidence: string;
}>;
average_daily_revenue: number;
forecast_period: number;
based_on_days: number;
}
export interface MarketPenetrationData {
total_bookings: number;
penetration_by_room_type: Array<{
room_type: string;
bookings: number;
revenue: number;
market_share: number;
}>;
period: {
start: string;
end: string;
};
}
// ==================== OPERATIONAL ANALYTICS ====================
export interface StaffPerformanceData {
staff_performance: Array<{
staff_id: number;
staff_name: string;
email: string;
check_ins_handled: number;
performance_score: number;
}>;
period: {
start: string;
end: string;
};
}
export interface ServiceUsageData {
services: Array<{
service_id: number;
service_name: string;
category: string;
usage_count: number;
total_revenue: number;
average_price: number;
}>;
total_services: number;
total_usage: number;
total_revenue: number;
}
export interface OperationalEfficiencyData {
conversion_rate: number;
average_booking_value: number;
cancellation_rate: number;
total_bookings: number;
confirmed_bookings: number;
cancelled_bookings: number;
}
// ==================== GUEST ANALYTICS ====================
export interface GuestLTVData {
guests: Array<{
user_id: number;
name: string;
email: string;
total_bookings: number;
lifetime_value: number;
average_booking_value: number;
}>;
average_ltv: number;
total_guests_analyzed: number;
}
export interface CustomerAcquisitionCostData {
new_customers: number;
period: {
start: string;
end: string;
};
note: string;
}
export interface RepeatGuestRateData {
repeat_guest_rate: number;
total_guests: number;
repeat_guests: number;
one_time_guests: number;
}
export interface GuestSatisfactionTrendsData {
trends: Array<{
date: string;
average_rating: number;
review_count: number;
}>;
overall_average_rating: number;
total_reviews: number;
}
// ==================== FINANCIAL ANALYTICS ====================
export interface ProfitLossData {
total_revenue: number;
refunds: number;
net_revenue: number;
gross_profit: number;
period: {
start: string;
end: string;
};
}
export interface PaymentMethodAnalyticsData {
payment_methods: Array<{
payment_method: string;
transaction_count: number;
total_amount: number;
average_amount: number;
percentage: number;
}>;
total_transactions: number;
total_amount: number;
}
export interface RefundAnalysisData {
refunds: Array<{
date: string;
refund_count: number;
refund_amount: number;
}>;
total_refund_amount: number;
total_refund_count: number;
average_refund_amount: number;
}
// ==================== COMPREHENSIVE ANALYTICS ====================
export interface ComprehensiveAnalyticsData {
period: {
start: string | null;
end: string | null;
};
revenue?: {
revpar: RevPARData;
adr: ADRData;
occupancy_rate: OccupancyRateData;
revenue_forecast: RevenueForecastData;
market_penetration: MarketPenetrationData;
};
operational?: {
staff_performance: StaffPerformanceData;
service_usage: ServiceUsageData;
operational_efficiency: OperationalEfficiencyData;
};
guest?: {
lifetime_value: GuestLTVData;
customer_acquisition_cost: CustomerAcquisitionCostData;
repeat_guest_rate: RepeatGuestRateData;
satisfaction_trends: GuestSatisfactionTrendsData;
};
financial?: {
profit_loss: ProfitLossData;
payment_methods: PaymentMethodAnalyticsData;
refund_analysis: RefundAnalysisData;
};
}
export interface AnalyticsParams {
from?: string;
to?: string;
days?: number;
include_revenue?: boolean;
include_operational?: boolean;
include_guest?: boolean;
include_financial?: boolean;
}
// ==================== API FUNCTIONS ====================
// Revenue Analytics
export const getRevPAR = async (params?: AnalyticsParams): Promise<{ status: string; data: RevPARData }> => {
const response = await apiClient.get('/analytics/revenue/revpar', { params });
return response.data;
};
export const getADR = async (params?: AnalyticsParams): Promise<{ status: string; data: ADRData }> => {
const response = await apiClient.get('/analytics/revenue/adr', { params });
return response.data;
};
export const getOccupancyRate = async (params?: AnalyticsParams): Promise<{ status: string; data: OccupancyRateData }> => {
const response = await apiClient.get('/analytics/revenue/occupancy', { params });
return response.data;
};
export const getRevenueForecast = async (days: number = 30): Promise<{ status: string; data: RevenueForecastData }> => {
const response = await apiClient.get('/analytics/revenue/forecast', { params: { days } });
return response.data;
};
export const getMarketPenetration = async (params?: AnalyticsParams): Promise<{ status: string; data: MarketPenetrationData }> => {
const response = await apiClient.get('/analytics/revenue/market-penetration', { params });
return response.data;
};
// Operational Analytics
export const getStaffPerformance = async (params?: AnalyticsParams): Promise<{ status: string; data: StaffPerformanceData }> => {
const response = await apiClient.get('/analytics/operational/staff-performance', { params });
return response.data;
};
export const getServiceUsageAnalytics = async (params?: AnalyticsParams): Promise<{ status: string; data: ServiceUsageData }> => {
const response = await apiClient.get('/analytics/operational/service-usage', { params });
return response.data;
};
export const getOperationalEfficiency = async (params?: AnalyticsParams): Promise<{ status: string; data: OperationalEfficiencyData }> => {
const response = await apiClient.get('/analytics/operational/efficiency', { params });
return response.data;
};
// Guest Analytics
export const getGuestLifetimeValue = async (params?: AnalyticsParams): Promise<{ status: string; data: GuestLTVData }> => {
const response = await apiClient.get('/analytics/guest/lifetime-value', { params });
return response.data;
};
export const getCustomerAcquisitionCost = async (params?: AnalyticsParams): Promise<{ status: string; data: CustomerAcquisitionCostData }> => {
const response = await apiClient.get('/analytics/guest/acquisition-cost', { params });
return response.data;
};
export const getRepeatGuestRate = async (params?: AnalyticsParams): Promise<{ status: string; data: RepeatGuestRateData }> => {
const response = await apiClient.get('/analytics/guest/repeat-rate', { params });
return response.data;
};
export const getGuestSatisfactionTrends = async (params?: AnalyticsParams): Promise<{ status: string; data: GuestSatisfactionTrendsData }> => {
const response = await apiClient.get('/analytics/guest/satisfaction-trends', { params });
return response.data;
};
// Financial Analytics
export const getProfitLoss = async (params?: AnalyticsParams): Promise<{ status: string; data: ProfitLossData }> => {
const response = await apiClient.get('/analytics/financial/profit-loss', { params });
return response.data;
};
export const getPaymentMethodAnalytics = async (params?: AnalyticsParams): Promise<{ status: string; data: PaymentMethodAnalyticsData }> => {
const response = await apiClient.get('/analytics/financial/payment-methods', { params });
return response.data;
};
export const getRefundAnalysis = async (params?: AnalyticsParams): Promise<{ status: string; data: RefundAnalysisData }> => {
const response = await apiClient.get('/analytics/financial/refunds', { params });
return response.data;
};
// Comprehensive Analytics
export const getComprehensiveAnalytics = async (params?: AnalyticsParams): Promise<{ status: string; data: ComprehensiveAnalyticsData }> => {
const response = await apiClient.get('/analytics/comprehensive', { params });
return response.data;
};
const analyticsService = {
// Revenue Analytics
getRevPAR,
getADR,
getOccupancyRate,
getRevenueForecast,
getMarketPenetration,
// Operational Analytics
getStaffPerformance,
getServiceUsageAnalytics,
getOperationalEfficiency,
// Guest Analytics
getGuestLifetimeValue,
getCustomerAcquisitionCost,
getRepeatGuestRate,
getGuestSatisfactionTrends,
// Financial Analytics
getProfitLoss,
getPaymentMethodAnalytics,
getRefundAnalysis,
// Comprehensive
getComprehensiveAnalytics,
};
export default analyticsService;

View File

@@ -132,47 +132,77 @@ export interface CheckBookingResponse {
export const createBooking = async (
bookingData: BookingData
): Promise<BookingResponse> => {
const response = await apiClient.post<BookingResponse>(
const response = await apiClient.post<any>(
'/bookings',
bookingData
);
return response.data;
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: data.status === 'success' || data.success === true,
data: data.data || {},
message: data.message,
};
};
export const getMyBookings = async ():
Promise<BookingsResponse> => {
const response = await apiClient.get<BookingsResponse>(
const response = await apiClient.get<any>(
'/bookings/me'
);
return response.data;
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: data.status === 'success' || data.success === true,
data: data.data || { bookings: [] },
message: data.message,
};
};
export const getBookingById = async (
id: number
): Promise<BookingResponse> => {
const response = await apiClient.get<BookingResponse>(
const response = await apiClient.get<any>(
`/bookings/${id}`
);
return response.data;
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: data.status === 'success' || data.success === true,
data: data.data || {},
message: data.message,
};
};
export const cancelBooking = async (
id: number
): Promise<BookingResponse> => {
const response = await apiClient.patch<BookingResponse>(
const response = await apiClient.patch<any>(
`/bookings/${id}/cancel`
);
return response.data;
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: data.status === 'success' || data.success === true,
data: data.data || {},
message: data.message,
};
};
export const checkBookingByNumber = async (
bookingNumber: string
): Promise<CheckBookingResponse> => {
const response =
await apiClient.get<CheckBookingResponse>(
await apiClient.get<any>(
`/bookings/check/${bookingNumber}`
);
return response.data;
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: data.status === 'success' || data.success === true,
data: data.data || {},
message: data.message,
};
};
export const getAllBookings = async (
@@ -185,16 +215,28 @@ export const getAllBookings = async (
endDate?: string;
}
): Promise<BookingsResponse> => {
const response = await apiClient.get<BookingsResponse>('/bookings', { params });
return response.data;
const response = await apiClient.get<any>('/bookings', { params });
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: data.status === 'success' || data.success === true,
data: data.data || { bookings: [] },
message: data.message,
};
};
export const updateBooking = async (
id: number,
data: Partial<Booking>
): Promise<BookingResponse> => {
const response = await apiClient.put<BookingResponse>(`/bookings/${id}`, data);
return response.data;
const response = await apiClient.put<any>(`/bookings/${id}`, data);
const responseData = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: responseData.status === 'success' || responseData.success === true,
data: responseData.data || {},
message: responseData.message,
};
};
export const checkRoomAvailability = async (
@@ -286,11 +328,17 @@ export const generateQRCode = (
export const adminCreateBooking = async (
bookingData: BookingData & { user_id: number; status?: string }
): Promise<BookingResponse> => {
const response = await apiClient.post<BookingResponse>(
const response = await apiClient.post<any>(
'/bookings/admin-create',
bookingData
);
return response.data;
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: data.status === 'success' || data.success === true,
data: data.data || {},
message: data.message,
};
};
export default {

View File

@@ -0,0 +1,192 @@
import apiClient from './apiClient';
export interface Campaign {
id: number;
name: string;
subject: string;
html_content?: string;
text_content?: string;
campaign_type: string;
status: string;
segment_id?: number;
scheduled_at?: string;
sent_at?: string;
total_recipients: number;
total_sent: number;
total_delivered: number;
total_opened: number;
total_clicked: number;
total_bounced: number;
open_rate?: number;
click_rate?: number;
created_at: string;
}
export interface CampaignSegment {
id: number;
name: string;
description?: string;
criteria: Record<string, any>;
estimated_count?: number;
created_at: string;
}
export interface EmailTemplate {
id: number;
name: string;
subject: string;
html_content: string;
text_content?: string;
category?: string;
variables?: string[];
created_at: string;
}
export interface DripSequence {
id: number;
name: string;
description?: string;
trigger_event?: string;
step_count: number;
created_at: string;
}
export interface CampaignAnalytics {
campaign_id: number;
total_recipients: number;
total_sent: number;
total_delivered: number;
total_opened: number;
total_clicked: number;
total_bounced: number;
total_unsubscribed: number;
open_rate: number;
click_rate: number;
bounce_rate: number;
status_breakdown: Record<string, number>;
}
class EmailCampaignService {
// Campaigns
async getCampaigns(params?: {
status?: string;
campaign_type?: string;
limit?: number;
offset?: number;
}): Promise<Campaign[]> {
const response = await apiClient.get('/email-campaigns', { params });
return response.data;
}
async getCampaign(campaignId: number): Promise<Campaign> {
const response = await apiClient.get(`/email-campaigns/${campaignId}`);
return response.data;
}
async createCampaign(data: {
name: string;
subject: string;
html_content: string;
text_content?: string;
campaign_type?: string;
segment_id?: number;
scheduled_at?: string;
template_id?: number;
from_name?: string;
from_email?: string;
reply_to_email?: string;
track_opens?: boolean;
track_clicks?: boolean;
}): Promise<{ campaign_id: number }> {
const response = await apiClient.post('/email-campaigns', data);
return response.data;
}
async updateCampaign(campaignId: number, data: Partial<{
name: string;
subject: string;
html_content: string;
text_content: string;
segment_id: number;
scheduled_at: string;
status: string;
}>): Promise<void> {
await apiClient.put(`/email-campaigns/${campaignId}`, data);
}
async sendCampaign(campaignId: number): Promise<{ sent: number; failed: number; total: number }> {
const response = await apiClient.post(`/email-campaigns/${campaignId}/send`);
return response.data.result;
}
async getCampaignAnalytics(campaignId: number): Promise<CampaignAnalytics> {
const response = await apiClient.get(`/email-campaigns/${campaignId}/analytics`);
return response.data;
}
// Segments
async getSegments(): Promise<CampaignSegment[]> {
const response = await apiClient.get('/email-campaigns/segments');
return response.data;
}
async createSegment(data: {
name: string;
description?: string;
criteria: Record<string, any>;
}): Promise<{ segment_id: number; estimated_count: number }> {
const response = await apiClient.post('/email-campaigns/segments', data);
return response.data;
}
// Templates
async getTemplates(category?: string): Promise<EmailTemplate[]> {
const response = await apiClient.get('/email-campaigns/templates', {
params: { category }
});
return response.data;
}
async createTemplate(data: {
name: string;
subject: string;
html_content: string;
text_content?: string;
category?: string;
variables?: string[];
}): Promise<{ template_id: number }> {
const response = await apiClient.post('/email-campaigns/templates', data);
return response.data;
}
// Drip Sequences
async getDripSequences(): Promise<DripSequence[]> {
const response = await apiClient.get('/email-campaigns/drip-sequences');
return response.data;
}
async createDripSequence(data: {
name: string;
description?: string;
trigger_event?: string;
}): Promise<{ sequence_id: number }> {
const response = await apiClient.post('/email-campaigns/drip-sequences', data);
return response.data;
}
async addDripStep(sequenceId: number, data: {
subject: string;
html_content: string;
text_content?: string;
delay_days?: number;
delay_hours?: number;
template_id?: number;
}): Promise<{ step_id: number }> {
const response = await apiClient.post(`/email-campaigns/drip-sequences/${sequenceId}/steps`, data);
return response.data;
}
}
export const emailCampaignService = new EmailCampaignService();
export default emailCampaignService;

View File

@@ -0,0 +1,286 @@
import apiClient from './apiClient';
export interface RoomBlock {
room_type_id: number;
num_rooms: number;
rate_per_room: number;
}
export interface GroupBookingData {
coordinator_name?: string;
coordinator_email?: string;
coordinator_phone?: string;
check_in_date: string;
check_out_date: string;
room_blocks: RoomBlock[];
group_name?: string;
group_type?: string;
payment_option?: 'coordinator_pays_all' | 'individual_payments' | 'split_payment';
deposit_required?: boolean;
deposit_percentage?: number;
special_requests?: string;
notes?: string;
cancellation_policy?: string;
cancellation_deadline?: string;
cancellation_penalty_percentage?: number;
group_discount_percentage?: number;
}
export interface GroupBookingMemberData {
full_name: string;
email?: string;
phone?: string;
user_id?: number;
room_block_id?: number;
special_requests?: string;
preferences?: Record<string, any>;
}
export interface GroupPaymentData {
amount: number;
payment_method: string;
payment_type?: 'deposit' | 'full' | 'remaining';
transaction_id?: string;
paid_by_member_id?: number;
paid_by_user_id?: number;
notes?: string;
}
export interface GroupBooking {
id: number;
group_booking_number: string;
coordinator: {
id: number;
name: string;
email: string;
phone?: string;
};
group_name?: string;
group_type?: string;
total_rooms: number;
total_guests: number;
check_in_date: string;
check_out_date: string;
base_rate_per_room: number;
group_discount_percentage: number;
group_discount_amount: number;
original_total_price: number;
discount_amount: number;
total_price: number;
payment_option: 'coordinator_pays_all' | 'individual_payments' | 'split_payment';
deposit_required: boolean;
deposit_percentage?: number;
deposit_amount?: number;
amount_paid: number;
balance_due: number;
status: 'draft' | 'pending' | 'confirmed' | 'partially_confirmed' | 'checked_in' | 'checked_out' | 'cancelled';
special_requests?: string;
notes?: string;
cancellation_policy?: string;
cancellation_deadline?: string;
cancellation_penalty_percentage?: number;
created_at: string;
updated_at: string;
confirmed_at?: string;
cancelled_at?: string;
room_blocks?: RoomBlockDetail[];
members?: GroupBookingMember[];
payments?: GroupPayment[];
}
export interface RoomBlockDetail {
id: number;
room_type_id: number;
room_type?: {
id: number;
name: string;
base_price: number;
};
rooms_blocked: number;
rooms_confirmed: number;
rooms_available: number;
rate_per_room: number;
total_block_price: number;
is_active: boolean;
block_released_at?: string;
}
export interface GroupBookingMember {
id: number;
full_name: string;
email?: string;
phone?: string;
user_id?: number;
room_block_id?: number;
assigned_room_id?: number;
individual_booking_id?: number;
special_requests?: string;
preferences?: Record<string, any>;
individual_amount?: number;
individual_paid: number;
individual_balance: number;
is_checked_in: boolean;
checked_in_at?: string;
is_checked_out: boolean;
checked_out_at?: string;
}
export interface GroupPayment {
id: number;
amount: number;
payment_method: string;
payment_type: string;
payment_status: string;
transaction_id?: string;
payment_date?: string;
notes?: string;
paid_by_member_id?: number;
paid_by_user_id?: number;
created_at: string;
}
export interface GroupBookingListResponse {
status: string;
data: {
group_bookings: GroupBooking[];
pagination?: {
total: number;
page: number;
limit: number;
totalPages: number;
};
};
}
export interface GroupBookingResponse {
status: string;
message?: string;
data: {
group_booking: GroupBooking;
};
}
export interface GroupBookingMemberResponse {
status: string;
message?: string;
data: {
member: GroupBookingMember;
};
}
export interface GroupPaymentResponse {
status: string;
message?: string;
data: {
payment: GroupPayment;
};
}
const groupBookingService = {
// Create a new group booking
async createGroupBooking(data: GroupBookingData): Promise<GroupBookingResponse> {
const response = await apiClient.post('/group-bookings', data);
return response.data;
},
// Get all group bookings (admin/staff)
async getGroupBookings(params?: {
search?: string;
status?: string;
page?: number;
limit?: number;
}): Promise<GroupBookingListResponse> {
const response = await apiClient.get('/group-bookings', { params });
return response.data;
},
// Get current user's group bookings
async getMyGroupBookings(): Promise<GroupBookingListResponse> {
const response = await apiClient.get('/group-bookings/me');
return response.data;
},
// Get a specific group booking
async getGroupBooking(groupBookingId: number): Promise<GroupBookingResponse> {
const response = await apiClient.get(`/group-bookings/${groupBookingId}`);
return response.data;
},
// Add a member to a group booking
async addMember(
groupBookingId: number,
memberData: GroupBookingMemberData
): Promise<GroupBookingMemberResponse> {
const response = await apiClient.post(
`/group-bookings/${groupBookingId}/members`,
memberData
);
return response.data;
},
// Confirm a group booking
async confirmGroupBooking(groupBookingId: number): Promise<GroupBookingResponse> {
const response = await apiClient.post(`/group-bookings/${groupBookingId}/confirm`);
return response.data;
},
// Assign a room to a member and create individual booking
async assignRoomToMember(
groupBookingId: number,
memberId: number,
roomId: number
): Promise<{ status: string; message?: string; data: { booking: any } }> {
const response = await apiClient.post(
`/group-bookings/${groupBookingId}/members/${memberId}/assign-room`,
{ room_id: roomId }
);
return response.data;
},
// Add a payment to a group booking
async addPayment(
groupBookingId: number,
paymentData: GroupPaymentData
): Promise<GroupPaymentResponse> {
const response = await apiClient.post(
`/group-bookings/${groupBookingId}/payments`,
paymentData
);
return response.data;
},
// Cancel a group booking
async cancelGroupBooking(
groupBookingId: number,
reason?: string
): Promise<GroupBookingResponse> {
const response = await apiClient.post(`/group-bookings/${groupBookingId}/cancel`, {
reason,
});
return response.data;
},
// Check availability for a group booking
async checkAvailability(groupBookingId: number): Promise<{
status: string;
data: {
availability: Array<{
room_block_id: number;
room_type_id: number;
rooms_blocked: number;
availability: {
available: boolean;
available_count: number;
required_count: number;
message?: string;
};
}>;
};
}> {
const response = await apiClient.get(`/group-bookings/${groupBookingId}/availability`);
return response.data;
},
};
export default groupBookingService;

View File

@@ -17,6 +17,9 @@ export type * from './favoriteService';
export { default as bookingService } from './bookingService';
export type * from './bookingService';
export { default as groupBookingService } from './groupBookingService';
export type * from './groupBookingService';
export { default as paymentService } from './paymentService';
export type { Payment as PaymentServicePayment, PaymentData, PaymentResponse, BankInfo } from './paymentService';
@@ -40,7 +43,13 @@ export { default as chatService } from './chatService';
export { default as contactService } from './contactService';
export { default as loyaltyService } from './loyaltyService';
export { default as guestProfileService } from './guestProfileService';
export { default as advancedRoomService } from './advancedRoomService';
export { default as ratePlanService } from './ratePlanService';
export { default as packageService } from './packageService';
export { default as securityService } from './securityService';
export { default as emailCampaignService } from './emailCampaignService';
export type { CustomerDashboardStats, CustomerDashboardResponse } from './dashboardService';
export type * from './emailCampaignService';
export type * from './reportService';
export type * from './auditService';
export type * from './pageContentService';
@@ -48,3 +57,6 @@ export type * from './chatService';
export type * from './contactService';
export type * from './loyaltyService';
export type * from './guestProfileService';
export type * from './advancedRoomService';
export type * from './ratePlanService';
export type * from './packageService';

View File

@@ -101,38 +101,79 @@ export const getInvoices = async (params?: {
page?: number;
limit?: number;
}): Promise<InvoiceResponse> => {
const response = await apiClient.get<InvoiceResponse>('/invoices', { params });
return response.data;
const response = await apiClient.get<any>('/invoices', { params });
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
status: data.status === 'success' || data.success === true ? 'success' : 'error',
data: data.data || {},
message: data.message,
};
};
export const getInvoiceById = async (id: number): Promise<InvoiceResponse> => {
const response = await apiClient.get<InvoiceResponse>(`/invoices/${id}`);
return response.data;
const response = await apiClient.get<any>(`/invoices/${id}`);
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
status: data.status === 'success' || data.success === true ? 'success' : 'error',
data: data.data || {},
message: data.message,
};
};
export const getInvoicesByBooking = async (bookingId: number): Promise<InvoiceResponse> => {
const response = await apiClient.get<InvoiceResponse>(`/invoices/booking/${bookingId}`);
return response.data;
const response = await apiClient.get<any>(`/invoices/booking/${bookingId}`);
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
status: data.status === 'success' || data.success === true ? 'success' : 'error',
data: data.data || {},
message: data.message,
};
};
export const createInvoice = async (data: CreateInvoiceData): Promise<InvoiceResponse> => {
const response = await apiClient.post<InvoiceResponse>('/invoices', data);
return response.data;
const response = await apiClient.post<any>('/invoices', data);
const responseData = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
status: responseData.status === 'success' || responseData.success === true ? 'success' : 'error',
data: responseData.data || {},
message: responseData.message,
};
};
export const updateInvoice = async (id: number, data: UpdateInvoiceData): Promise<InvoiceResponse> => {
const response = await apiClient.put<InvoiceResponse>(`/invoices/${id}`, data);
return response.data;
const response = await apiClient.put<any>(`/invoices/${id}`, data);
const responseData = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
status: responseData.status === 'success' || responseData.success === true ? 'success' : 'error',
data: responseData.data || {},
message: responseData.message,
};
};
export const markInvoiceAsPaid = async (id: number, amount?: number): Promise<InvoiceResponse> => {
const response = await apiClient.post<InvoiceResponse>(`/invoices/${id}/mark-paid`, { amount });
return response.data;
const response = await apiClient.post<any>(`/invoices/${id}/mark-paid`, { amount });
const responseData = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
status: responseData.status === 'success' || responseData.success === true ? 'success' : 'error',
data: responseData.data || {},
message: responseData.message,
};
};
export const deleteInvoice = async (id: number): Promise<{ status: string; message: string }> => {
const response = await apiClient.delete<{ status: string; message: string }>(`/invoices/${id}`);
return response.data;
const response = await apiClient.delete<any>(`/invoices/${id}`);
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
status: data.status === 'success' || data.success === true ? 'success' : 'error',
message: data.message || 'Invoice deleted successfully',
};
};
const invoiceService = {

View File

@@ -0,0 +1,125 @@
import apiClient from './apiClient';
export interface Notification {
id: number;
user_id?: number;
notification_type: 'booking_confirmation' | 'payment_receipt' | 'pre_arrival_reminder' | 'check_in_reminder' | 'check_out_reminder' | 'marketing_campaign' | 'loyalty_update' | 'system_alert' | 'custom';
channel: 'email' | 'sms' | 'push' | 'whatsapp' | 'in_app';
subject?: string;
content: string;
status: 'pending' | 'sent' | 'delivered' | 'failed' | 'read';
priority: string;
sent_at?: string;
delivered_at?: string;
read_at?: string;
created_at: string;
}
export interface NotificationTemplate {
id: number;
name: string;
notification_type: string;
channel: string;
subject?: string;
content: string;
variables?: string[];
}
export interface NotificationPreferences {
email_enabled: boolean;
sms_enabled: boolean;
push_enabled: boolean;
whatsapp_enabled: boolean;
in_app_enabled: boolean;
booking_confirmation_email: boolean;
booking_confirmation_sms: boolean;
payment_receipt_email: boolean;
payment_receipt_sms: boolean;
pre_arrival_reminder_email: boolean;
pre_arrival_reminder_sms: boolean;
check_in_reminder_email: boolean;
check_in_reminder_sms: boolean;
check_out_reminder_email: boolean;
check_out_reminder_sms: boolean;
marketing_campaign_email: boolean;
marketing_campaign_sms: boolean;
loyalty_update_email: boolean;
loyalty_update_sms: boolean;
system_alert_email: boolean;
system_alert_push: boolean;
}
export interface SendNotificationRequest {
user_id?: number;
notification_type: string;
channel: string;
content: string;
subject?: string;
template_id?: number;
priority?: string;
scheduled_at?: string;
booking_id?: number;
payment_id?: number;
meta_data?: Record<string, any>;
}
const notificationService = {
// Notifications
getNotifications: async (params?: {
user_id?: number;
notification_type?: string;
channel?: string;
status?: string;
skip?: number;
limit?: number;
}) => {
return apiClient.get<{ status: string; data: Notification[] }>('/notifications/', { params });
},
getMyNotifications: async (params?: {
status?: string;
skip?: number;
limit?: number;
}) => {
return apiClient.get<{ status: string; data: Notification[] }>('/notifications/my-notifications', { params });
},
sendNotification: async (data: SendNotificationRequest) => {
return apiClient.post<{ status: string; data: Notification }>('/notifications/send', data);
},
markAsRead: async (notificationId: number) => {
return apiClient.post<{ status: string; data: Notification }>(`/notifications/${notificationId}/read`);
},
// Templates
getTemplates: async (params?: {
notification_type?: string;
channel?: string;
}) => {
return apiClient.get<{ status: string; data: NotificationTemplate[] }>('/notifications/templates', { params });
},
createTemplate: async (data: {
name: string;
notification_type: string;
channel: string;
content: string;
subject?: string;
variables?: string[];
}) => {
return apiClient.post<{ status: string; data: NotificationTemplate }>('/notifications/templates', data);
},
// Preferences
getPreferences: async () => {
return apiClient.get<{ status: string; data: NotificationPreferences }>('/notifications/preferences');
},
updatePreferences: async (data: Partial<NotificationPreferences>) => {
return apiClient.put<{ status: string; data: NotificationPreferences }>('/notifications/preferences', data);
},
};
export default notificationService;

View File

@@ -0,0 +1,190 @@
import apiClient from './apiClient';
export type PackageStatus = 'active' | 'inactive' | 'scheduled' | 'expired';
export type PackageItemType = 'room' | 'service' | 'breakfast' | 'activity' | 'amenity' | 'discount';
export interface PackageItem {
id?: number;
item_type: PackageItemType;
item_id?: number;
item_name: string;
item_description?: string;
quantity: number;
unit?: string;
price?: number;
included: boolean;
price_modifier?: number;
display_order: number;
extra_data?: any;
}
export interface Package {
id: number;
name: string;
code: string;
description?: string;
status: PackageStatus;
base_price?: number;
price_modifier: number;
discount_percentage?: number;
room_type_id?: number;
room_type_name?: string;
min_nights?: number;
max_nights?: number;
valid_from?: string;
valid_to?: string;
image_url?: string;
highlights?: string[];
terms_conditions?: string;
extra_data?: any;
items?: PackageItem[];
created_at?: string;
updated_at?: string;
}
export interface PackageListResponse {
status: string;
data: {
packages: Package[];
pagination?: {
total: number;
page: number;
limit: number;
totalPages: number;
};
};
}
export interface CreatePackageData {
name: string;
code: string;
description?: string;
status?: PackageStatus;
base_price?: number;
price_modifier?: number;
discount_percentage?: number;
room_type_id?: number;
min_nights?: number;
max_nights?: number;
valid_from?: string;
valid_to?: string;
image_url?: string;
highlights?: string[];
terms_conditions?: string;
extra_data?: any;
items?: PackageItem[];
}
export interface UpdatePackageData {
name?: string;
description?: string;
status?: PackageStatus;
base_price?: number;
price_modifier?: number;
discount_percentage?: number;
room_type_id?: number;
min_nights?: number;
max_nights?: number;
valid_from?: string;
valid_to?: string;
image_url?: string;
highlights?: string[];
terms_conditions?: string;
extra_data?: any;
}
export interface GetAvailablePackagesParams {
room_type_id: number;
check_in: string;
check_out: string;
num_nights?: number;
}
class PackageService {
async getPackages(params?: {
search?: string;
status?: string;
room_type_id?: number;
page?: number;
limit?: number;
}): Promise<PackageListResponse> {
const response = await apiClient.get('/packages', { params });
return response.data;
}
async getPackage(id: number): Promise<{ status: string; data: { package: Package } }> {
const response = await apiClient.get(`/packages/${id}`);
return response.data;
}
async createPackage(data: CreatePackageData): Promise<{ status: string; message: string; data: { package_id: number } }> {
const response = await apiClient.post('/packages', data);
return response.data;
}
async updatePackage(id: number, data: UpdatePackageData): Promise<{ status: string; message: string }> {
const response = await apiClient.put(`/packages/${id}`, data);
return response.data;
}
async deletePackage(id: number): Promise<{ status: string; message: string }> {
const response = await apiClient.delete(`/packages/${id}`);
return response.data;
}
async getAvailablePackages(params: GetAvailablePackagesParams): Promise<{ status: string; data: { packages: Package[] } }> {
const response = await apiClient.get(`/packages/available/${params.room_type_id}`, {
params: {
check_in: params.check_in,
check_out: params.check_out,
num_nights: params.num_nights,
},
});
return response.data;
}
/**
* Calculate the total price for a package
*/
calculatePackagePrice(
baseRoomPrice: number,
numNights: number,
packageData: Package
): number {
let totalPrice = 0;
// If package has a fixed base price, use it
if (packageData.base_price) {
totalPrice = packageData.base_price * numNights;
} else {
// Calculate from room price and items
totalPrice = baseRoomPrice * numNights;
// Add item prices if not included
if (packageData.items) {
for (const item of packageData.items) {
if (!item.included && item.price) {
const itemPrice = item.price * item.quantity;
totalPrice += itemPrice;
}
}
}
}
// Apply price modifier
if (packageData.price_modifier) {
totalPrice = totalPrice * packageData.price_modifier;
}
// Apply discount
if (packageData.discount_percentage) {
totalPrice = totalPrice * (1 - packageData.discount_percentage / 100);
}
return Math.max(0, totalPrice);
}
}
const packageService = new PackageService();
export default packageService;

View File

@@ -3,7 +3,7 @@ import apiClient from './apiClient';
export interface PaymentData {
booking_id: number;
amount: number;
payment_method: 'cash' | 'bank_transfer' | 'stripe' | 'paypal';
payment_method: 'cash' | 'bank_transfer' | 'stripe' | 'paypal' | 'borica';
transaction_id?: string;
notes?: string;
}
@@ -12,7 +12,7 @@ export interface Payment {
id: number;
booking_id: number;
amount: number;
payment_method: 'cash' | 'bank_transfer' | 'credit_card' | 'debit_card' | 'e_wallet' | 'stripe' | 'paypal';
payment_method: 'cash' | 'bank_transfer' | 'credit_card' | 'debit_card' | 'e_wallet' | 'stripe' | 'paypal' | 'borica';
payment_type: 'full' | 'deposit' | 'remaining';
deposit_percentage?: number;
payment_status: 'pending' | 'completed' | 'failed' | 'refunded';
@@ -331,6 +331,77 @@ export const cancelPayPalPayment = async (
};
};
export const createBoricaPayment = async (
bookingId: number,
amount: number,
currency: string = 'BGN',
returnUrl?: string
): Promise<{
success: boolean;
data: {
payment_request: {
terminal_id: string;
merchant_id: string;
order_id: string;
amount: string;
currency: string;
description: string;
timestamp: string;
signature: string;
gateway_url: string;
return_url: string;
trtype: string;
};
payment_id: number;
transaction_id: string;
};
message?: string;
}> => {
const response = await apiClient.post(
'/payments/borica/create-payment',
{
booking_id: bookingId,
amount,
currency,
return_url: returnUrl,
}
);
const data = response.data;
return {
success: data.status === "success" || data.success === true,
data: data.data || {},
message: data.message,
};
};
export const confirmBoricaPayment = async (
responseData: Record<string, string>
): Promise<{
success: boolean;
data: {
payment: Payment;
booking: {
id: number;
booking_number: string;
status: string;
};
};
message?: string;
}> => {
const response = await apiClient.post(
'/payments/borica/confirm',
responseData
);
const data = response.data;
return {
success: data.status === "success" || data.success === true,
data: data.data || {},
message: data.message,
};
};
export default {
createPayment,
getPayments,
@@ -345,4 +416,6 @@ export default {
createPayPalOrder,
capturePayPalPayment,
cancelPayPalPayment,
createBoricaPayment,
confirmBoricaPayment,
};

View File

@@ -0,0 +1,198 @@
import apiClient from './apiClient';
export type RatePlanType = 'BAR' | 'non_refundable' | 'advance_purchase' | 'corporate' | 'government' | 'military' | 'long_stay' | 'package';
export type RatePlanStatus = 'active' | 'inactive' | 'scheduled' | 'expired';
export interface RatePlanRule {
id?: number;
rule_type: string;
rule_key: string;
rule_value?: any;
price_modifier?: number;
discount_percentage?: number;
fixed_adjustment?: number;
priority: number;
}
export interface RatePlan {
id: number;
name: string;
code: string;
description?: string;
plan_type: RatePlanType;
status: RatePlanStatus;
base_price_modifier: number;
discount_percentage?: number;
fixed_discount?: number;
room_type_id?: number;
room_type_name?: string;
min_nights?: number;
max_nights?: number;
advance_days_required?: number;
valid_from?: string;
valid_to?: string;
is_refundable: boolean;
requires_deposit: boolean;
deposit_percentage?: number;
cancellation_hours?: number;
corporate_code?: string;
requires_verification: boolean;
verification_type?: string;
long_stay_nights?: number;
is_package: boolean;
package_id?: number;
priority: number;
extra_data?: any;
rules?: RatePlanRule[];
created_at?: string;
updated_at?: string;
}
export interface RatePlanListResponse {
status: string;
data: {
rate_plans: RatePlan[];
pagination?: {
total: number;
page: number;
limit: number;
totalPages: number;
};
};
}
export interface CreateRatePlanData {
name: string;
code: string;
description?: string;
plan_type: RatePlanType;
status?: RatePlanStatus;
base_price_modifier?: number;
discount_percentage?: number;
fixed_discount?: number;
room_type_id?: number;
min_nights?: number;
max_nights?: number;
advance_days_required?: number;
valid_from?: string;
valid_to?: string;
is_refundable?: boolean;
requires_deposit?: boolean;
deposit_percentage?: number;
cancellation_hours?: number;
corporate_code?: string;
requires_verification?: boolean;
verification_type?: string;
long_stay_nights?: number;
is_package?: boolean;
package_id?: number;
priority?: number;
extra_data?: any;
rules?: RatePlanRule[];
}
export interface UpdateRatePlanData {
name?: string;
description?: string;
status?: RatePlanStatus;
base_price_modifier?: number;
discount_percentage?: number;
fixed_discount?: number;
room_type_id?: number;
min_nights?: number;
max_nights?: number;
advance_days_required?: number;
valid_from?: string;
valid_to?: string;
is_refundable?: boolean;
requires_deposit?: boolean;
deposit_percentage?: number;
cancellation_hours?: number;
corporate_code?: string;
requires_verification?: boolean;
verification_type?: string;
long_stay_nights?: number;
package_id?: number;
priority?: number;
extra_data?: any;
}
export interface GetAvailableRatePlansParams {
room_type_id: number;
check_in: string;
check_out: string;
num_nights?: number;
}
class RatePlanService {
async getRatePlans(params?: {
search?: string;
status?: string;
plan_type?: string;
room_type_id?: number;
page?: number;
limit?: number;
}): Promise<RatePlanListResponse> {
const response = await apiClient.get('/rate-plans', { params });
return response.data;
}
async getRatePlan(id: number): Promise<{ status: string; data: { rate_plan: RatePlan } }> {
const response = await apiClient.get(`/rate-plans/${id}`);
return response.data;
}
async createRatePlan(data: CreateRatePlanData): Promise<{ status: string; message: string; data: { rate_plan_id: number } }> {
const response = await apiClient.post('/rate-plans', data);
return response.data;
}
async updateRatePlan(id: number, data: UpdateRatePlanData): Promise<{ status: string; message: string }> {
const response = await apiClient.put(`/rate-plans/${id}`, data);
return response.data;
}
async deleteRatePlan(id: number): Promise<{ status: string; message: string }> {
const response = await apiClient.delete(`/rate-plans/${id}`);
return response.data;
}
async getAvailableRatePlans(params: GetAvailableRatePlansParams): Promise<{ status: string; data: { rate_plans: RatePlan[] } }> {
const response = await apiClient.get(`/rate-plans/available/${params.room_type_id}`, {
params: {
check_in: params.check_in,
check_out: params.check_out,
num_nights: params.num_nights,
},
});
return response.data;
}
/**
* Calculate the final price for a rate plan
*/
calculatePrice(basePrice: number, ratePlan: RatePlan): number {
let price = basePrice;
// Apply base price modifier
if (ratePlan.base_price_modifier) {
price = price * ratePlan.base_price_modifier;
}
// Apply percentage discount
if (ratePlan.discount_percentage) {
price = price * (1 - ratePlan.discount_percentage / 100);
}
// Apply fixed discount
if (ratePlan.fixed_discount) {
price = price - ratePlan.fixed_discount;
}
return Math.max(0, price); // Ensure price is not negative
}
}
const ratePlanService = new RatePlanService();
export default ratePlanService;

View File

@@ -48,13 +48,27 @@ export interface ReportParams {
export const getReports = async (
params: ReportParams = {}
): Promise<ReportResponse> => {
const response = await apiClient.get('/reports', { params });
return response.data;
const response = await apiClient.get<any>('/reports', { params });
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: data.status === 'success' || data.success === true,
status: data.status,
data: data.data || {},
message: data.message,
};
};
export const getDashboardStats = async (): Promise<ReportResponse> => {
const response = await apiClient.get('/reports/dashboard');
return response.data;
const response = await apiClient.get<any>('/reports/dashboard');
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: data.status === 'success' || data.success === true,
status: data.status,
data: data.data || {},
message: data.message,
};
};
export const exportReport = async (

View File

@@ -0,0 +1,249 @@
import apiClient from './apiClient';
export interface SecurityEvent {
id: number;
user_id?: number;
event_type: string;
severity: string;
ip_address?: string;
user_agent?: string;
request_path?: string;
request_method?: string;
description?: string;
details?: any;
resolved: boolean;
resolved_at?: string;
resolved_by?: number;
resolution_notes?: string;
created_at: string;
}
export interface SecurityStats {
total_events: number;
by_type: Record<string, number>;
by_severity: Record<string, number>;
unresolved_critical: number;
period_days: number;
}
export interface IPWhitelist {
id: number;
ip_address: string;
description?: string;
is_active: boolean;
created_at: string;
}
export interface IPBlacklist {
id: number;
ip_address: string;
reason?: string;
is_active: boolean;
blocked_until?: string;
created_at: string;
}
export interface OAuthProvider {
id: number;
name: string;
display_name: string;
is_active: boolean;
is_sso_enabled: boolean;
created_at: string;
}
export interface DataSubjectRequest {
id: number;
user_id?: number;
email: string;
request_type: string;
status: string;
description?: string;
verified: boolean;
verified_at?: string;
assigned_to?: number;
completed_at?: string;
created_at: string;
}
class SecurityService {
// Security Events
async getSecurityEvents(params?: {
user_id?: number;
event_type?: string;
severity?: string;
ip_address?: string;
resolved?: boolean;
days?: number;
limit?: number;
offset?: number;
}): Promise<SecurityEvent[]> {
const response = await apiClient.get('/security/events', { params });
return response.data;
}
async getSecurityStats(days: number = 7): Promise<SecurityStats> {
const response = await apiClient.get('/security/events/stats', {
params: { days }
});
return response.data;
}
async resolveSecurityEvent(eventId: number, resolutionNotes?: string): Promise<void> {
await apiClient.post(`/security/events/${eventId}/resolve`, {
resolution_notes: resolutionNotes
});
}
// IP Whitelist
async getWhitelistedIPs(): Promise<IPWhitelist[]> {
const response = await apiClient.get('/security/ip/whitelist');
return response.data;
}
async addIPToWhitelist(ipAddress: string, description?: string): Promise<void> {
await apiClient.post('/security/ip/whitelist', {
ip_address: ipAddress,
description
});
}
async removeIPFromWhitelist(ipAddress: string): Promise<void> {
await apiClient.delete(`/security/ip/whitelist/${ipAddress}`);
}
// IP Blacklist
async getBlacklistedIPs(): Promise<IPBlacklist[]> {
const response = await apiClient.get('/security/ip/blacklist');
return response.data;
}
async addIPToBlacklist(ipAddress: string, reason?: string, blockedUntil?: string): Promise<void> {
await apiClient.post('/security/ip/blacklist', {
ip_address: ipAddress,
reason,
blocked_until: blockedUntil
});
}
async removeIPFromBlacklist(ipAddress: string): Promise<void> {
await apiClient.delete(`/security/ip/blacklist/${ipAddress}`);
}
// OAuth Providers
async getOAuthProviders(): Promise<OAuthProvider[]> {
const response = await apiClient.get('/security/oauth/providers');
return response.data;
}
async createOAuthProvider(data: {
name: string;
display_name: string;
client_id: string;
client_secret: string;
authorization_url: string;
token_url: string;
userinfo_url: string;
scopes?: string;
is_active?: boolean;
is_sso_enabled?: boolean;
}): Promise<OAuthProvider> {
const response = await apiClient.post('/security/oauth/providers', data);
return response.data;
}
async updateOAuthProvider(providerId: number, data: Partial<{
display_name: string;
client_id: string;
client_secret: string;
authorization_url: string;
token_url: string;
userinfo_url: string;
scopes: string;
is_active: boolean;
is_sso_enabled: boolean;
}>): Promise<OAuthProvider> {
const response = await apiClient.put(`/security/oauth/providers/${providerId}`, data);
return response.data;
}
async deleteOAuthProvider(providerId: number): Promise<void> {
await apiClient.delete(`/security/oauth/providers/${providerId}`);
}
async getOAuthAuthorizationUrl(providerName: string, redirectUri: string, state?: string): Promise<string> {
const response = await apiClient.get(`/security/oauth/${providerName}/authorize`, {
params: { redirect_uri: redirectUri, state }
});
return response.data.authorization_url;
}
// GDPR Requests
async getGDPRRequests(params?: {
status?: string;
request_type?: string;
limit?: number;
offset?: number;
}): Promise<DataSubjectRequest[]> {
const response = await apiClient.get('/security/gdpr/requests', { params });
return response.data;
}
async getGDPRRequest(requestId: number): Promise<DataSubjectRequest> {
const response = await apiClient.get(`/security/gdpr/requests/${requestId}`);
return response.data;
}
async assignGDPRRequest(requestId: number): Promise<void> {
await apiClient.post(`/security/gdpr/requests/${requestId}/assign`);
}
async completeGDPRRequest(requestId: number, notes?: string): Promise<void> {
await apiClient.post(`/security/gdpr/requests/${requestId}/complete`, { notes });
}
// GDPR
async createDataSubjectRequest(email: string, requestType: string, description?: string): Promise<{ verification_token: string }> {
const response = await apiClient.post('/security/gdpr/request', {
email,
request_type: requestType,
description
});
return response.data;
}
async verifyDataSubjectRequest(verificationToken: string): Promise<void> {
await apiClient.post(`/security/gdpr/verify/${verificationToken}`);
}
async getUserData(userId: number): Promise<any> {
const response = await apiClient.get(`/security/gdpr/data/${userId}`);
return response.data.data;
}
async exportUserData(userId: number): Promise<any> {
const response = await apiClient.get(`/security/gdpr/export/${userId}`);
return response.data.data;
}
async deleteUserData(userId: number): Promise<void> {
await apiClient.delete(`/security/gdpr/data/${userId}`);
}
// Security Scanning
async runSecurityScan(): Promise<any> {
const response = await apiClient.post('/security/scan/run', {});
return response.data.results;
}
async scheduleSecurityScan(intervalHours: number = 24): Promise<any> {
const response = await apiClient.post('/security/scan/schedule', null, {
params: { interval_hours: intervalHours }
});
return response.data.schedule;
}
}
export const securityService = new SecurityService();
export default securityService;

View File

@@ -54,6 +54,35 @@ export interface UpdatePayPalSettingsRequest {
paypal_mode?: string;
}
export interface BoricaSettingsResponse {
status: string;
data: {
borica_terminal_id: string;
borica_merchant_id: string;
borica_private_key_path: string;
borica_certificate_path: string;
borica_gateway_url: string;
borica_mode: string;
borica_terminal_id_masked: string;
borica_merchant_id_masked: string;
has_terminal_id: boolean;
has_merchant_id: boolean;
has_private_key_path: boolean;
has_certificate_path: boolean;
updated_at?: string | null;
updated_by?: string | null;
};
}
export interface UpdateBoricaSettingsRequest {
borica_terminal_id?: string;
borica_merchant_id?: string;
borica_private_key_path?: string;
borica_certificate_path?: string;
borica_gateway_url?: string;
borica_mode?: string;
}
export interface SmtpSettingsResponse {
status: string;
data: {
@@ -145,6 +174,16 @@ export interface UploadFaviconResponse {
};
}
export interface UploadBoricaCertificateResponse {
status: string;
message: string;
data: {
file_path: string;
file_type: string;
filename: string;
};
}
export interface RecaptchaSettingsResponse {
status: string;
data: {
@@ -246,6 +285,39 @@ const systemSettingsService = {
return response.data;
},
getBoricaSettings: async (): Promise<BoricaSettingsResponse> => {
const response = await apiClient.get<BoricaSettingsResponse>(
'/api/admin/system-settings/borica'
);
return response.data;
},
updateBoricaSettings: async (
settings: UpdateBoricaSettingsRequest
): Promise<BoricaSettingsResponse> => {
const response = await apiClient.put<BoricaSettingsResponse>(
'/api/admin/system-settings/borica',
settings
);
return response.data;
},
uploadBoricaCertificate: async (
file: File,
fileType: 'private_key' | 'certificate' = 'private_key'
): Promise<UploadBoricaCertificateResponse> => {
const formData = new FormData();
formData.append('file', file);
formData.append('file_type', fileType);
const response = await apiClient.post<UploadBoricaCertificateResponse>(
'/api/admin/system-settings/borica/upload-certificate',
formData
);
return response.data;
},
getSmtpSettings: async (): Promise<SmtpSettingsResponse> => {
const response = await apiClient.get<SmtpSettingsResponse>(

View File

@@ -0,0 +1,141 @@
import apiClient from './apiClient';
export interface Task {
id: number;
title: string;
description?: string;
task_type: string;
status: 'pending' | 'assigned' | 'in_progress' | 'completed' | 'cancelled' | 'overdue';
priority: 'low' | 'medium' | 'high' | 'urgent';
workflow_instance_id?: number;
booking_id?: number;
room_id?: number;
assigned_to?: number;
assigned_to_name?: string;
created_by: number;
created_by_name?: string;
due_date?: string;
completed_at?: string;
estimated_duration_minutes?: number;
actual_duration_minutes?: number;
notes?: string;
metadata?: Record<string, any>;
comments?: TaskComment[];
created_at: string;
updated_at: string;
}
export interface TaskComment {
id: number;
task_id: number;
user_id: number;
user_name?: string;
comment: string;
created_at: string;
}
export interface CreateTaskRequest {
title: string;
description?: string;
task_type?: string;
priority?: string;
workflow_instance_id?: number;
booking_id?: number;
room_id?: number;
assigned_to?: number;
due_date?: string;
estimated_duration_minutes?: number;
metadata?: Record<string, any>;
}
export interface UpdateTaskRequest {
title?: string;
description?: string;
status?: string;
priority?: string;
assigned_to?: number;
due_date?: string;
notes?: string;
actual_duration_minutes?: number;
}
export interface TaskStatistics {
total: number;
pending: number;
assigned: number;
in_progress: number;
completed: number;
overdue: number;
cancelled: number;
completion_rate: number;
average_completion_time_minutes?: number;
}
const taskService = {
// Task CRUD
createTask: async (data: CreateTaskRequest) => {
return apiClient.post<{ status: string; data: Task }>('/tasks/', data);
},
getTasks: async (params?: {
assigned_to?: number;
created_by?: number;
status?: string;
priority?: string;
task_type?: string;
booking_id?: number;
room_id?: number;
workflow_instance_id?: number;
overdue_only?: boolean;
skip?: number;
limit?: number;
}) => {
return apiClient.get<{ status: string; data: Task[] }>('/tasks/', { params });
},
getMyTasks: async (status?: string) => {
return apiClient.get<{ status: string; data: Task[] }>('/tasks/my-tasks', { params: { status } });
},
getTask: async (id: number) => {
return apiClient.get<{ status: string; data: Task }>(`/tasks/${id}`);
},
updateTask: async (id: number, data: UpdateTaskRequest) => {
return apiClient.put<{ status: string; data: Task }>(`/tasks/${id}`, data);
},
// Task Actions
assignTask: async (taskId: number, userId: number) => {
return apiClient.post<{ status: string; data: Task }>(`/tasks/${taskId}/assign`, { user_id: userId });
},
startTask: async (taskId: number) => {
return apiClient.post<{ status: string; data: Task }>(`/tasks/${taskId}/start`);
},
completeTask: async (taskId: number, notes?: string) => {
return apiClient.post<{ status: string; data: Task }>(`/tasks/${taskId}/complete`, { notes });
},
cancelTask: async (taskId: number, reason?: string) => {
return apiClient.post<{ status: string; data: Task }>(`/tasks/${taskId}/cancel`, { reason });
},
// Task Comments
addTaskComment: async (taskId: number, comment: string) => {
return apiClient.post<{ status: string; data: TaskComment }>(`/tasks/${taskId}/comments`, { comment });
},
// Statistics
getTaskStatistics: async (params?: {
assigned_to?: number;
start_date?: string;
end_date?: string;
}) => {
return apiClient.get<{ status: string; data: TaskStatistics }>('/tasks/statistics/', { params });
},
};
export default taskService;

View File

@@ -56,37 +56,66 @@ export interface UserSearchParams {
export const getUsers = async (
params: UserSearchParams = {}
): Promise<UserListResponse> => {
const response = await apiClient.get('/users', { params });
return response.data;
const response = await apiClient.get<any>('/users', { params });
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: data.status === 'success' || data.success === true,
status: data.status,
data: data.data || { users: [] },
message: data.message,
};
};
export const getUserById = async (
id: number
): Promise<{ success: boolean; data: { user: User } }> => {
const response = await apiClient.get(`/users/${id}`);
return response.data;
const response = await apiClient.get<any>(`/users/${id}`);
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: data.status === 'success' || data.success === true,
data: data.data || {},
};
};
export const createUser = async (
data: CreateUserData
): Promise<{ success: boolean; data: { user: User }; message: string }> => {
const response = await apiClient.post('/users', data);
return response.data;
const response = await apiClient.post<any>('/users', data);
const responseData = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: responseData.status === 'success' || responseData.success === true,
data: responseData.data || {},
message: responseData.message || 'User created successfully',
};
};
export const updateUser = async (
id: number,
data: UpdateUserData
): Promise<{ success: boolean; data: { user: User }; message: string }> => {
const response = await apiClient.put(`/users/${id}`, data);
return response.data;
const response = await apiClient.put<any>(`/users/${id}`, data);
const responseData = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: responseData.status === 'success' || responseData.success === true,
data: responseData.data || {},
message: responseData.message || 'User updated successfully',
};
};
export const deleteUser = async (
id: number
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(`/users/${id}`);
return response.data;
const response = await apiClient.delete<any>(`/users/${id}`);
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
success: data.status === 'success' || data.success === true,
message: data.message || 'User deleted successfully',
};
};
export default {

View File

@@ -0,0 +1,123 @@
import apiClient from './apiClient';
export interface WorkflowStep {
title: string;
description?: string;
task_type: string;
priority?: 'low' | 'medium' | 'high' | 'urgent';
assigned_to?: number;
estimated_duration_minutes?: number;
due_date_offset_hours?: number;
metadata?: Record<string, any>;
}
export interface Workflow {
id: number;
name: string;
description?: string;
workflow_type: 'pre_arrival' | 'room_preparation' | 'maintenance' | 'guest_communication' | 'follow_up' | 'custom';
trigger: 'booking_created' | 'booking_confirmed' | 'check_in' | 'check_out' | 'maintenance_request' | 'guest_message' | 'manual' | 'scheduled';
status: 'active' | 'inactive' | 'archived';
sla_hours?: number;
steps: WorkflowStep[];
trigger_config?: Record<string, any>;
created_at: string;
updated_at: string;
}
export interface WorkflowInstance {
id: number;
workflow_id: number;
booking_id?: number;
room_id?: number;
user_id?: number;
status: 'pending' | 'in_progress' | 'completed' | 'cancelled';
started_at: string;
completed_at?: string;
due_date?: string;
}
export interface CreateWorkflowRequest {
name: string;
description?: string;
workflow_type: string;
trigger: string;
steps: WorkflowStep[];
trigger_config?: Record<string, any>;
sla_hours?: number;
}
export interface UpdateWorkflowRequest {
name?: string;
description?: string;
steps?: WorkflowStep[];
status?: string;
trigger_config?: Record<string, any>;
sla_hours?: number;
}
export interface TriggerWorkflowRequest {
booking_id?: number;
room_id?: number;
user_id?: number;
metadata?: Record<string, any>;
}
const workflowService = {
// Workflow CRUD
createWorkflow: async (data: CreateWorkflowRequest) => {
return apiClient.post<{ status: string; data: Workflow }>('/workflows/', data);
},
getWorkflows: async (params?: {
workflow_type?: string;
status?: string;
skip?: number;
limit?: number;
}) => {
return apiClient.get<{ status: string; data: Workflow[] }>('/workflows/', { params });
},
getWorkflow: async (id: number) => {
return apiClient.get<{ status: string; data: Workflow }>(`/workflows/${id}`);
},
updateWorkflow: async (id: number, data: UpdateWorkflowRequest) => {
return apiClient.put<{ status: string; data: Workflow }>(`/workflows/${id}`, data);
},
deleteWorkflow: async (id: number) => {
return apiClient.delete<{ status: string; message: string }>(`/workflows/${id}`);
},
// Workflow Instances
triggerWorkflow: async (workflowId: number, data: TriggerWorkflowRequest) => {
return apiClient.post<{ status: string; data: WorkflowInstance }>(`/workflows/${workflowId}/trigger`, data);
},
getWorkflowInstances: async (params?: {
workflow_id?: number;
booking_id?: number;
status?: string;
skip?: number;
limit?: number;
}) => {
return apiClient.get<{ status: string; data: WorkflowInstance[] }>('/workflows/instances/', { params });
},
completeWorkflowInstance: async (instanceId: number) => {
return apiClient.post<{ status: string; data: WorkflowInstance }>(`/workflows/instances/${instanceId}/complete`);
},
// Predefined workflows
getPreArrivalWorkflows: async () => {
return apiClient.get<{ status: string; data: Workflow[] }>('/workflows/types/pre-arrival');
},
getRoomPreparationWorkflows: async () => {
return apiClient.get<{ status: string; data: Workflow[] }>('/workflows/types/room-preparation');
},
};
export default workflowService;

Some files were not shown because too many files have changed in this diff Show More