updates
This commit is contained in:
2931
Frontend/package-lock.json
generated
2931
Frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
377
Frontend/src/components/analytics/CustomReportBuilder.tsx
Normal file
377
Frontend/src/components/analytics/CustomReportBuilder.tsx
Normal 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;
|
||||
|
||||
224
Frontend/src/components/analytics/SimpleChart.tsx
Normal file
224
Frontend/src/components/analytics/SimpleChart.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }>();
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
87
Frontend/src/components/common/Preloader.tsx
Normal file
87
Frontend/src/components/common/Preloader.tsx
Normal 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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
|
||||
139
Frontend/src/components/notifications/InAppNotificationBell.tsx
Normal file
139
Frontend/src/components/notifications/InAppNotificationBell.tsx
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
241
Frontend/src/components/notifications/SendNotificationModal.tsx
Normal file
241
Frontend/src/components/notifications/SendNotificationModal.tsx
Normal 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;
|
||||
|
||||
227
Frontend/src/components/payments/BoricaPaymentModal.tsx
Normal file
227
Frontend/src/components/payments/BoricaPaymentModal.tsx
Normal 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;
|
||||
|
||||
632
Frontend/src/components/shared/CreateGroupBookingModal.tsx
Normal file
632
Frontend/src/components/shared/CreateGroupBookingModal.tsx
Normal 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;
|
||||
|
||||
729
Frontend/src/components/shared/HousekeepingManagement.tsx
Normal file
729
Frontend/src/components/shared/HousekeepingManagement.tsx
Normal 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;
|
||||
|
||||
768
Frontend/src/components/shared/InspectionManagement.tsx
Normal file
768
Frontend/src/components/shared/InspectionManagement.tsx
Normal 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;
|
||||
|
||||
704
Frontend/src/components/shared/MaintenanceManagement.tsx
Normal file
704
Frontend/src/components/shared/MaintenanceManagement.tsx
Normal 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;
|
||||
|
||||
12
Frontend/src/components/shared/index.ts
Normal file
12
Frontend/src/components/shared/index.ts
Normal 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';
|
||||
|
||||
187
Frontend/src/components/tasks/CreateTaskModal.tsx
Normal file
187
Frontend/src/components/tasks/CreateTaskModal.tsx
Normal 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;
|
||||
|
||||
255
Frontend/src/components/tasks/TaskDetailModal.tsx
Normal file
255
Frontend/src/components/tasks/TaskDetailModal.tsx
Normal 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;
|
||||
|
||||
95
Frontend/src/components/tasks/TaskFilters.tsx
Normal file
95
Frontend/src/components/tasks/TaskFilters.tsx
Normal 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;
|
||||
|
||||
308
Frontend/src/components/workflows/WorkflowBuilder.tsx
Normal file
308
Frontend/src/components/workflows/WorkflowBuilder.tsx
Normal 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;
|
||||
|
||||
97
Frontend/src/components/workflows/WorkflowDetailModal.tsx
Normal file
97
Frontend/src/components/workflows/WorkflowDetailModal.tsx
Normal 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;
|
||||
|
||||
56
Frontend/src/contexts/NavigationLoadingContext.tsx
Normal file
56
Frontend/src/contexts/NavigationLoadingContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
52
Frontend/src/contexts/ResponsiveContext.tsx
Normal file
52
Frontend/src/contexts/ResponsiveContext.tsx
Normal 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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
129
Frontend/src/hooks/useResponsive.ts
Normal file
129
Frontend/src/hooks/useResponsive.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { SidebarAccountant } from '../components/layout';
|
||||
import { useResponsive } from '../hooks';
|
||||
|
||||
const AccountantLayout: React.FC = () => {
|
||||
const { isMobile, isTablet, isDesktop } = useResponsive();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
{/* Sidebar */}
|
||||
@@ -10,7 +13,7 @@ const AccountantLayout: React.FC = () => {
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-auto lg:ml-0">
|
||||
<div className="min-h-screen pt-20 lg:pt-0">
|
||||
<div className={`min-h-screen ${isMobile ? 'pt-20' : 'pt-0'}`}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,140 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import React, { useState, Suspense, useEffect } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { SidebarAdmin } from '../components/layout';
|
||||
import { Sparkles, Zap } from 'lucide-react';
|
||||
import { useResponsive } from '../hooks';
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
// Luxury Loading Overlay
|
||||
const LuxuryLoadingOverlay: React.FC = () => {
|
||||
return (
|
||||
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
{}
|
||||
<SidebarAdmin />
|
||||
|
||||
{}
|
||||
<div className="flex-1 overflow-auto lg:ml-0">
|
||||
<div className="min-h-screen pt-20 lg:pt-0">
|
||||
<Outlet />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-slate-50/95 via-white/95 to-slate-50/95 backdrop-blur-sm z-50">
|
||||
<div className="text-center space-y-4 sm:space-y-6 px-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-amber-400 via-amber-500 to-amber-600 rounded-2xl sm:rounded-3xl blur-2xl opacity-60 animate-pulse"></div>
|
||||
<div className="relative bg-gradient-to-br from-amber-500 via-amber-600 to-amber-700 p-6 sm:p-8 rounded-2xl sm:rounded-3xl shadow-2xl border border-amber-400/30">
|
||||
<div className="flex items-center justify-center gap-3 sm:gap-4 mb-3 sm:mb-4">
|
||||
<Sparkles className="w-6 h-6 sm:w-8 sm:h-8 text-white animate-pulse" />
|
||||
<Zap className="w-6 h-6 sm:w-8 sm:h-8 text-white animate-pulse" style={{ animationDelay: '0.2s' }} />
|
||||
</div>
|
||||
<div className="w-12 sm:w-16 h-1 bg-white/30 rounded-full mx-auto overflow-hidden">
|
||||
<div className="h-full bg-white rounded-full animate-shimmer" style={{
|
||||
width: '60%',
|
||||
animation: 'shimmer 2s infinite'
|
||||
}}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-slate-600 font-medium text-base sm:text-lg tracking-wide">Loading Dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const { isMobile, isTablet, isDesktop } = useResponsive();
|
||||
const location = useLocation();
|
||||
|
||||
// Handle route transitions
|
||||
useEffect(() => {
|
||||
setIsTransitioning(true);
|
||||
const timer = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className={`${isMobile ? 'relative' : 'flex'} h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 relative overflow-hidden`}>
|
||||
{/* Luxury Background Pattern */}
|
||||
<div className="fixed inset-0 opacity-[0.02] pointer-events-none z-0">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 2px 2px, rgba(251, 191, 36, 0.3) 1px, transparent 0)`,
|
||||
backgroundSize: '60px 60px'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Animated Gradient Overlay */}
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-amber-50/20 via-transparent to-amber-100/10 pointer-events-none z-0"></div>
|
||||
|
||||
<SidebarAdmin
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
|
||||
<main
|
||||
className={`
|
||||
overflow-x-hidden overflow-y-auto transition-all duration-500 ease-in-out
|
||||
relative z-5
|
||||
${isMobile ? 'w-full h-full' : 'flex-1'}
|
||||
`}
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
{/* Luxury Content Container */}
|
||||
<div className="relative min-h-screen">
|
||||
{/* Top Spacing for Mobile Menu Button - Minimal */}
|
||||
<div className="h-12 sm:h-14 md:h-14 lg:h-0"></div>
|
||||
|
||||
{/* Content Wrapper with Luxury Styling */}
|
||||
<div
|
||||
className={`
|
||||
relative transition-all duration-500 ease-in-out
|
||||
${isTransitioning ? 'opacity-0 scale-[0.98]' : 'opacity-100 scale-100'}
|
||||
${isMobile ? 'px-2 py-2' : 'px-2 sm:px-3 md:px-4 lg:px-6 xl:px-8 py-3 sm:py-4 md:py-5 lg:py-8'}
|
||||
max-w-full
|
||||
`}
|
||||
>
|
||||
{/* Luxury Content Area */}
|
||||
<div className="relative max-w-full overflow-x-hidden">
|
||||
{/* Subtle Top Border Accent */}
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-amber-300/30 to-transparent"></div>
|
||||
|
||||
{/* Main Content with Luxury Padding */}
|
||||
<div className={`relative ${isMobile ? 'pt-1' : 'pt-3 sm:pt-4 md:pt-5 lg:pt-6'} max-w-full`}>
|
||||
<Suspense fallback={<LuxuryLoadingOverlay />}>
|
||||
<div className="relative z-10 max-w-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Custom CSS for shimmer animation */}
|
||||
<style>{`
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(200%);
|
||||
}
|
||||
}
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.6s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
|
||||
@@ -3,8 +3,11 @@ import { Outlet } from 'react-router-dom';
|
||||
import { SidebarStaff } from '../components/layout';
|
||||
import StaffChatNotification from '../components/chat/StaffChatNotification';
|
||||
import { ChatNotificationProvider } from '../contexts/ChatNotificationContext';
|
||||
import { useResponsive } from '../hooks';
|
||||
|
||||
const StaffLayout: React.FC = () => {
|
||||
const { isMobile, isTablet, isDesktop } = useResponsive();
|
||||
|
||||
return (
|
||||
<ChatNotificationProvider>
|
||||
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
@@ -13,7 +16,7 @@ const StaffLayout: React.FC = () => {
|
||||
|
||||
{}
|
||||
<div className="flex-1 overflow-auto lg:ml-0">
|
||||
<div className="min-h-screen pt-20 lg:pt-0">
|
||||
<div className={`min-h-screen ${isMobile ? 'pt-20' : 'pt-0'}`}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1803
Frontend/src/pages/accountant/AnalyticsDashboardPage.tsx
Normal file
1803
Frontend/src/pages/accountant/AnalyticsDashboardPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
353
Frontend/src/pages/accountant/InvoiceManagementPage.tsx
Normal file
353
Frontend/src/pages/accountant/InvoiceManagementPage.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Search, Plus, Edit, Trash2, Eye, FileText, Filter } from 'lucide-react';
|
||||
import { invoiceService, Invoice } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import { ExportButton } from '../../components/common';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { formatDate } from '../../utils/format';
|
||||
|
||||
const InvoiceManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const navigate = useNavigate();
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
status: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoices();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchInvoices = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await invoiceService.getInvoices({
|
||||
status: filters.status || undefined,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
|
||||
if (response.status === 'success' && response.data) {
|
||||
let invoiceList = response.data.invoices || [];
|
||||
|
||||
|
||||
if (filters.search) {
|
||||
invoiceList = invoiceList.filter((inv) =>
|
||||
inv.invoice_number.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||
inv.customer_name.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||
inv.customer_email.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||
(inv.promotion_code && inv.promotion_code.toLowerCase().includes(filters.search.toLowerCase()))
|
||||
);
|
||||
}
|
||||
|
||||
setInvoices(invoiceList);
|
||||
setTotalPages(response.data.total_pages || 1);
|
||||
setTotalItems(response.data.total || 0);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load invoices');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
draft: {
|
||||
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
|
||||
text: 'text-slate-700',
|
||||
label: 'Draft',
|
||||
border: 'border-slate-200'
|
||||
},
|
||||
sent: {
|
||||
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
|
||||
text: 'text-blue-800',
|
||||
label: 'Sent',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
paid: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: 'Paid',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
overdue: {
|
||||
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
|
||||
text: 'text-rose-800',
|
||||
label: 'Overdue',
|
||||
border: 'border-rose-200'
|
||||
},
|
||||
cancelled: {
|
||||
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
|
||||
text: 'text-slate-700',
|
||||
label: 'Cancelled',
|
||||
border: 'border-slate-200'
|
||||
},
|
||||
};
|
||||
return badges[status] || badges.draft;
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this invoice?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invoiceService.deleteInvoice(id);
|
||||
toast.success('Invoice deleted successfully');
|
||||
fetchInvoices();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to delete invoice');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && invoices.length === 0) {
|
||||
return <Loading fullScreen text="Loading invoices..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
Invoice Management
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-3 text-lg font-light">Manage and track all invoices</p>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<ExportButton
|
||||
data={invoices.map(i => ({
|
||||
'Invoice Number': i.invoice_number,
|
||||
'Customer Name': i.customer_name,
|
||||
'Customer Email': i.customer_email,
|
||||
'Booking ID': i.booking_id || 'N/A',
|
||||
'Subtotal': formatCurrency(i.subtotal),
|
||||
'Tax Amount': formatCurrency(i.tax_amount),
|
||||
'Discount Amount': formatCurrency(i.discount_amount),
|
||||
'Total Amount': formatCurrency(i.total_amount),
|
||||
'Amount Paid': formatCurrency(i.amount_paid),
|
||||
'Balance Due': formatCurrency(i.balance_due),
|
||||
'Status': i.status,
|
||||
'Issue Date': i.issue_date ? formatDate(i.issue_date) : 'N/A',
|
||||
'Due Date': i.due_date ? formatDate(i.due_date) : 'N/A',
|
||||
'Paid Date': i.paid_date ? formatDate(i.paid_date) : 'N/A',
|
||||
'Is Proforma': i.is_proforma ? 'Yes' : 'No'
|
||||
}))}
|
||||
filename="invoices"
|
||||
title="Invoice Management Report"
|
||||
/>
|
||||
<button
|
||||
onClick={() => navigate('/accountant/bookings')}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Invoice from Booking
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search invoices..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-3 px-4 py-3.5 bg-gradient-to-r from-slate-50 to-white border-2 border-slate-200 rounded-xl">
|
||||
<Filter className="w-5 h-5 text-amber-600" />
|
||||
<span className="text-sm font-semibold text-slate-700">
|
||||
{totalItems} invoice{totalItems !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
|
||||
Invoice #
|
||||
</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
|
||||
Customer
|
||||
</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
|
||||
Booking
|
||||
</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
|
||||
Promotion
|
||||
</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
|
||||
Due Date
|
||||
</th>
|
||||
<th className="px-8 py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-100">
|
||||
{invoices.length > 0 ? (
|
||||
invoices.map((invoice, index) => {
|
||||
const statusBadge = getStatusBadge(invoice.status);
|
||||
return (
|
||||
<tr
|
||||
key={invoice.id}
|
||||
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
|
||||
style={{ animationDelay: `${index * 0.05}s` }}
|
||||
>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<FileText className="w-5 h-5 text-amber-600 mr-3" />
|
||||
<span className="text-sm font-bold text-slate-900 font-mono">
|
||||
{invoice.invoice_number}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5">
|
||||
<div className="text-sm font-semibold text-slate-900">{invoice.customer_name}</div>
|
||||
<div className="text-sm text-slate-500">{invoice.customer_email}</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<span className="text-sm font-medium text-amber-600">#{invoice.booking_id}</span>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||
{formatCurrency(invoice.total_amount)}
|
||||
</div>
|
||||
{invoice.balance_due > 0 && (
|
||||
<div className="text-xs text-rose-600 font-medium mt-1">
|
||||
Due: {formatCurrency(invoice.balance_due)}
|
||||
</div>
|
||||
)}
|
||||
{invoice.discount_amount > 0 && (
|
||||
<div className="text-xs text-green-600 font-medium mt-1">
|
||||
Discount: -{formatCurrency(invoice.discount_amount)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{invoice.promotion_code ? (
|
||||
<span className="px-3 py-1 text-xs font-semibold rounded-full bg-gradient-to-r from-purple-50 to-pink-50 text-purple-700 border border-purple-200">
|
||||
{invoice.promotion_code}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">—</span>
|
||||
)}
|
||||
{invoice.is_proforma && (
|
||||
<div className="text-xs text-blue-600 font-medium mt-1">
|
||||
Proforma
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<span className={`px-4 py-1.5 text-xs font-semibold rounded-full border shadow-sm ${statusBadge.bg} ${statusBadge.text} ${statusBadge.border || ''}`}>
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap text-sm text-slate-600">
|
||||
{formatDate(invoice.due_date, 'short')}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/accountant/invoices/${invoice.id}`)}
|
||||
className="p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all duration-200 shadow-sm hover:shadow-md border border-blue-200 hover:border-blue-300"
|
||||
title="View"
|
||||
>
|
||||
<Eye className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/accountant/invoices/${invoice.id}/edit`)}
|
||||
className="p-2 rounded-lg text-indigo-600 hover:text-indigo-700 hover:bg-indigo-50 transition-all duration-200 shadow-sm hover:shadow-md border border-indigo-200 hover:border-indigo-300"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(invoice.id)}
|
||||
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-8 py-12 text-center">
|
||||
<div className="text-slate-500">
|
||||
<FileText className="w-16 h-16 mx-auto mb-4 text-slate-300" />
|
||||
<p className="text-lg font-semibold">No invoices found</p>
|
||||
<p className="text-sm mt-1">Create your first invoice to get started</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t border-gray-200">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvoiceManagementPage;
|
||||
|
||||
317
Frontend/src/pages/accountant/PaymentManagementPage.tsx
Normal file
317
Frontend/src/pages/accountant/PaymentManagementPage.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { paymentService } from '../../services/api';
|
||||
import type { Payment } from '../../services/api/paymentService';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import { ExportButton } from '../../components/common';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { formatDate } from '../../utils/format';
|
||||
|
||||
const PaymentManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
method: '',
|
||||
from: '',
|
||||
to: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPayments();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchPayments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await paymentService.getPayments({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setPayments(response.data.payments);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load payments list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getMethodBadge = (method: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
cash: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: 'Cash',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
bank_transfer: {
|
||||
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
|
||||
text: 'text-blue-800',
|
||||
label: 'Bank transfer',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
stripe: {
|
||||
bg: 'bg-gradient-to-r from-indigo-50 to-purple-50',
|
||||
text: 'text-indigo-800',
|
||||
label: 'Stripe',
|
||||
border: 'border-indigo-200'
|
||||
},
|
||||
paypal: {
|
||||
bg: 'bg-gradient-to-r from-blue-50 to-cyan-50',
|
||||
text: 'text-blue-800',
|
||||
label: 'PayPal',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
credit_card: {
|
||||
bg: 'bg-gradient-to-r from-purple-50 to-pink-50',
|
||||
text: 'text-purple-800',
|
||||
label: 'Credit card',
|
||||
border: 'border-purple-200'
|
||||
},
|
||||
};
|
||||
const badge = badges[method] || badges.cash;
|
||||
return (
|
||||
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getPaymentStatusBadge = (status: string) => {
|
||||
const statusConfig: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
completed: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: '✅ Paid',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
pending: {
|
||||
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
|
||||
text: 'text-amber-800',
|
||||
label: '⏳ Pending',
|
||||
border: 'border-amber-200'
|
||||
},
|
||||
failed: {
|
||||
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
|
||||
text: 'text-rose-800',
|
||||
label: '❌ Failed',
|
||||
border: 'border-rose-200'
|
||||
},
|
||||
refunded: {
|
||||
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
|
||||
text: 'text-slate-700',
|
||||
label: '💰 Refunded',
|
||||
border: 'border-slate-200'
|
||||
},
|
||||
};
|
||||
const config = statusConfig[status] || statusConfig.pending;
|
||||
return (
|
||||
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${config.bg} ${config.text} ${config.border}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
{}
|
||||
<div className="animate-fade-in">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
Payment Management
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-3 text-lg font-light">Track payment transactions</p>
|
||||
</div>
|
||||
<ExportButton
|
||||
data={payments.map(p => ({
|
||||
'Transaction ID': p.transaction_id || `PAY-${p.id}`,
|
||||
'Booking Number': p.booking?.booking_number || 'N/A',
|
||||
'Customer': p.booking?.user?.full_name || p.booking?.user?.email || 'N/A',
|
||||
'Payment Method': p.payment_method || 'N/A',
|
||||
'Payment Type': p.payment_type || 'N/A',
|
||||
'Amount': formatCurrency(p.amount || 0),
|
||||
'Status': p.payment_status,
|
||||
'Payment Date': p.payment_date ? formatDate(p.payment_date) : 'N/A',
|
||||
'Created At': p.created_at ? formatDate(p.created_at) : 'N/A'
|
||||
}))}
|
||||
filename="payments"
|
||||
title="Payment Transactions Report"
|
||||
customHeaders={{
|
||||
'Transaction ID': 'Transaction ID',
|
||||
'Booking Number': 'Booking Number',
|
||||
'Customer': 'Customer',
|
||||
'Payment Method': 'Payment Method',
|
||||
'Payment Type': 'Payment Type',
|
||||
'Amount': 'Amount',
|
||||
'Status': 'Status',
|
||||
'Payment Date': 'Payment Date',
|
||||
'Created At': 'Created At'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.method}
|
||||
onChange={(e) => setFilters({ ...filters, method: e.target.value })}
|
||||
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<option value="">All methods</option>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="stripe">Stripe</option>
|
||||
<option value="credit_card">Credit card</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.from}
|
||||
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
|
||||
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
|
||||
placeholder="From date"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.to}
|
||||
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
|
||||
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
|
||||
placeholder="To date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Transaction ID</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Booking Number</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Customer</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Method</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Type</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Amount</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Payment Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-100">
|
||||
{payments.map((payment, index) => (
|
||||
<tr
|
||||
key={payment.id}
|
||||
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
|
||||
style={{ animationDelay: `${index * 0.05}s` }}
|
||||
>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-bold text-slate-900 font-mono">{payment.transaction_id || `PAY-${payment.id}`}</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-amber-600">{payment.booking?.booking_number}</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-slate-900">{payment.booking?.user?.name}</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{getMethodBadge(payment.payment_method)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{payment.payment_type === 'deposit' ? (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
|
||||
Deposit (20%)
|
||||
</span>
|
||||
) : payment.payment_type === 'remaining' ? (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 border border-blue-200">
|
||||
Remaining
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
|
||||
Full Payment
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{getPaymentStatusBadge(payment.payment_status)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||
{formatCurrency(payment.amount)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm text-slate-600">
|
||||
{new Date(payment.payment_date || payment.createdAt || '').toLocaleDateString('en-US')}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-amber-500 via-amber-600 to-amber-700 rounded-2xl shadow-2xl p-8 text-white animate-fade-in" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2 text-amber-100">Total Revenue</h3>
|
||||
<p className="text-4xl font-bold">
|
||||
{formatCurrency(payments
|
||||
.filter(p => p.payment_status === 'completed')
|
||||
.reduce((sum, p) => sum + p.amount, 0))}
|
||||
</p>
|
||||
<p className="text-sm mt-3 text-amber-100/90">
|
||||
Total {payments.filter(p => p.payment_status === 'completed').length} paid transaction{payments.filter(p => p.payment_status === 'completed').length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/20 backdrop-blur-sm p-6 rounded-2xl">
|
||||
<div className="text-5xl font-bold text-white/80">{payments.filter(p => p.payment_status === 'completed').length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentManagementPage;
|
||||
11
Frontend/src/pages/accountant/index.ts
Normal file
11
Frontend/src/pages/accountant/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Accountant Pages
|
||||
*
|
||||
* All pages accessible only to accountants
|
||||
*/
|
||||
|
||||
export { default as AccountantDashboardPage } from './DashboardPage';
|
||||
export { default as PaymentManagementPage } from './PaymentManagementPage';
|
||||
export { default as InvoiceManagementPage } from './InvoiceManagementPage';
|
||||
export { default as AnalyticsDashboardPage } from './AnalyticsDashboardPage';
|
||||
|
||||
679
Frontend/src/pages/admin/AdvancedAnalyticsPage.tsx
Normal file
679
Frontend/src/pages/admin/AdvancedAnalyticsPage.tsx
Normal file
@@ -0,0 +1,679 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Users,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Building2,
|
||||
Activity,
|
||||
Star,
|
||||
CreditCard,
|
||||
Target,
|
||||
Award,
|
||||
ArrowDown,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loading } from '../../components/common';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import analyticsService, {
|
||||
ComprehensiveAnalyticsData,
|
||||
RevPARData,
|
||||
ADRData,
|
||||
OccupancyRateData,
|
||||
RevenueForecastData,
|
||||
MarketPenetrationData,
|
||||
StaffPerformanceData,
|
||||
ServiceUsageData,
|
||||
OperationalEfficiencyData,
|
||||
GuestLTVData,
|
||||
RepeatGuestRateData,
|
||||
GuestSatisfactionTrendsData,
|
||||
ProfitLossData,
|
||||
PaymentMethodAnalyticsData,
|
||||
RefundAnalysisData,
|
||||
} from '../../services/api/analyticsService';
|
||||
import { SimpleBarChart, SimpleLineChart, SimplePieChart, KPICard } from '../../components/analytics/SimpleChart';
|
||||
import { exportData } from '../../utils/exportUtils';
|
||||
import CustomReportBuilder from '../../components/analytics/CustomReportBuilder';
|
||||
|
||||
type AnalyticsCategory = 'revenue' | 'operational' | 'guest' | 'financial' | 'comprehensive';
|
||||
|
||||
const AdvancedAnalyticsPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [activeCategory, setActiveCategory] = useState<AnalyticsCategory>('comprehensive');
|
||||
const [showReportBuilder, setShowReportBuilder] = useState(false);
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
to: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
|
||||
// Revenue Analytics
|
||||
const { data: revparData, loading: revparLoading, execute: fetchRevPAR } = useAsync<RevPARData>(
|
||||
() => analyticsService.getRevPAR({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: adrData, loading: adrLoading, execute: fetchADR } = useAsync<ADRData>(
|
||||
() => analyticsService.getADR({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: occupancyData, loading: occupancyLoading, execute: fetchOccupancy } = useAsync<OccupancyRateData>(
|
||||
() => analyticsService.getOccupancyRate({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: forecastData, execute: fetchForecast } = useAsync<RevenueForecastData>(
|
||||
() => analyticsService.getRevenueForecast(30).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: marketPenetrationData, execute: fetchMarketPenetration } = useAsync<MarketPenetrationData>(
|
||||
() => analyticsService.getMarketPenetration({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
// Operational Analytics
|
||||
const { data: staffPerformanceData, loading: staffLoading, execute: fetchStaffPerformance } = useAsync<StaffPerformanceData>(
|
||||
() => analyticsService.getStaffPerformance({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: serviceUsageData, loading: serviceLoading, execute: fetchServiceUsage } = useAsync<ServiceUsageData>(
|
||||
() => analyticsService.getServiceUsageAnalytics({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: efficiencyData, loading: efficiencyLoading, execute: fetchEfficiency } = useAsync<OperationalEfficiencyData>(
|
||||
() => analyticsService.getOperationalEfficiency({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
// Guest Analytics
|
||||
const { data: ltvData, loading: ltvLoading, execute: fetchLTV } = useAsync<GuestLTVData>(
|
||||
() => analyticsService.getGuestLifetimeValue({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: repeatRateData, loading: repeatLoading, execute: fetchRepeatRate } = useAsync<RepeatGuestRateData>(
|
||||
() => analyticsService.getRepeatGuestRate({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: satisfactionData, loading: satisfactionLoading, execute: fetchSatisfaction } = useAsync<GuestSatisfactionTrendsData>(
|
||||
() => analyticsService.getGuestSatisfactionTrends({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
// Financial Analytics
|
||||
const { data: profitLossData, loading: profitLossLoading, execute: fetchProfitLoss } = useAsync<ProfitLossData>(
|
||||
() => analyticsService.getProfitLoss({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: paymentMethodData, loading: paymentMethodLoading, execute: fetchPaymentMethods } = useAsync<PaymentMethodAnalyticsData>(
|
||||
() => analyticsService.getPaymentMethodAnalytics({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: refundData, loading: refundLoading, execute: fetchRefunds } = useAsync<RefundAnalysisData>(
|
||||
() => analyticsService.getRefundAnalysis({ from: dateRange.from, to: dateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
// Comprehensive Analytics
|
||||
const { data: comprehensiveData, loading: comprehensiveLoading, execute: fetchComprehensive } = useAsync<ComprehensiveAnalyticsData>(
|
||||
() => analyticsService.getComprehensiveAnalytics({
|
||||
from: dateRange.from,
|
||||
to: dateRange.to,
|
||||
include_revenue: true,
|
||||
include_operational: true,
|
||||
include_guest: true,
|
||||
include_financial: true,
|
||||
}).then(r => r.data),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategoryData();
|
||||
}, [activeCategory, dateRange]);
|
||||
|
||||
const loadCategoryData = async () => {
|
||||
try {
|
||||
if (activeCategory === 'comprehensive') {
|
||||
await fetchComprehensive();
|
||||
} else if (activeCategory === 'revenue') {
|
||||
await Promise.all([fetchRevPAR(), fetchADR(), fetchOccupancy(), fetchForecast(), fetchMarketPenetration()]);
|
||||
} else if (activeCategory === 'operational') {
|
||||
await Promise.all([fetchStaffPerformance(), fetchServiceUsage(), fetchEfficiency()]);
|
||||
} else if (activeCategory === 'guest') {
|
||||
await Promise.all([fetchLTV(), fetchRepeatRate(), fetchSatisfaction()]);
|
||||
} else if (activeCategory === 'financial') {
|
||||
await Promise.all([fetchProfitLoss(), fetchPaymentMethods(), fetchRefunds()]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to load analytics data');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = (format: 'csv' | 'xlsx' | 'pdf' | 'json') => {
|
||||
try {
|
||||
let exportDataArray: any[] = [];
|
||||
let filename = 'analytics';
|
||||
let title = 'Analytics Report';
|
||||
|
||||
if (activeCategory === 'revenue' && comprehensiveData?.revenue) {
|
||||
const revenue = comprehensiveData.revenue;
|
||||
exportDataArray = [
|
||||
{ Metric: 'RevPAR', Value: revenue.revpar.revpar },
|
||||
{ Metric: 'ADR', Value: revenue.adr.adr },
|
||||
{ Metric: 'Occupancy Rate', Value: `${revenue.occupancy_rate.occupancy_rate}%` },
|
||||
];
|
||||
filename = 'revenue-analytics';
|
||||
title = 'Revenue Analytics Report';
|
||||
} else if (activeCategory === 'operational' && comprehensiveData?.operational) {
|
||||
const operational = comprehensiveData.operational;
|
||||
exportDataArray = operational.staff_performance.staff_performance.map(staff => ({
|
||||
'Staff Name': staff.staff_name,
|
||||
'Email': staff.email,
|
||||
'Check-ins Handled': staff.check_ins_handled,
|
||||
'Performance Score': staff.performance_score,
|
||||
}));
|
||||
filename = 'operational-analytics';
|
||||
title = 'Operational Analytics Report';
|
||||
} else if (activeCategory === 'guest' && comprehensiveData?.guest) {
|
||||
const guest = comprehensiveData.guest;
|
||||
exportDataArray = guest.lifetime_value.guests.slice(0, 50).map(g => ({
|
||||
'Guest Name': g.name,
|
||||
'Email': g.email,
|
||||
'Total Bookings': g.total_bookings,
|
||||
'Lifetime Value': g.lifetime_value,
|
||||
'Average Booking Value': g.average_booking_value,
|
||||
}));
|
||||
filename = 'guest-analytics';
|
||||
title = 'Guest Analytics Report';
|
||||
} else if (activeCategory === 'financial' && comprehensiveData?.financial) {
|
||||
const financial = comprehensiveData.financial;
|
||||
exportDataArray = financial.payment_methods.payment_methods.map(pm => ({
|
||||
'Payment Method': pm.payment_method,
|
||||
'Transaction Count': pm.transaction_count,
|
||||
'Total Amount': pm.total_amount,
|
||||
'Average Amount': pm.average_amount,
|
||||
'Percentage': `${pm.percentage}%`,
|
||||
}));
|
||||
filename = 'financial-analytics';
|
||||
title = 'Financial Analytics Report';
|
||||
} else if (comprehensiveData) {
|
||||
// Export comprehensive data
|
||||
exportDataArray = [comprehensiveData];
|
||||
filename = 'comprehensive-analytics';
|
||||
title = 'Comprehensive Analytics Report';
|
||||
}
|
||||
|
||||
if (exportDataArray.length > 0) {
|
||||
exportData({
|
||||
filename,
|
||||
title,
|
||||
data: exportDataArray,
|
||||
format,
|
||||
});
|
||||
toast.success(`Exported ${format.toUpperCase()} successfully`);
|
||||
} else {
|
||||
toast.error('No data to export');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`Export failed: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = comprehensiveLoading ||
|
||||
(activeCategory === 'revenue' && (revparLoading || adrLoading || occupancyLoading)) ||
|
||||
(activeCategory === 'operational' && (staffLoading || serviceLoading || efficiencyLoading)) ||
|
||||
(activeCategory === 'guest' && (ltvLoading || repeatLoading || satisfactionLoading)) ||
|
||||
(activeCategory === 'financial' && (profitLossLoading || paymentMethodLoading || refundLoading));
|
||||
|
||||
const categories = [
|
||||
{ id: 'comprehensive' as AnalyticsCategory, label: 'Comprehensive', icon: BarChart3, color: 'blue' },
|
||||
{ id: 'revenue' as AnalyticsCategory, label: 'Revenue', icon: DollarSign, color: 'green' },
|
||||
{ id: 'operational' as AnalyticsCategory, label: 'Operational', icon: Activity, color: 'orange' },
|
||||
{ id: 'guest' as AnalyticsCategory, label: 'Guest', icon: Users, color: 'purple' },
|
||||
{ id: 'financial' as AnalyticsCategory, label: 'Financial', icon: CreditCard, color: 'red' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Advanced Analytics & BI</h1>
|
||||
<p className="text-gray-600">Comprehensive business intelligence and analytics dashboard</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-gray-500" />
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.from}
|
||||
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
<span className="text-gray-500">to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadCategoryData}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowReportBuilder(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Custom Report
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport('csv')}
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-2 border border-gray-100">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => {
|
||||
const Icon = category.icon;
|
||||
const isActive = activeCategory === category.id;
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all ${
|
||||
isActive
|
||||
? 'bg-indigo-600 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{category.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<Loading fullScreen text="Loading analytics..." />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{activeCategory === 'comprehensive' && comprehensiveData && (
|
||||
<ComprehensiveView data={comprehensiveData} formatCurrency={formatCurrency} />
|
||||
)}
|
||||
{activeCategory === 'revenue' && (
|
||||
<RevenueView
|
||||
revparData={revparData ?? undefined}
|
||||
adrData={adrData ?? undefined}
|
||||
occupancyData={occupancyData ?? undefined}
|
||||
forecastData={forecastData ?? undefined}
|
||||
marketPenetrationData={marketPenetrationData ?? undefined}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
)}
|
||||
{activeCategory === 'operational' && (
|
||||
<OperationalView
|
||||
staffPerformanceData={staffPerformanceData ?? undefined}
|
||||
serviceUsageData={serviceUsageData ?? undefined}
|
||||
efficiencyData={efficiencyData ?? undefined}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
)}
|
||||
{activeCategory === 'guest' && (
|
||||
<GuestView
|
||||
ltvData={ltvData ?? undefined}
|
||||
repeatRateData={repeatRateData ?? undefined}
|
||||
satisfactionData={satisfactionData ?? undefined}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
)}
|
||||
{activeCategory === 'financial' && (
|
||||
<FinancialView
|
||||
profitLossData={profitLossData ?? undefined}
|
||||
paymentMethodData={paymentMethodData ?? undefined}
|
||||
refundData={refundData ?? undefined}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Report Builder Modal */}
|
||||
{showReportBuilder && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<CustomReportBuilder onClose={() => setShowReportBuilder(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Comprehensive View Component
|
||||
const ComprehensiveView: React.FC<{ data: ComprehensiveAnalyticsData; formatCurrency: (amount: number) => string }> = ({ data, formatCurrency }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{data.revenue && (
|
||||
<>
|
||||
<KPICard
|
||||
title="RevPAR"
|
||||
value={formatCurrency(data.revenue.revpar.revpar)}
|
||||
icon={<TrendingUp className="w-6 h-6" />}
|
||||
color="blue"
|
||||
/>
|
||||
<KPICard
|
||||
title="ADR"
|
||||
value={formatCurrency(data.revenue.adr.adr)}
|
||||
icon={<DollarSign className="w-6 h-6" />}
|
||||
color="green"
|
||||
/>
|
||||
<KPICard
|
||||
title="Occupancy Rate"
|
||||
value={`${data.revenue.occupancy_rate.occupancy_rate.toFixed(1)}%`}
|
||||
icon={<Building2 className="w-6 h-6" />}
|
||||
color="orange"
|
||||
/>
|
||||
<KPICard
|
||||
title="Net Revenue"
|
||||
value={data.financial ? formatCurrency(data.financial.profit_loss.net_revenue) : 'N/A'}
|
||||
icon={<CreditCard className="w-6 h-6" />}
|
||||
color="purple"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{data.revenue && data.revenue.market_penetration && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Market Penetration by Room Type</h3>
|
||||
<SimpleBarChart
|
||||
data={data.revenue.market_penetration.penetration_by_room_type.map((item) => ({
|
||||
label: item.room_type,
|
||||
value: item.market_share,
|
||||
}))}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.financial && data.financial.payment_methods && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Payment Methods Distribution</h3>
|
||||
<SimplePieChart
|
||||
data={data.financial.payment_methods.payment_methods.map((item) => ({
|
||||
label: item.payment_method,
|
||||
value: item.percentage,
|
||||
}))}
|
||||
size={200}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Revenue View Component
|
||||
const RevenueView: React.FC<{
|
||||
revparData?: RevPARData;
|
||||
adrData?: ADRData;
|
||||
occupancyData?: OccupancyRateData;
|
||||
forecastData?: RevenueForecastData;
|
||||
marketPenetrationData?: MarketPenetrationData;
|
||||
formatCurrency: (amount: number) => string;
|
||||
}> = ({ revparData, adrData, occupancyData, forecastData, marketPenetrationData, formatCurrency }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{revparData && (
|
||||
<KPICard
|
||||
title="RevPAR"
|
||||
value={formatCurrency(revparData.revpar)}
|
||||
subtitle={`${revparData.period_days} days`}
|
||||
icon={<TrendingUp className="w-6 h-6" />}
|
||||
color="blue"
|
||||
/>
|
||||
)}
|
||||
{adrData && (
|
||||
<KPICard
|
||||
title="ADR"
|
||||
value={formatCurrency(adrData.adr)}
|
||||
icon={<DollarSign className="w-6 h-6" />}
|
||||
color="green"
|
||||
/>
|
||||
)}
|
||||
{occupancyData && (
|
||||
<KPICard
|
||||
title="Occupancy Rate"
|
||||
value={`${occupancyData.occupancy_rate.toFixed(1)}%`}
|
||||
subtitle={`${occupancyData.occupied_room_nights} / ${occupancyData.available_room_nights} nights`}
|
||||
icon={<Building2 className="w-6 h-6" />}
|
||||
color="orange"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{forecastData && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Revenue Forecast (Next 30 Days)</h3>
|
||||
<SimpleLineChart
|
||||
data={forecastData.forecast.slice(0, 30).map(item => ({
|
||||
label: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
value: item.forecasted_revenue,
|
||||
}))}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{marketPenetrationData && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Market Penetration by Room Type</h3>
|
||||
<SimpleBarChart
|
||||
data={marketPenetrationData.penetration_by_room_type.map((item) => ({
|
||||
label: item.room_type,
|
||||
value: item.market_share,
|
||||
}))}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Operational View Component
|
||||
const OperationalView: React.FC<{
|
||||
staffPerformanceData?: StaffPerformanceData;
|
||||
serviceUsageData?: ServiceUsageData;
|
||||
efficiencyData?: OperationalEfficiencyData;
|
||||
formatCurrency: (amount: number) => string;
|
||||
}> = ({ serviceUsageData, efficiencyData, formatCurrency }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{efficiencyData && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<KPICard
|
||||
title="Conversion Rate"
|
||||
value={`${efficiencyData.conversion_rate.toFixed(1)}%`}
|
||||
icon={<Target className="w-6 h-6" />}
|
||||
color="blue"
|
||||
/>
|
||||
<KPICard
|
||||
title="Avg Booking Value"
|
||||
value={formatCurrency(efficiencyData.average_booking_value)}
|
||||
icon={<DollarSign className="w-6 h-6" />}
|
||||
color="green"
|
||||
/>
|
||||
<KPICard
|
||||
title="Cancellation Rate"
|
||||
value={`${efficiencyData.cancellation_rate.toFixed(1)}%`}
|
||||
icon={<Activity className="w-6 h-6" />}
|
||||
color="red"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serviceUsageData && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top Services by Revenue</h3>
|
||||
<SimpleBarChart
|
||||
data={serviceUsageData.services.slice(0, 10).map(item => ({
|
||||
label: item.service_name,
|
||||
value: item.total_revenue,
|
||||
}))}
|
||||
height={250}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Guest View Component
|
||||
const GuestView: React.FC<{
|
||||
ltvData?: GuestLTVData;
|
||||
repeatRateData?: RepeatGuestRateData;
|
||||
satisfactionData?: GuestSatisfactionTrendsData;
|
||||
formatCurrency: (amount: number) => string;
|
||||
}> = ({ ltvData, repeatRateData, satisfactionData, formatCurrency }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{repeatRateData && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<KPICard
|
||||
title="Repeat Guest Rate"
|
||||
value={`${repeatRateData.repeat_guest_rate.toFixed(1)}%`}
|
||||
subtitle={`${repeatRateData.repeat_guests} repeat / ${repeatRateData.total_guests} total`}
|
||||
icon={<Users className="w-6 h-6" />}
|
||||
color="purple"
|
||||
/>
|
||||
{ltvData && (
|
||||
<KPICard
|
||||
title="Average LTV"
|
||||
value={formatCurrency(ltvData.average_ltv)}
|
||||
subtitle={`${ltvData.total_guests_analyzed} guests`}
|
||||
icon={<Award className="w-6 h-6" />}
|
||||
color="blue"
|
||||
/>
|
||||
)}
|
||||
{satisfactionData && (
|
||||
<KPICard
|
||||
title="Avg Satisfaction"
|
||||
value={`${satisfactionData.overall_average_rating.toFixed(1)}/5`}
|
||||
subtitle={`${satisfactionData.total_reviews} reviews`}
|
||||
icon={<Star className="w-6 h-6" />}
|
||||
color="orange"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{satisfactionData && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Satisfaction Trends</h3>
|
||||
<SimpleLineChart
|
||||
data={satisfactionData.trends.map(item => ({
|
||||
label: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
value: item.average_rating,
|
||||
}))}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Financial View Component
|
||||
const FinancialView: React.FC<{
|
||||
profitLossData?: ProfitLossData;
|
||||
paymentMethodData?: PaymentMethodAnalyticsData;
|
||||
refundData?: RefundAnalysisData;
|
||||
formatCurrency: (amount: number) => string;
|
||||
}> = ({ profitLossData, paymentMethodData, formatCurrency }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{profitLossData && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<KPICard
|
||||
title="Total Revenue"
|
||||
value={formatCurrency(profitLossData.total_revenue)}
|
||||
icon={<DollarSign className="w-6 h-6" />}
|
||||
color="green"
|
||||
/>
|
||||
<KPICard
|
||||
title="Refunds"
|
||||
value={formatCurrency(profitLossData.refunds)}
|
||||
icon={<ArrowDown className="w-6 h-6" />}
|
||||
color="red"
|
||||
/>
|
||||
<KPICard
|
||||
title="Net Revenue"
|
||||
value={formatCurrency(profitLossData.net_revenue)}
|
||||
icon={<TrendingUp className="w-6 h-6" />}
|
||||
color="blue"
|
||||
/>
|
||||
<KPICard
|
||||
title="Gross Profit"
|
||||
value={formatCurrency(profitLossData.gross_profit)}
|
||||
icon={<Award className="w-6 h-6" />}
|
||||
color="purple"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentMethodData && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Payment Methods Distribution</h3>
|
||||
<SimplePieChart
|
||||
data={paymentMethodData.payment_methods.map((item) => ({
|
||||
label: item.payment_method,
|
||||
value: item.percentage,
|
||||
}))}
|
||||
size={250}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedAnalyticsPage;
|
||||
|
||||
1475
Frontend/src/pages/admin/AdvancedRoomManagementPage.tsx
Normal file
1475
Frontend/src/pages/admin/AdvancedRoomManagementPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,14 @@ import {
|
||||
ClipboardList,
|
||||
X,
|
||||
ChevronRight,
|
||||
Star
|
||||
Star,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
DollarSign,
|
||||
CreditCard,
|
||||
Building2,
|
||||
Target,
|
||||
Award
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loading, EmptyState, ExportButton } from '../../components/common';
|
||||
@@ -31,12 +38,37 @@ import { reportService, ReportData, reviewService, Review } from '../../services
|
||||
import { auditService, AuditLog, AuditLogFilters } from '../../services/api/auditService';
|
||||
import { formatDate } from '../../utils/format';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import analyticsService, {
|
||||
ComprehensiveAnalyticsData,
|
||||
RevPARData,
|
||||
ADRData,
|
||||
OccupancyRateData,
|
||||
RevenueForecastData,
|
||||
MarketPenetrationData,
|
||||
StaffPerformanceData,
|
||||
ServiceUsageData,
|
||||
OperationalEfficiencyData,
|
||||
GuestLTVData,
|
||||
RepeatGuestRateData,
|
||||
GuestSatisfactionTrendsData,
|
||||
ProfitLossData,
|
||||
PaymentMethodAnalyticsData,
|
||||
RefundAnalysisData,
|
||||
} from '../../services/api/analyticsService';
|
||||
import { SimpleBarChart, SimpleLineChart, SimplePieChart, KPICard } from '../../components/analytics/SimpleChart';
|
||||
import { exportData } from '../../utils/exportUtils';
|
||||
import CustomReportBuilder from '../../components/analytics/CustomReportBuilder';
|
||||
|
||||
type AnalyticsTab = 'overview' | 'reports' | 'audit-logs' | 'reviews';
|
||||
type AnalyticsTab = 'overview' | 'reports' | 'revenue' | 'operational' | 'guest' | 'financial' | 'audit-logs' | 'reviews';
|
||||
|
||||
const AnalyticsDashboardPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [activeTab, setActiveTab] = useState<AnalyticsTab>('overview');
|
||||
const [showReportBuilder, setShowReportBuilder] = useState(false);
|
||||
const [analyticsDateRange, setAnalyticsDateRange] = useState({
|
||||
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
to: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
|
||||
|
||||
const [dateRange, setDateRange] = useState({
|
||||
@@ -71,6 +103,89 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
const [reviewsTotalItems, setReviewsTotalItems] = useState(0);
|
||||
const reviewsPerPage = 5;
|
||||
|
||||
// Advanced Analytics Data
|
||||
const { data: comprehensiveData, loading: comprehensiveLoading, execute: fetchComprehensive } = useAsync<ComprehensiveAnalyticsData>(
|
||||
() => analyticsService.getComprehensiveAnalytics({
|
||||
from: analyticsDateRange.from,
|
||||
to: analyticsDateRange.to,
|
||||
include_revenue: true,
|
||||
include_operational: true,
|
||||
include_guest: true,
|
||||
include_financial: true,
|
||||
}).then(r => r.data),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const { data: revparData, loading: revparLoading, execute: fetchRevPAR } = useAsync<RevPARData>(
|
||||
() => analyticsService.getRevPAR({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: adrData, loading: adrLoading, execute: fetchADR } = useAsync<ADRData>(
|
||||
() => analyticsService.getADR({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: occupancyData, loading: occupancyLoading, execute: fetchOccupancy } = useAsync<OccupancyRateData>(
|
||||
() => analyticsService.getOccupancyRate({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: forecastData, loading: forecastLoading, execute: fetchForecast } = useAsync<RevenueForecastData>(
|
||||
() => analyticsService.getRevenueForecast(30).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: marketPenetrationData, loading: marketLoading, execute: fetchMarketPenetration } = useAsync<MarketPenetrationData>(
|
||||
() => analyticsService.getMarketPenetration({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: staffPerformanceData, loading: staffLoading, execute: fetchStaffPerformance } = useAsync<StaffPerformanceData>(
|
||||
() => analyticsService.getStaffPerformance({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: serviceUsageData, loading: serviceLoading, execute: fetchServiceUsage } = useAsync<ServiceUsageData>(
|
||||
() => analyticsService.getServiceUsageAnalytics({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: efficiencyData, loading: efficiencyLoading, execute: fetchEfficiency } = useAsync<OperationalEfficiencyData>(
|
||||
() => analyticsService.getOperationalEfficiency({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: ltvData, loading: ltvLoading, execute: fetchLTV } = useAsync<GuestLTVData>(
|
||||
() => analyticsService.getGuestLifetimeValue({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: repeatRateData, loading: repeatLoading, execute: fetchRepeatRate } = useAsync<RepeatGuestRateData>(
|
||||
() => analyticsService.getRepeatGuestRate({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: satisfactionData, loading: satisfactionLoading, execute: fetchSatisfaction } = useAsync<GuestSatisfactionTrendsData>(
|
||||
() => analyticsService.getGuestSatisfactionTrends({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: profitLossData, loading: profitLossLoading, execute: fetchProfitLoss } = useAsync<ProfitLossData>(
|
||||
() => analyticsService.getProfitLoss({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: paymentMethodData, loading: paymentMethodLoading, execute: fetchPaymentMethods } = useAsync<PaymentMethodAnalyticsData>(
|
||||
() => analyticsService.getPaymentMethodAnalytics({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const { data: refundData, loading: refundLoading, execute: fetchRefunds } = useAsync<RefundAnalysisData>(
|
||||
() => analyticsService.getRefundAnalysis({ from: analyticsDateRange.from, to: analyticsDateRange.to }).then(r => r.data),
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const fetchReports = async (): Promise<ReportData> => {
|
||||
const params: any = {};
|
||||
if (dateRange.from) params.from = dateRange.from;
|
||||
@@ -98,8 +213,18 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
fetchLogs();
|
||||
} else if (activeTab === 'reviews') {
|
||||
fetchReviews();
|
||||
} else if (activeTab === 'overview') {
|
||||
fetchComprehensive();
|
||||
} else if (activeTab === 'revenue') {
|
||||
Promise.all([fetchRevPAR(), fetchADR(), fetchOccupancy(), fetchForecast(), fetchMarketPenetration()]);
|
||||
} else if (activeTab === 'operational') {
|
||||
Promise.all([fetchStaffPerformance(), fetchServiceUsage(), fetchEfficiency()]);
|
||||
} else if (activeTab === 'guest') {
|
||||
Promise.all([fetchLTV(), fetchRepeatRate(), fetchSatisfaction()]);
|
||||
} else if (activeTab === 'financial') {
|
||||
Promise.all([fetchProfitLoss(), fetchPaymentMethods(), fetchRefunds()]);
|
||||
}
|
||||
}, [activeTab, auditFilters, currentPage, reviewsFilters, reviewsCurrentPage]);
|
||||
}, [activeTab, auditFilters, currentPage, reviewsFilters, reviewsCurrentPage, analyticsDateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
setAuditFilters(prev => ({ ...prev, page: currentPage }));
|
||||
@@ -132,8 +257,81 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const handleExport = async (format: 'csv' | 'xlsx' | 'pdf' | 'json' = 'csv') => {
|
||||
try {
|
||||
// Handle analytics tabs export
|
||||
if (activeTab === 'overview' || activeTab === 'revenue' || activeTab === 'operational' || activeTab === 'guest' || activeTab === 'financial') {
|
||||
let exportDataArray: any[] = [];
|
||||
let filename = 'analytics';
|
||||
let title = 'Analytics Report';
|
||||
|
||||
if (activeTab === 'overview' && comprehensiveData) {
|
||||
const revenue = comprehensiveData.revenue;
|
||||
if (revenue) {
|
||||
exportDataArray = [
|
||||
{ Metric: 'RevPAR', Value: revenue.revpar.revpar },
|
||||
{ Metric: 'ADR', Value: revenue.adr.adr },
|
||||
{ Metric: 'Occupancy Rate', Value: `${revenue.occupancy_rate.occupancy_rate}%` },
|
||||
];
|
||||
}
|
||||
filename = 'comprehensive-analytics';
|
||||
title = 'Comprehensive Analytics Report';
|
||||
} else if (activeTab === 'revenue' && comprehensiveData?.revenue) {
|
||||
const revenue = comprehensiveData.revenue;
|
||||
exportDataArray = [
|
||||
{ Metric: 'RevPAR', Value: revenue.revpar.revpar },
|
||||
{ Metric: 'ADR', Value: revenue.adr.adr },
|
||||
{ Metric: 'Occupancy Rate', Value: `${revenue.occupancy_rate.occupancy_rate}%` },
|
||||
];
|
||||
filename = 'revenue-analytics';
|
||||
title = 'Revenue Analytics Report';
|
||||
} else if (activeTab === 'operational' && comprehensiveData?.operational) {
|
||||
const operational = comprehensiveData.operational;
|
||||
exportDataArray = operational.staff_performance.staff_performance.map(staff => ({
|
||||
'Staff Name': staff.staff_name,
|
||||
'Email': staff.email,
|
||||
'Check-ins Handled': staff.check_ins_handled,
|
||||
'Performance Score': staff.performance_score,
|
||||
}));
|
||||
filename = 'operational-analytics';
|
||||
title = 'Operational Analytics Report';
|
||||
} else if (activeTab === 'guest' && comprehensiveData?.guest) {
|
||||
const guest = comprehensiveData.guest;
|
||||
exportDataArray = guest.lifetime_value.guests.slice(0, 50).map(g => ({
|
||||
'Guest Name': g.name,
|
||||
'Email': g.email,
|
||||
'Total Bookings': g.total_bookings,
|
||||
'Lifetime Value': g.lifetime_value,
|
||||
'Average Booking Value': g.average_booking_value,
|
||||
}));
|
||||
filename = 'guest-analytics';
|
||||
title = 'Guest Analytics Report';
|
||||
} else if (activeTab === 'financial' && comprehensiveData?.financial) {
|
||||
const financial = comprehensiveData.financial;
|
||||
exportDataArray = financial.payment_methods.payment_methods.map(pm => ({
|
||||
'Payment Method': pm.payment_method,
|
||||
'Transaction Count': pm.transaction_count,
|
||||
'Total Amount': pm.total_amount,
|
||||
'Average Amount': pm.average_amount,
|
||||
'Percentage': `${pm.percentage}%`,
|
||||
}));
|
||||
filename = 'financial-analytics';
|
||||
title = 'Financial Analytics Report';
|
||||
}
|
||||
|
||||
if (exportDataArray.length > 0) {
|
||||
exportData({
|
||||
filename,
|
||||
title,
|
||||
data: exportDataArray,
|
||||
format,
|
||||
});
|
||||
toast.success(`Exported ${format.toUpperCase()} successfully`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reports tab export (existing functionality)
|
||||
const params: any = {};
|
||||
if (dateRange.from) params.from = dateRange.from;
|
||||
if (dateRange.to) params.to = dateRange.to;
|
||||
@@ -271,6 +469,10 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview' as AnalyticsTab, label: 'Overview', icon: BarChart3 },
|
||||
{ id: 'revenue' as AnalyticsTab, label: 'Revenue', icon: DollarSign },
|
||||
{ id: 'operational' as AnalyticsTab, label: 'Operational', icon: Activity },
|
||||
{ id: 'guest' as AnalyticsTab, label: 'Guest', icon: Users },
|
||||
{ id: 'financial' as AnalyticsTab, label: 'Financial', icon: CreditCard },
|
||||
{ id: 'reports' as AnalyticsTab, label: 'Reports', icon: FileText },
|
||||
{ id: 'audit-logs' as AnalyticsTab, label: 'Audit Logs', icon: ClipboardList },
|
||||
{ id: 'reviews' as AnalyticsTab, label: 'Reviews', icon: Star },
|
||||
@@ -278,65 +480,117 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-10 animate-fade-in">
|
||||
<div className="max-w-7xl mx-auto px-2 sm:px-3 md:px-4 lg:px-6 xl:px-8 py-2 sm:py-4 md:py-6 lg:py-8 space-y-3 sm:space-y-4 md:space-y-6 lg:space-y-8 animate-fade-in">
|
||||
{}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-400/5 via-transparent to-indigo-600/5 rounded-3xl blur-3xl"></div>
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-blue-200/30 p-8 md:p-10">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-8">
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-400 to-indigo-600 rounded-2xl blur-lg opacity-50"></div>
|
||||
<div className="relative p-4 rounded-2xl bg-gradient-to-br from-blue-500 via-blue-500 to-indigo-600 shadow-xl border border-blue-400/50">
|
||||
<BarChart3 className="w-8 h-8 text-white" />
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-gradient-to-br from-cyan-300 to-blue-500 rounded-full shadow-lg animate-pulse"></div>
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-xl sm:rounded-2xl md:rounded-3xl shadow-2xl border border-blue-200/30 p-3 sm:p-4 md:p-6 lg:p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 sm:gap-6 md:gap-8">
|
||||
<div className="flex items-start gap-3 sm:gap-4 md:gap-5">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-400 to-indigo-600 rounded-xl sm:rounded-2xl blur-lg opacity-50"></div>
|
||||
<div className="relative p-2.5 sm:p-3 md:p-4 rounded-xl sm:rounded-2xl bg-gradient-to-br from-blue-500 via-blue-500 to-indigo-600 shadow-xl border border-blue-400/50">
|
||||
<BarChart3 className="w-5 h-5 sm:w-6 sm:h-6 md:w-8 md:h-8 text-white" />
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3 sm:w-4 sm:h-4 bg-gradient-to-br from-cyan-300 to-blue-500 rounded-full shadow-lg animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold bg-gradient-to-r from-slate-900 via-blue-700 to-slate-900 bg-clip-text text-transparent">
|
||||
<div className="space-y-2 sm:space-y-3 flex-1">
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-3xl font-extrabold bg-gradient-to-r from-slate-900 via-blue-700 to-slate-900 bg-clip-text text-transparent">
|
||||
Analytics Dashboard
|
||||
</h1>
|
||||
<Sparkles className="w-6 h-6 text-blue-500 animate-pulse" />
|
||||
<Sparkles className="w-4 h-4 sm:w-5 sm:h-5 text-blue-500 animate-pulse" />
|
||||
</div>
|
||||
<p className="text-gray-600 text-base md:text-lg max-w-2xl leading-relaxed">
|
||||
<p className="text-gray-600 text-xs sm:text-sm md:text-sm max-w-2xl leading-relaxed">
|
||||
Comprehensive insights, reports, and system activity tracking
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{(activeTab === 'overview' || activeTab === 'revenue' || activeTab === 'operational' || activeTab === 'guest' || activeTab === 'financial') && (
|
||||
<div className="mt-4 sm:mt-6 flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3 flex-wrap">
|
||||
<div className="flex flex-col xs:flex-row items-stretch xs:items-center gap-2 flex-1 sm:flex-initial">
|
||||
<Calendar className="w-4 h-4 sm:w-5 sm:h-5 text-gray-500 hidden xs:block" />
|
||||
<input
|
||||
type="date"
|
||||
value={analyticsDateRange.from}
|
||||
onChange={(e) => setAnalyticsDateRange({ ...analyticsDateRange, from: e.target.value })}
|
||||
className="flex-1 px-2 sm:px-3 py-1.5 sm:py-2 border border-gray-300 rounded-lg text-xs sm:text-sm"
|
||||
/>
|
||||
<span className="text-gray-500 text-xs sm:text-sm hidden xs:inline-flex items-center">to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={analyticsDateRange.to}
|
||||
onChange={(e) => setAnalyticsDateRange({ ...analyticsDateRange, to: e.target.value })}
|
||||
className="flex-1 px-2 sm:px-3 py-1.5 sm:py-2 border border-gray-300 rounded-lg text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeTab === 'overview') fetchComprehensive();
|
||||
else if (activeTab === 'revenue') Promise.all([fetchRevPAR(), fetchADR(), fetchOccupancy(), fetchForecast(), fetchMarketPenetration()]);
|
||||
else if (activeTab === 'operational') Promise.all([fetchStaffPerformance(), fetchServiceUsage(), fetchEfficiency()]);
|
||||
else if (activeTab === 'guest') Promise.all([fetchLTV(), fetchRepeatRate(), fetchSatisfaction()]);
|
||||
else if (activeTab === 'financial') Promise.all([fetchProfitLoss(), fetchPaymentMethods(), fetchRefunds()]);
|
||||
}}
|
||||
className="flex-1 sm:flex-none px-3 sm:px-4 py-1.5 sm:py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center justify-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowReportBuilder(true)}
|
||||
className="flex-1 sm:flex-none px-3 sm:px-4 py-1.5 sm:py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
<span className="hidden sm:inline">Custom Report</span>
|
||||
<span className="sm:hidden">Report</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport('csv')}
|
||||
className="flex-1 sm:flex-none px-3 sm:px-4 py-1.5 sm:py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center justify-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="mt-10 pt-8 border-t border-gradient-to-r from-transparent via-blue-200/30 to-transparent">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
group relative flex items-center gap-3 px-6 py-3.5 rounded-xl font-semibold text-sm
|
||||
transition-all duration-300 overflow-hidden
|
||||
${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-blue-500 via-blue-500 to-indigo-600 text-white shadow-xl shadow-blue-500/40 scale-105'
|
||||
: 'bg-white/80 text-gray-700 border border-gray-200/60 hover:border-blue-300/60 hover:bg-gradient-to-r hover:from-blue-50/50 hover:to-indigo-50/30 hover:shadow-lg hover:scale-102'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
|
||||
)}
|
||||
<Icon className={`w-5 h-5 transition-transform duration-300 ${isActive ? 'text-white' : 'text-gray-600 group-hover:text-blue-600 group-hover:scale-110'}`} />
|
||||
<span className="relative z-10">{tab.label}</span>
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-cyan-300 via-blue-400 to-indigo-400"></div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="mt-4 sm:mt-6 md:mt-8 lg:mt-10 pt-4 sm:pt-6 md:pt-8 border-t border-gradient-to-r from-transparent via-blue-200/30 to-transparent">
|
||||
<div className="overflow-x-auto -mx-2 sm:-mx-3 px-2 sm:px-3 scrollbar-hide">
|
||||
<div className="flex gap-2 sm:gap-3 min-w-max sm:min-w-0 sm:flex-wrap">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
group relative flex items-center gap-1.5 sm:gap-2 md:gap-3 px-3 sm:px-4 md:px-6 py-2 sm:py-2.5 md:py-3.5 rounded-lg sm:rounded-xl font-semibold text-xs sm:text-sm flex-shrink-0
|
||||
transition-all duration-300 overflow-hidden
|
||||
${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-blue-500 via-blue-500 to-indigo-600 text-white shadow-xl shadow-blue-500/40 scale-105'
|
||||
: 'bg-white/80 text-gray-700 border border-gray-200/60 hover:border-blue-300/60 hover:bg-gradient-to-r hover:from-blue-50/50 hover:to-indigo-50/30 hover:shadow-lg hover:scale-102'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
|
||||
)}
|
||||
<Icon className={`w-3.5 h-3.5 sm:w-4 sm:h-4 md:w-5 md:h-5 transition-transform duration-300 flex-shrink-0 ${isActive ? 'text-white' : 'text-gray-600 group-hover:text-blue-600 group-hover:scale-110'}`} />
|
||||
<span className="relative z-10 whitespace-nowrap">{tab.label}</span>
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 sm:h-1 bg-gradient-to-r from-cyan-300 via-blue-400 to-indigo-400"></div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,108 +598,199 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
|
||||
{}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||
<div
|
||||
onClick={() => setActiveTab('reports')}
|
||||
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-blue-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-blue-300/60 overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-blue-400/10 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-400 to-blue-600 rounded-xl blur-md opacity-50 group-hover:opacity-75 transition-opacity"></div>
|
||||
<div className="relative p-3.5 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg border border-blue-400/50 group-hover:scale-110 transition-transform">
|
||||
<FileText className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-xl mb-1">Reports & Analytics</h3>
|
||||
<div className="h-1 w-12 bg-gradient-to-r from-blue-500 to-blue-600 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{comprehensiveLoading ? (
|
||||
<Loading fullScreen text="Loading analytics..." />
|
||||
) : comprehensiveData ? (
|
||||
<>
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
{comprehensiveData.revenue && (
|
||||
<>
|
||||
<KPICard
|
||||
title="RevPAR"
|
||||
value={formatCurrency(comprehensiveData.revenue.revpar.revpar)}
|
||||
icon={<TrendingUp className="w-6 h-6" />}
|
||||
color="blue"
|
||||
/>
|
||||
<KPICard
|
||||
title="ADR"
|
||||
value={formatCurrency(comprehensiveData.revenue.adr.adr)}
|
||||
icon={<DollarSign className="w-6 h-6" />}
|
||||
color="green"
|
||||
/>
|
||||
<KPICard
|
||||
title="Occupancy Rate"
|
||||
value={`${comprehensiveData.revenue.occupancy_rate.occupancy_rate.toFixed(1)}%`}
|
||||
icon={<Building2 className="w-6 h-6" />}
|
||||
color="orange"
|
||||
/>
|
||||
<KPICard
|
||||
title="Net Revenue"
|
||||
value={comprehensiveData.financial ? formatCurrency(comprehensiveData.financial.profit_loss.net_revenue) : 'N/A'}
|
||||
icon={<CreditCard className="w-6 h-6" />}
|
||||
color="purple"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
View comprehensive reports, revenue analytics, and booking statistics
|
||||
</p>
|
||||
<div className="pt-5 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 font-medium">View Reports</span>
|
||||
<ChevronRight className="w-5 h-5 text-blue-600 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-blue-50/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setActiveTab('audit-logs')}
|
||||
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-indigo-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-indigo-300/60 overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-indigo-400/10 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-indigo-400 to-indigo-600 rounded-xl blur-md opacity-50 group-hover:opacity-75 transition-opacity"></div>
|
||||
<div className="relative p-3.5 rounded-xl bg-gradient-to-br from-indigo-500 to-indigo-600 shadow-lg border border-indigo-400/50 group-hover:scale-110 transition-transform">
|
||||
<ClipboardList className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 md:gap-6">
|
||||
{comprehensiveData.revenue && comprehensiveData.revenue.market_penetration && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Market Penetration by Room Type</h3>
|
||||
<SimpleBarChart
|
||||
data={comprehensiveData.revenue.market_penetration.penetration_by_room_type.map((item, index) => ({
|
||||
label: item.room_type,
|
||||
value: item.market_share,
|
||||
}))}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-xl mb-1">Audit Logs</h3>
|
||||
<div className="h-1 w-12 bg-gradient-to-r from-indigo-500 to-indigo-600 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
Track system activity, user actions, and security events
|
||||
</p>
|
||||
<div className="pt-5 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 font-medium">View Logs</span>
|
||||
<ChevronRight className="w-5 h-5 text-indigo-600 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-indigo-50/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
onClick={() => setActiveTab('reviews')}
|
||||
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-amber-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-amber-300/60 overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-amber-400/10 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-amber-400 to-amber-600 rounded-xl blur-md opacity-50 group-hover:opacity-75 transition-opacity"></div>
|
||||
<div className="relative p-3.5 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 shadow-lg border border-amber-400/50 group-hover:scale-110 transition-transform">
|
||||
<Star className="w-6 h-6 text-white fill-white" />
|
||||
{comprehensiveData.financial && comprehensiveData.financial.payment_methods && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Payment Methods Distribution</h3>
|
||||
<SimplePieChart
|
||||
data={comprehensiveData.financial.payment_methods.payment_methods.map((item) => ({
|
||||
label: item.payment_method,
|
||||
value: item.percentage,
|
||||
}))}
|
||||
size={200}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Access Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||
<div
|
||||
onClick={() => setActiveTab('revenue')}
|
||||
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-green-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-green-300/60 overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-green-400/10 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-green-500 to-green-600 shadow-lg">
|
||||
<DollarSign className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-xl mb-1">Revenue Analytics</h3>
|
||||
<div className="h-1 w-12 bg-gradient-to-r from-green-500 to-green-600 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
RevPAR, ADR, Occupancy, Forecast, Market Penetration
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-xl mb-1">Reviews</h3>
|
||||
<div className="h-1 w-12 bg-gradient-to-r from-amber-500 to-amber-600 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setActiveTab('operational')}
|
||||
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-orange-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-orange-300/60 overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-orange-400/10 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 shadow-lg">
|
||||
<Activity className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-xl mb-1">Operational Analytics</h3>
|
||||
<div className="h-1 w-12 bg-gradient-to-r from-orange-500 to-orange-600 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
Staff Performance, Service Usage, Efficiency Metrics
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setActiveTab('guest')}
|
||||
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-purple-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-purple-300/60 overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-purple-400/10 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 shadow-lg">
|
||||
<Users className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-xl mb-1">Guest Analytics</h3>
|
||||
<div className="h-1 w-12 bg-gradient-to-r from-purple-500 to-purple-600 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
LTV, CAC, Repeat Rate, Satisfaction Trends
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
Manage customer reviews and ratings
|
||||
</p>
|
||||
<div className="pt-5 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 font-medium">View Reviews</span>
|
||||
<ChevronRight className="w-5 h-5 text-amber-600 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<EmptyState
|
||||
title="No Analytics Data"
|
||||
description="Click refresh to load analytics data"
|
||||
action={{
|
||||
label: 'Refresh',
|
||||
onClick: fetchComprehensive
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-amber-50/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revenue Analytics Tab */}
|
||||
{activeTab === 'revenue' && (
|
||||
<RevenueAnalyticsView
|
||||
revparData={revparData}
|
||||
adrData={adrData}
|
||||
occupancyData={occupancyData}
|
||||
forecastData={forecastData}
|
||||
marketPenetrationData={marketPenetrationData}
|
||||
formatCurrency={formatCurrency}
|
||||
loading={revparLoading || adrLoading || occupancyLoading || forecastLoading || marketLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Operational Analytics Tab */}
|
||||
{activeTab === 'operational' && (
|
||||
<OperationalAnalyticsView
|
||||
staffPerformanceData={staffPerformanceData}
|
||||
serviceUsageData={serviceUsageData}
|
||||
efficiencyData={efficiencyData}
|
||||
formatCurrency={formatCurrency}
|
||||
loading={staffLoading || serviceLoading || efficiencyLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Guest Analytics Tab */}
|
||||
{activeTab === 'guest' && (
|
||||
<GuestAnalyticsView
|
||||
ltvData={ltvData}
|
||||
repeatRateData={repeatRateData}
|
||||
satisfactionData={satisfactionData}
|
||||
formatCurrency={formatCurrency}
|
||||
loading={ltvLoading || repeatLoading || satisfactionLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Financial Analytics Tab */}
|
||||
{activeTab === 'financial' && (
|
||||
<FinancialAnalyticsView
|
||||
profitLossData={profitLossData}
|
||||
paymentMethodData={paymentMethodData}
|
||||
refundData={refundData}
|
||||
formatCurrency={formatCurrency}
|
||||
loading={profitLossLoading || paymentMethodLoading || refundLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{}
|
||||
{activeTab === 'reports' && (
|
||||
<div className="space-y-8">
|
||||
@@ -1199,10 +1544,265 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Report Builder Modal */}
|
||||
{showReportBuilder && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<CustomReportBuilder onClose={() => setShowReportBuilder(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Revenue Analytics View Component
|
||||
const RevenueAnalyticsView: React.FC<{
|
||||
revparData?: RevPARData;
|
||||
adrData?: ADRData;
|
||||
occupancyData?: OccupancyRateData;
|
||||
forecastData?: RevenueForecastData;
|
||||
marketPenetrationData?: MarketPenetrationData;
|
||||
formatCurrency: (amount: number) => string;
|
||||
loading: boolean;
|
||||
}> = ({ revparData, adrData, occupancyData, forecastData, marketPenetrationData, formatCurrency, loading }) => {
|
||||
if (loading) {
|
||||
return <Loading fullScreen text="Loading revenue analytics..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{revparData && (
|
||||
<KPICard
|
||||
title="RevPAR"
|
||||
value={formatCurrency(revparData.revpar)}
|
||||
subtitle={`${revparData.period_days} days`}
|
||||
icon={<TrendingUp className="w-6 h-6" />}
|
||||
color="blue"
|
||||
/>
|
||||
)}
|
||||
{adrData && (
|
||||
<KPICard
|
||||
title="ADR"
|
||||
value={formatCurrency(adrData.adr)}
|
||||
icon={<DollarSign className="w-6 h-6" />}
|
||||
color="green"
|
||||
/>
|
||||
)}
|
||||
{occupancyData && (
|
||||
<KPICard
|
||||
title="Occupancy Rate"
|
||||
value={`${occupancyData.occupancy_rate.toFixed(1)}%`}
|
||||
subtitle={`${occupancyData.occupied_room_nights} / ${occupancyData.available_room_nights} nights`}
|
||||
icon={<Building2 className="w-6 h-6" />}
|
||||
color="orange"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{forecastData && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Revenue Forecast (Next 30 Days)</h3>
|
||||
<SimpleLineChart
|
||||
data={forecastData.forecast.slice(0, 30).map(item => ({
|
||||
label: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
value: item.forecasted_revenue,
|
||||
}))}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{marketPenetrationData && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Market Penetration by Room Type</h3>
|
||||
<SimpleBarChart
|
||||
data={marketPenetrationData.penetration_by_room_type.map((item) => ({
|
||||
label: item.room_type,
|
||||
value: item.market_share,
|
||||
}))}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Operational Analytics View Component
|
||||
const OperationalAnalyticsView: React.FC<{
|
||||
staffPerformanceData?: StaffPerformanceData;
|
||||
serviceUsageData?: ServiceUsageData;
|
||||
efficiencyData?: OperationalEfficiencyData;
|
||||
formatCurrency: (amount: number) => string;
|
||||
loading: boolean;
|
||||
}> = ({ staffPerformanceData, serviceUsageData, efficiencyData, formatCurrency, loading }) => {
|
||||
if (loading) {
|
||||
return <Loading fullScreen text="Loading operational analytics..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{efficiencyData && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<KPICard
|
||||
title="Conversion Rate"
|
||||
value={`${efficiencyData.conversion_rate.toFixed(1)}%`}
|
||||
icon={<Target className="w-6 h-6" />}
|
||||
color="blue"
|
||||
/>
|
||||
<KPICard
|
||||
title="Avg Booking Value"
|
||||
value={formatCurrency(efficiencyData.average_booking_value)}
|
||||
icon={<DollarSign className="w-6 h-6" />}
|
||||
color="green"
|
||||
/>
|
||||
<KPICard
|
||||
title="Cancellation Rate"
|
||||
value={`${efficiencyData.cancellation_rate.toFixed(1)}%`}
|
||||
icon={<Activity className="w-6 h-6" />}
|
||||
color="red"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serviceUsageData && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top Services by Revenue</h3>
|
||||
<SimpleBarChart
|
||||
data={serviceUsageData.services.slice(0, 10).map(item => ({
|
||||
label: item.service_name,
|
||||
value: item.total_revenue,
|
||||
}))}
|
||||
height={250}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Guest Analytics View Component
|
||||
const GuestAnalyticsView: React.FC<{
|
||||
ltvData?: GuestLTVData;
|
||||
repeatRateData?: RepeatGuestRateData;
|
||||
satisfactionData?: GuestSatisfactionTrendsData;
|
||||
formatCurrency: (amount: number) => string;
|
||||
loading: boolean;
|
||||
}> = ({ ltvData, repeatRateData, satisfactionData, formatCurrency, loading }) => {
|
||||
if (loading) {
|
||||
return <Loading fullScreen text="Loading guest analytics..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{repeatRateData && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<KPICard
|
||||
title="Repeat Guest Rate"
|
||||
value={`${repeatRateData.repeat_guest_rate.toFixed(1)}%`}
|
||||
subtitle={`${repeatRateData.repeat_guests} repeat / ${repeatRateData.total_guests} total`}
|
||||
icon={<Users className="w-6 h-6" />}
|
||||
color="purple"
|
||||
/>
|
||||
{ltvData && (
|
||||
<KPICard
|
||||
title="Average LTV"
|
||||
value={formatCurrency(ltvData.average_ltv)}
|
||||
subtitle={`${ltvData.total_guests_analyzed} guests`}
|
||||
icon={<Award className="w-6 h-6" />}
|
||||
color="blue"
|
||||
/>
|
||||
)}
|
||||
{satisfactionData && (
|
||||
<KPICard
|
||||
title="Avg Satisfaction"
|
||||
value={`${satisfactionData.overall_average_rating.toFixed(1)}/5`}
|
||||
subtitle={`${satisfactionData.total_reviews} reviews`}
|
||||
icon={<Star className="w-6 h-6" />}
|
||||
color="orange"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{satisfactionData && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Satisfaction Trends</h3>
|
||||
<SimpleLineChart
|
||||
data={satisfactionData.trends.map(item => ({
|
||||
label: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
value: item.average_rating,
|
||||
}))}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Financial Analytics View Component
|
||||
const FinancialAnalyticsView: React.FC<{
|
||||
profitLossData?: ProfitLossData;
|
||||
paymentMethodData?: PaymentMethodAnalyticsData;
|
||||
refundData?: RefundAnalysisData;
|
||||
formatCurrency: (amount: number) => string;
|
||||
loading: boolean;
|
||||
}> = ({ profitLossData, paymentMethodData, refundData, formatCurrency, loading }) => {
|
||||
if (loading) {
|
||||
return <Loading fullScreen text="Loading financial analytics..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{profitLossData && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<KPICard
|
||||
title="Total Revenue"
|
||||
value={formatCurrency(profitLossData.total_revenue)}
|
||||
icon={<DollarSign className="w-6 h-6" />}
|
||||
color="green"
|
||||
/>
|
||||
<KPICard
|
||||
title="Refunds"
|
||||
value={formatCurrency(profitLossData.refunds)}
|
||||
icon={<Download className="w-6 h-6" />}
|
||||
color="red"
|
||||
/>
|
||||
<KPICard
|
||||
title="Net Revenue"
|
||||
value={formatCurrency(profitLossData.net_revenue)}
|
||||
icon={<TrendingUp className="w-6 h-6" />}
|
||||
color="blue"
|
||||
/>
|
||||
<KPICard
|
||||
title="Gross Profit"
|
||||
value={formatCurrency(profitLossData.gross_profit)}
|
||||
icon={<Award className="w-6 h-6" />}
|
||||
color="purple"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentMethodData && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Payment Methods Distribution</h3>
|
||||
<SimplePieChart
|
||||
data={paymentMethodData.payment_methods.map((item) => ({
|
||||
label: item.payment_method,
|
||||
value: item.percentage,
|
||||
}))}
|
||||
size={250}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsDashboardPage;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import Pagination from '../../components/common/Pagination';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../utils/format';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CreateBookingModal from '../../components/admin/CreateBookingModal';
|
||||
import CreateBookingModal from '../../components/shared/CreateBookingModal';
|
||||
|
||||
const BookingManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -157,31 +157,31 @@ const BookingManagementPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
<div className="space-y-4 sm:space-y-6 md:space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-3 sm:-m-4 md:-m-6 p-4 sm:p-6 md:p-8">
|
||||
{/* Header with Create Button */}
|
||||
<div className="animate-fade-in">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-3xl sm:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-2">
|
||||
<div className="h-1 w-12 sm:w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-xl sm:text-2xl md:text-2xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
Booking Management
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-3 text-base sm:text-lg font-light">Manage and track all hotel bookings with precision</p>
|
||||
<p className="text-slate-600 mt-2 sm:mt-3 text-xs sm:text-sm md:text-sm font-light">Manage and track all hotel bookings with precision</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl whitespace-nowrap w-full sm:w-auto"
|
||||
className="flex items-center justify-center gap-2 px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg sm:rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl whitespace-nowrap w-full sm:w-auto text-xs sm:text-sm"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<Plus className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
Create Booking
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
|
||||
@@ -209,7 +209,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
@@ -368,7 +368,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-amber-100 mb-1">Booking Details</h2>
|
||||
<h2 className="text-xl sm:text-2xl md:text-2xl font-bold text-amber-100 mb-1">Booking Details</h2>
|
||||
<p className="text-amber-200/80 text-sm font-light">Comprehensive booking information</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -387,7 +387,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Booking Number</label>
|
||||
<p className="text-xl font-bold text-slate-900 font-mono">{selectedBooking.booking_number}</p>
|
||||
<p className="text-base sm:text-lg md:text-lg font-bold text-slate-900 font-mono">{selectedBooking.booking_number}</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Status</label>
|
||||
@@ -562,7 +562,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-green-50 via-emerald-50 to-green-50 p-6 rounded-xl border-2 border-green-200 shadow-lg mb-4">
|
||||
<label className="text-xs font-semibold text-green-700 uppercase tracking-wider mb-2 block">Amount Paid</label>
|
||||
<p className="text-3xl font-bold bg-gradient-to-r from-green-600 via-emerald-700 to-green-600 bg-clip-text text-transparent">
|
||||
<p className="text-xl sm:text-2xl md:text-2xl font-bold bg-gradient-to-r from-green-600 via-emerald-700 to-green-600 bg-clip-text text-transparent">
|
||||
{formatCurrency(amountPaid)}
|
||||
</p>
|
||||
{hasPayments && completedPayments.length > 0 && (
|
||||
@@ -584,7 +584,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
{remainingDue > 0 && (
|
||||
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg mb-4">
|
||||
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Remaining Due (To be paid)</label>
|
||||
<p className="text-3xl font-bold text-amber-600">
|
||||
<p className="text-xl sm:text-2xl md:text-2xl font-bold text-amber-600">
|
||||
{formatCurrency(remainingDue)}
|
||||
</p>
|
||||
{selectedBooking.total_price > 0 && (
|
||||
@@ -598,7 +598,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-slate-50 to-gray-50 p-6 rounded-xl border-2 border-slate-200 shadow-lg">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2 block">Total Booking Price</label>
|
||||
<p className="text-2xl font-bold text-slate-700">
|
||||
<p className="text-lg sm:text-xl md:text-xl font-bold text-slate-700">
|
||||
{formatCurrency(selectedBooking.total_price)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
|
||||
@@ -129,6 +129,9 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
if (response.status === 'success' && response.data) {
|
||||
let invoiceList = response.data.invoices || [];
|
||||
|
||||
// Client-side filtering for search (only on current page results)
|
||||
// Note: This is a limitation - search only works on current page
|
||||
// For full search functionality, backend needs to support search parameter
|
||||
if (invoiceFilters.search) {
|
||||
invoiceList = invoiceList.filter((inv) =>
|
||||
inv.invoice_number.toLowerCase().includes(invoiceFilters.search.toLowerCase()) ||
|
||||
@@ -138,8 +141,15 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
}
|
||||
|
||||
setInvoices(invoiceList);
|
||||
setInvoicesTotalPages(response.data.total_pages || 1);
|
||||
setInvoicesTotalItems(response.data.total || 0);
|
||||
// Only update pagination if not searching (to avoid incorrect counts)
|
||||
if (!invoiceFilters.search) {
|
||||
setInvoicesTotalPages(response.data.total_pages || 1);
|
||||
setInvoicesTotalItems(response.data.total || 0);
|
||||
} else {
|
||||
// When searching, keep original pagination but show filtered count
|
||||
setInvoicesTotalPages(response.data.total_pages || 1);
|
||||
setInvoicesTotalItems(response.data.total || 0);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load invoices');
|
||||
@@ -354,28 +364,28 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-10 animate-fade-in">
|
||||
<div className="max-w-7xl mx-auto px-2 sm:px-3 md:px-4 lg:px-6 xl:px-8 py-2 sm:py-4 md:py-6 lg:py-8 space-y-3 sm:space-y-4 md:space-y-6 lg:space-y-8 animate-fade-in">
|
||||
{}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-400/5 via-transparent to-purple-600/5 rounded-3xl blur-3xl"></div>
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-emerald-200/30 p-8 md:p-10">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-8">
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-400 to-purple-600 rounded-2xl blur-lg opacity-50"></div>
|
||||
<div className="relative p-4 rounded-2xl bg-gradient-to-br from-emerald-500 via-emerald-500 to-purple-600 shadow-xl border border-emerald-400/50">
|
||||
<FileText className="w-8 h-8 text-white" />
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-gradient-to-br from-green-300 to-emerald-500 rounded-full shadow-lg animate-pulse"></div>
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-xl sm:rounded-2xl md:rounded-3xl shadow-2xl border border-emerald-200/30 p-3 sm:p-4 md:p-6 lg:p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 sm:gap-6 md:gap-8">
|
||||
<div className="flex items-start gap-3 sm:gap-4 md:gap-5">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-400 to-purple-600 rounded-xl sm:rounded-2xl blur-lg opacity-50"></div>
|
||||
<div className="relative p-2.5 sm:p-3 md:p-4 rounded-xl sm:rounded-2xl bg-gradient-to-br from-emerald-500 via-emerald-500 to-purple-600 shadow-xl border border-emerald-400/50">
|
||||
<FileText className="w-5 h-5 sm:w-6 sm:h-6 md:w-8 md:h-8 text-white" />
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3 sm:w-4 sm:h-4 bg-gradient-to-br from-green-300 to-emerald-500 rounded-full shadow-lg animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold bg-gradient-to-r from-slate-900 via-emerald-700 to-slate-900 bg-clip-text text-transparent">
|
||||
<div className="space-y-2 sm:space-y-3 flex-1">
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-3xl font-extrabold bg-gradient-to-r from-slate-900 via-emerald-700 to-slate-900 bg-clip-text text-transparent">
|
||||
Business Dashboard
|
||||
</h1>
|
||||
<Sparkles className="w-6 h-6 text-emerald-500 animate-pulse" />
|
||||
<Sparkles className="w-4 h-4 sm:w-5 sm:h-5 text-emerald-500 animate-pulse" />
|
||||
</div>
|
||||
<p className="text-gray-600 text-base md:text-lg max-w-2xl leading-relaxed">
|
||||
<p className="text-gray-600 text-xs sm:text-sm md:text-sm max-w-2xl leading-relaxed">
|
||||
Manage invoices, payments, and promotional campaigns
|
||||
</p>
|
||||
</div>
|
||||
@@ -383,36 +393,38 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="mt-10 pt-8 border-t border-gradient-to-r from-transparent via-emerald-200/30 to-transparent">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
group relative flex items-center gap-3 px-6 py-3.5 rounded-xl font-semibold text-sm
|
||||
transition-all duration-300 overflow-hidden
|
||||
${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-emerald-500 via-emerald-500 to-purple-600 text-white shadow-xl shadow-emerald-500/40 scale-105'
|
||||
: 'bg-white/80 text-gray-700 border border-gray-200/60 hover:border-emerald-300/60 hover:bg-gradient-to-r hover:from-emerald-50/50 hover:to-purple-50/30 hover:shadow-lg hover:scale-102'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
|
||||
)}
|
||||
<Icon className={`w-5 h-5 transition-transform duration-300 ${isActive ? 'text-white' : 'text-gray-600 group-hover:text-emerald-600 group-hover:scale-110'}`} />
|
||||
<span className="relative z-10">{tab.label}</span>
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-300 via-emerald-400 to-purple-400"></div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="mt-4 sm:mt-6 md:mt-8 lg:mt-10 pt-4 sm:pt-6 md:pt-8 border-t border-gradient-to-r from-transparent via-emerald-200/30 to-transparent">
|
||||
<div className="overflow-x-auto -mx-2 sm:-mx-3 px-2 sm:px-3 scrollbar-hide">
|
||||
<div className="flex gap-2 sm:gap-3 min-w-max sm:min-w-0 sm:flex-wrap">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
group relative flex items-center gap-1.5 sm:gap-2 md:gap-3 px-3 sm:px-4 md:px-6 py-2 sm:py-2.5 md:py-3.5 rounded-lg sm:rounded-xl font-semibold text-xs sm:text-sm flex-shrink-0
|
||||
transition-all duration-300 overflow-hidden
|
||||
${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-emerald-500 via-emerald-500 to-purple-600 text-white shadow-xl shadow-emerald-500/40 scale-105'
|
||||
: 'bg-white/80 text-gray-700 border border-gray-200/60 hover:border-emerald-300/60 hover:bg-gradient-to-r hover:from-emerald-50/50 hover:to-purple-50/30 hover:shadow-lg hover:scale-102'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
|
||||
)}
|
||||
<Icon className={`w-3.5 h-3.5 sm:w-4 sm:h-4 md:w-5 md:h-5 transition-transform duration-300 flex-shrink-0 ${isActive ? 'text-white' : 'text-gray-600 group-hover:text-emerald-600 group-hover:scale-110'}`} />
|
||||
<span className="relative z-10 whitespace-nowrap">{tab.label}</span>
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 sm:h-1 bg-gradient-to-r from-green-300 via-emerald-400 to-purple-400"></div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -420,7 +432,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
|
||||
{}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5 md:gap-6 lg:gap-8">
|
||||
<div
|
||||
onClick={() => setActiveTab('invoices')}
|
||||
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-blue-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-blue-300/60 overflow-hidden"
|
||||
|
||||
@@ -134,51 +134,51 @@ const DashboardPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
<div className="space-y-3 sm:space-y-4 md:space-y-6 lg:space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen max-w-full overflow-x-hidden">
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-6 animate-fade-in">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 sm:gap-6 animate-fade-in">
|
||||
<div className="w-full lg:w-auto">
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-2">
|
||||
<div className="h-1 w-12 sm:w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-xl sm:text-2xl md:text-2xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
Dashboard
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-3 text-lg font-light">Hotel operations overview and analytics</p>
|
||||
<p className="text-slate-600 mt-2 sm:mt-3 text-xs sm:text-sm md:text-sm font-light">Hotel operations overview and analytics</p>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
|
||||
<div className="flex flex-col xs:flex-row gap-2 sm:gap-3 w-full sm:w-auto">
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.from}
|
||||
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
|
||||
className="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
|
||||
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 sm:py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
|
||||
/>
|
||||
<span className="text-slate-500 font-medium">to</span>
|
||||
<span className="hidden xs:inline-flex items-center text-slate-500 font-medium">to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
className="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
|
||||
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 sm:py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex gap-2 sm:gap-3 items-center w-full sm:w-auto">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-sm"
|
||||
className="flex-1 sm:flex-none px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-xs sm:text-sm"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw className={`w-3 h-3 sm:w-4 sm:h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-6 py-2.5 bg-gradient-to-r from-rose-500 to-rose-600 text-white rounded-xl font-semibold hover:from-rose-600 hover:to-rose-700 transition-all duration-200 shadow-lg hover:shadow-xl flex items-center gap-2 text-sm"
|
||||
className="flex-1 sm:flex-none px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-rose-500 to-rose-600 text-white rounded-xl font-semibold hover:from-rose-600 hover:to-rose-700 transition-all duration-200 shadow-lg hover:shadow-xl flex items-center justify-center gap-2 text-xs sm:text-sm"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<LogOut className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="hidden sm:inline">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -186,82 +186,82 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-5 md:gap-6">
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-emerald-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-emerald-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Total Revenue</p>
|
||||
<p className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1 sm:mb-2">Total Revenue</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent truncate">
|
||||
{formatCurrency(stats?.total_revenue || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-emerald-100 to-emerald-200 p-4 rounded-2xl shadow-lg">
|
||||
<CurrencyIcon className="text-emerald-600" size={28} />
|
||||
<div className="bg-gradient-to-br from-emerald-100 to-emerald-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
|
||||
<CurrencyIcon className="text-emerald-600" size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-500 mr-2" />
|
||||
<span className="text-emerald-600 font-semibold text-sm">Active</span>
|
||||
<span className="text-slate-500 ml-2 text-sm">All time revenue</span>
|
||||
<div className="flex items-center mt-4 sm:mt-5 pt-3 sm:pt-4 border-t border-slate-100">
|
||||
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4 text-emerald-500 mr-1 sm:mr-2 flex-shrink-0" />
|
||||
<span className="text-emerald-600 font-semibold text-xs sm:text-sm">Active</span>
|
||||
<span className="text-slate-500 ml-1 sm:ml-2 text-xs sm:text-sm truncate">All time revenue</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-blue-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-blue-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Total Bookings</p>
|
||||
<p className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-blue-700 bg-clip-text text-transparent">
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1 sm:mb-2">Total Bookings</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold bg-gradient-to-r from-blue-600 to-blue-700 bg-clip-text text-transparent">
|
||||
{stats?.total_bookings || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-blue-100 to-blue-200 p-4 rounded-2xl shadow-lg">
|
||||
<Calendar className="w-7 h-7 text-blue-600" />
|
||||
<div className="bg-gradient-to-br from-blue-100 to-blue-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
|
||||
<Calendar className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
|
||||
<span className="text-slate-500 text-sm">
|
||||
<div className="flex items-center mt-4 sm:mt-5 pt-3 sm:pt-4 border-t border-slate-100">
|
||||
<span className="text-slate-500 text-xs sm:text-sm truncate">
|
||||
{stats.total_bookings > 0 ? 'Total bookings recorded' : 'No bookings yet'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-purple-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-purple-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Available Rooms</p>
|
||||
<p className="text-3xl font-bold bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent">
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1 sm:mb-2">Available Rooms</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent">
|
||||
{stats?.available_rooms || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-purple-100 to-purple-200 p-4 rounded-2xl shadow-lg">
|
||||
<Hotel className="w-7 h-7 text-purple-600" />
|
||||
<div className="bg-gradient-to-br from-purple-100 to-purple-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
|
||||
<Hotel className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
|
||||
<span className="text-slate-500 text-sm">
|
||||
<div className="flex items-center mt-4 sm:mt-5 pt-3 sm:pt-4 border-t border-slate-100">
|
||||
<span className="text-slate-500 text-xs sm:text-sm truncate">
|
||||
{stats?.occupied_rooms || 0} rooms in use
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-amber-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.4s' }}>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-amber-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.4s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Customers</p>
|
||||
<p className="text-3xl font-bold bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1 sm:mb-2">Customers</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">
|
||||
{stats?.total_customers || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-amber-100 to-amber-200 p-4 rounded-2xl shadow-lg">
|
||||
<Users className="w-7 h-7 text-amber-600" />
|
||||
<div className="bg-gradient-to-br from-amber-100 to-amber-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
|
||||
<Users className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
|
||||
<span className="text-slate-500 text-sm">
|
||||
<div className="flex items-center mt-4 sm:mt-5 pt-3 sm:pt-4 border-t border-slate-100">
|
||||
<span className="text-slate-500 text-xs sm:text-sm truncate">
|
||||
Unique customers with bookings
|
||||
</span>
|
||||
</div>
|
||||
@@ -269,35 +269,35 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 md:gap-6">
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
|
||||
<h2 className="text-xl font-bold text-slate-900">Daily Revenue</h2>
|
||||
<div className="p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-xl shadow-md">
|
||||
<BarChart3 className="w-5 h-5 text-blue-600" />
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
|
||||
<h2 className="text-sm sm:text-base md:text-base font-bold text-slate-900">Daily Revenue</h2>
|
||||
<div className="p-2 sm:p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-lg sm:rounded-xl shadow-md flex-shrink-0">
|
||||
<BarChart3 className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
{stats?.revenue_by_date && stats.revenue_by_date.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{stats.revenue_by_date.slice(0, 7).map((item, index) => {
|
||||
const maxRevenue = Math.max(...stats.revenue_by_date!.map(r => r.revenue));
|
||||
return (
|
||||
<div key={index} className="flex items-center py-2">
|
||||
<span className="text-sm text-slate-600 w-24 font-medium">
|
||||
<div key={index} className="flex items-center py-1.5 sm:py-2">
|
||||
<span className="text-xs sm:text-sm text-slate-600 w-16 sm:w-20 md:w-24 font-medium flex-shrink-0">
|
||||
{formatDate(item.date, 'short')}
|
||||
</span>
|
||||
<div className="flex-1 mx-4">
|
||||
<div className="bg-slate-200 rounded-full h-5 overflow-hidden shadow-inner">
|
||||
<div className="flex-1 mx-2 sm:mx-3 md:mx-4 min-w-0">
|
||||
<div className="bg-slate-200 rounded-full h-4 sm:h-5 overflow-hidden shadow-inner">
|
||||
<div
|
||||
className="bg-gradient-to-r from-emerald-500 to-emerald-600 h-5 rounded-full transition-all shadow-md"
|
||||
className="bg-gradient-to-r from-emerald-500 to-emerald-600 h-4 sm:h-5 rounded-full transition-all shadow-md"
|
||||
style={{
|
||||
width: `${Math.min((item.revenue / (maxRevenue || 1)) * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-slate-900 w-32 text-right bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||
<span className="text-xs sm:text-sm font-bold text-slate-900 w-20 sm:w-28 md:w-32 text-right bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent flex-shrink-0">
|
||||
{formatCurrency(item.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -313,12 +313,12 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
|
||||
<h2 className="text-xl font-bold text-slate-900">Booking Status</h2>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
|
||||
<h2 className="text-sm sm:text-base md:text-base font-bold text-slate-900">Booking Status</h2>
|
||||
</div>
|
||||
{stats?.bookings_by_status && Object.keys(stats.bookings_by_status).length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{Object.entries(stats.bookings_by_status)
|
||||
.filter(([_, count]) => count > 0)
|
||||
.map(([status, count]) => {
|
||||
@@ -337,12 +337,12 @@ const DashboardPage: React.FC = () => {
|
||||
cancelled: '❌ Canceled',
|
||||
};
|
||||
return (
|
||||
<div key={status} className="flex items-center justify-between p-3 bg-gradient-to-r from-slate-50 to-white rounded-xl hover:shadow-md transition-all duration-200 border border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-4 h-4 rounded-full shadow-md ${statusColors[status] || 'bg-slate-500'}`} />
|
||||
<span className="text-slate-700 font-medium">{statusLabels[status] || status}</span>
|
||||
<div key={status} className="flex items-center justify-between p-2.5 sm:p-3 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl hover:shadow-md transition-all duration-200 border border-slate-100">
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
|
||||
<div className={`w-3 h-3 sm:w-4 sm:h-4 rounded-full shadow-md flex-shrink-0 ${statusColors[status] || 'bg-slate-500'}`} />
|
||||
<span className="text-xs sm:text-sm md:text-base text-slate-700 font-medium truncate">{statusLabels[status] || status}</span>
|
||||
</div>
|
||||
<span className="font-bold text-slate-900 text-lg">{count}</span>
|
||||
<span className="font-bold text-slate-900 text-sm sm:text-base flex-shrink-0 ml-2">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -357,29 +357,29 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5 md:gap-6">
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
|
||||
<h2 className="text-xl font-bold text-slate-900">Top Booked Rooms</h2>
|
||||
<div className="p-2 bg-gradient-to-br from-amber-100 to-amber-200 rounded-xl">
|
||||
<Hotel className="w-5 h-5 text-amber-600" />
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
|
||||
<h2 className="text-sm sm:text-base md:text-base font-bold text-slate-900">Top Booked Rooms</h2>
|
||||
<div className="p-2 bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg sm:rounded-xl flex-shrink-0">
|
||||
<Hotel className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
{stats?.top_rooms && stats.top_rooms.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{stats.top_rooms.map((room, index) => (
|
||||
<div key={room.room_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-slate-50 to-white rounded-xl hover:from-amber-50 hover:to-yellow-50 transition-all duration-300 border border-slate-200 hover:border-amber-300 hover:shadow-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-amber-500 to-amber-600 text-white rounded-xl font-bold shadow-lg shadow-amber-500/40 text-lg">
|
||||
<div key={room.room_id} className="flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl hover:from-amber-50 hover:to-yellow-50 transition-all duration-300 border border-slate-200 hover:border-amber-300 hover:shadow-lg">
|
||||
<div className="flex items-center gap-2 sm:gap-3 md:gap-4 min-w-0 flex-1">
|
||||
<span className="flex items-center justify-center w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-amber-500 to-amber-600 text-white rounded-lg sm:rounded-xl font-bold shadow-lg shadow-amber-500/40 text-base sm:text-lg flex-shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">Room {room.room_number}</p>
|
||||
<p className="text-sm text-slate-500">{room.bookings} booking{room.bookings !== 1 ? 's' : ''}</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-slate-900 text-sm sm:text-base truncate">Room {room.room_number}</p>
|
||||
<p className="text-xs sm:text-sm text-slate-500">{room.bookings} booking{room.bookings !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-bold text-emerald-600 bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||
<span className="font-bold text-emerald-600 bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent text-sm sm:text-base flex-shrink-0 ml-2">
|
||||
{formatCurrency(room.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -394,22 +394,22 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
|
||||
<h2 className="text-xl font-bold text-slate-900">Services Used</h2>
|
||||
<div className="p-2 bg-gradient-to-br from-purple-100 to-purple-200 rounded-xl">
|
||||
<BarChart3 className="w-5 h-5 text-purple-600" />
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
|
||||
<h2 className="text-sm sm:text-base md:text-base font-bold text-slate-900">Services Used</h2>
|
||||
<div className="p-2 bg-gradient-to-br from-purple-100 to-purple-200 rounded-lg sm:rounded-xl flex-shrink-0">
|
||||
<BarChart3 className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
{stats?.service_usage && stats.service_usage.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{stats.service_usage.map((service) => (
|
||||
<div key={service.service_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-slate-50 to-white rounded-xl hover:from-purple-50 hover:to-indigo-50 transition-all duration-300 border border-slate-200 hover:border-purple-300 hover:shadow-lg">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{service.service_name}</p>
|
||||
<p className="text-sm text-slate-500">{service.usage_count} time{service.usage_count !== 1 ? 's' : ''} used</p>
|
||||
<div key={service.service_id} className="flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl hover:from-purple-50 hover:to-indigo-50 transition-all duration-300 border border-slate-200 hover:border-purple-300 hover:shadow-lg">
|
||||
<div className="min-w-0 flex-1 pr-2">
|
||||
<p className="font-semibold text-slate-900 text-sm sm:text-base truncate">{service.service_name}</p>
|
||||
<p className="text-xs sm:text-sm text-slate-500">{service.usage_count} time{service.usage_count !== 1 ? 's' : ''} used</p>
|
||||
</div>
|
||||
<span className="font-bold text-purple-600 bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent">
|
||||
<span className="font-bold text-purple-600 bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent text-sm sm:text-base flex-shrink-0">
|
||||
{formatCurrency(service.total_revenue)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -424,12 +424,12 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
|
||||
<h2 className="text-xl font-bold text-slate-900">Recent Payments</h2>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
|
||||
<h2 className="text-sm sm:text-base md:text-base font-bold text-slate-900">Recent Payments</h2>
|
||||
<button
|
||||
onClick={() => navigate('/admin/payments')}
|
||||
className="text-sm text-amber-600 hover:text-amber-700 font-semibold hover:underline transition-colors"
|
||||
className="text-xs sm:text-sm text-amber-600 hover:text-amber-700 font-semibold hover:underline transition-colors flex-shrink-0 ml-2"
|
||||
>
|
||||
View All →
|
||||
</button>
|
||||
@@ -439,23 +439,23 @@ const DashboardPage: React.FC = () => {
|
||||
<Loading text="Loading payments..." />
|
||||
</div>
|
||||
) : recentPayments && recentPayments.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{recentPayments.map((payment) => (
|
||||
<div
|
||||
key={payment.id}
|
||||
className="flex items-center justify-between p-4 bg-gradient-to-r from-slate-50 to-white rounded-xl hover:from-amber-50 hover:to-yellow-50 border border-slate-200 hover:border-amber-300 hover:shadow-lg cursor-pointer transition-all duration-200"
|
||||
className="flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl hover:from-amber-50 hover:to-yellow-50 border border-slate-200 hover:border-amber-300 hover:shadow-lg cursor-pointer transition-all duration-200"
|
||||
onClick={() => navigate(`/admin/payments`)}
|
||||
>
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<div className="p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-xl shadow-md">
|
||||
<CreditCard className="w-5 h-5 text-blue-600" />
|
||||
<div className="flex items-center space-x-2 sm:space-x-3 md:space-x-4 flex-1 min-w-0">
|
||||
<div className="p-2 sm:p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-lg sm:rounded-xl shadow-md flex-shrink-0">
|
||||
<CreditCard className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-slate-900 truncate text-lg">
|
||||
<p className="font-bold text-slate-900 truncate text-xs sm:text-sm md:text-base">
|
||||
{formatCurrency(payment.amount)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-sm text-slate-600 font-medium">
|
||||
<div className="flex items-center gap-1 sm:gap-2 mt-1 flex-wrap">
|
||||
<p className="text-xs sm:text-sm text-slate-600 font-medium">
|
||||
{getPaymentMethodLabel(payment.payment_method)}
|
||||
</p>
|
||||
{payment.payment_date && (
|
||||
@@ -466,7 +466,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1.5 text-xs font-semibold rounded-full border shadow-sm ${getPaymentStatusColor(payment.payment_status)}`}>
|
||||
<span className={`px-2 sm:px-3 py-1 sm:py-1.5 text-xs font-semibold rounded-full border shadow-sm flex-shrink-0 ml-2 ${getPaymentStatusColor(payment.payment_status)}`}>
|
||||
{payment.payment_status.charAt(0).toUpperCase() + payment.payment_status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
947
Frontend/src/pages/admin/EmailCampaignManagementPage.tsx
Normal file
947
Frontend/src/pages/admin/EmailCampaignManagementPage.tsx
Normal file
@@ -0,0 +1,947 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Mail,
|
||||
Plus,
|
||||
Send,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Users,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Filter,
|
||||
Search,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Play,
|
||||
Pause,
|
||||
RefreshCw,
|
||||
X,
|
||||
Save,
|
||||
Layers,
|
||||
Target
|
||||
} from 'lucide-react';
|
||||
import { emailCampaignService, Campaign, CampaignSegment, EmailTemplate, DripSequence, CampaignAnalytics } from '../../services/api/emailCampaignService';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import { formatDate } from '../../utils/format';
|
||||
|
||||
type CampaignTab = 'campaigns' | 'segments' | 'templates' | 'drip-sequences' | 'analytics';
|
||||
|
||||
const EmailCampaignManagementPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<CampaignTab>('campaigns');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||
const [segments, setSegments] = useState<CampaignSegment[]>([]);
|
||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||
const [dripSequences, setDripSequences] = useState<DripSequence[]>([]);
|
||||
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
|
||||
const [analytics, setAnalytics] = useState<CampaignAnalytics | null>(null);
|
||||
const [showCampaignModal, setShowCampaignModal] = useState(false);
|
||||
const [showSegmentModal, setShowSegmentModal] = useState(false);
|
||||
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
||||
const [showDripModal, setShowDripModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [dripForm, setDripForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
trigger_event: ''
|
||||
});
|
||||
const [filters, setFilters] = useState({
|
||||
status: '',
|
||||
campaign_type: ''
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
const [campaignForm, setCampaignForm] = useState({
|
||||
name: '',
|
||||
subject: '',
|
||||
html_content: '',
|
||||
text_content: '',
|
||||
campaign_type: 'newsletter',
|
||||
segment_id: undefined as number | undefined,
|
||||
scheduled_at: '',
|
||||
template_id: undefined as number | undefined,
|
||||
from_name: '',
|
||||
from_email: '',
|
||||
track_opens: true,
|
||||
track_clicks: true
|
||||
});
|
||||
|
||||
const [segmentForm, setSegmentForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
criteria: {
|
||||
role: '',
|
||||
has_bookings: undefined as boolean | undefined,
|
||||
is_vip: undefined as boolean | undefined,
|
||||
last_booking_days: undefined as number | undefined
|
||||
}
|
||||
});
|
||||
|
||||
const [templateForm, setTemplateForm] = useState({
|
||||
name: '',
|
||||
subject: '',
|
||||
html_content: '',
|
||||
text_content: '',
|
||||
category: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'campaigns') {
|
||||
fetchCampaigns();
|
||||
} else if (activeTab === 'segments') {
|
||||
fetchSegments();
|
||||
} else if (activeTab === 'templates') {
|
||||
fetchTemplates();
|
||||
} else if (activeTab === 'drip-sequences') {
|
||||
fetchDripSequences();
|
||||
}
|
||||
}, [activeTab, filters, currentPage]);
|
||||
|
||||
const fetchCampaigns = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await emailCampaignService.getCampaigns({
|
||||
status: filters.status || undefined,
|
||||
campaign_type: filters.campaign_type || undefined,
|
||||
limit: 20,
|
||||
offset: (currentPage - 1) * 20
|
||||
});
|
||||
setCampaigns(data);
|
||||
setTotalPages(Math.ceil(data.length / 20));
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to fetch campaigns');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSegments = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await emailCampaignService.getSegments();
|
||||
setSegments(data);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to fetch segments');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await emailCampaignService.getTemplates();
|
||||
setTemplates(data);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to fetch templates');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDripSequences = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await emailCampaignService.getDripSequences();
|
||||
setDripSequences(data);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to fetch drip sequences');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCampaign = async () => {
|
||||
try {
|
||||
if (editingItem) {
|
||||
await emailCampaignService.updateCampaign(editingItem.id, campaignForm);
|
||||
toast.success('Campaign updated');
|
||||
} else {
|
||||
await emailCampaignService.createCampaign(campaignForm);
|
||||
toast.success('Campaign created');
|
||||
}
|
||||
setShowCampaignModal(false);
|
||||
resetCampaignForm();
|
||||
fetchCampaigns();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to save campaign');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendCampaign = async (campaignId: number) => {
|
||||
if (!window.confirm('Are you sure you want to send this campaign?')) return;
|
||||
try {
|
||||
const result = await emailCampaignService.sendCampaign(campaignId);
|
||||
toast.success(`Campaign sent! ${result.sent} emails sent, ${result.failed} failed`);
|
||||
fetchCampaigns();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to send campaign');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewAnalytics = async (campaignId: number) => {
|
||||
try {
|
||||
const data = await emailCampaignService.getCampaignAnalytics(campaignId);
|
||||
setAnalytics(data);
|
||||
setActiveTab('analytics');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to fetch analytics');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSegment = async () => {
|
||||
try {
|
||||
// Build criteria object
|
||||
const criteria: any = {};
|
||||
if (segmentForm.criteria.role) criteria.role = segmentForm.criteria.role;
|
||||
if (segmentForm.criteria.has_bookings !== undefined) criteria.has_bookings = segmentForm.criteria.has_bookings;
|
||||
if (segmentForm.criteria.is_vip !== undefined) criteria.is_vip = segmentForm.criteria.is_vip;
|
||||
if (segmentForm.criteria.last_booking_days) criteria.last_booking_days = segmentForm.criteria.last_booking_days;
|
||||
|
||||
await emailCampaignService.createSegment({
|
||||
name: segmentForm.name,
|
||||
description: segmentForm.description,
|
||||
criteria
|
||||
});
|
||||
toast.success('Segment created');
|
||||
setShowSegmentModal(false);
|
||||
resetSegmentForm();
|
||||
fetchSegments();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to create segment');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTemplate = async () => {
|
||||
try {
|
||||
await emailCampaignService.createTemplate(templateForm);
|
||||
toast.success('Template created');
|
||||
setShowTemplateModal(false);
|
||||
resetTemplateForm();
|
||||
fetchTemplates();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to create template');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDripSequence = async () => {
|
||||
try {
|
||||
await emailCampaignService.createDripSequence(dripForm);
|
||||
toast.success('Drip sequence created');
|
||||
setShowDripModal(false);
|
||||
resetDripForm();
|
||||
fetchDripSequences();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to create drip sequence');
|
||||
}
|
||||
};
|
||||
|
||||
const resetCampaignForm = () => {
|
||||
setCampaignForm({
|
||||
name: '',
|
||||
subject: '',
|
||||
html_content: '',
|
||||
text_content: '',
|
||||
campaign_type: 'newsletter',
|
||||
segment_id: undefined,
|
||||
scheduled_at: '',
|
||||
template_id: undefined,
|
||||
from_name: '',
|
||||
from_email: '',
|
||||
track_opens: true,
|
||||
track_clicks: true
|
||||
});
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
const resetSegmentForm = () => {
|
||||
setSegmentForm({
|
||||
name: '',
|
||||
description: '',
|
||||
criteria: {
|
||||
role: '',
|
||||
has_bookings: undefined,
|
||||
is_vip: undefined,
|
||||
last_booking_days: undefined
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resetTemplateForm = () => {
|
||||
setTemplateForm({
|
||||
name: '',
|
||||
subject: '',
|
||||
html_content: '',
|
||||
text_content: '',
|
||||
category: ''
|
||||
});
|
||||
};
|
||||
|
||||
const resetDripForm = () => {
|
||||
setDripForm({
|
||||
name: '',
|
||||
description: '',
|
||||
trigger_event: ''
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'sent': return 'bg-green-100 text-green-800';
|
||||
case 'sending': return 'bg-blue-100 text-blue-800';
|
||||
case 'scheduled': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'draft': return 'bg-gray-100 text-gray-800';
|
||||
case 'paused': return 'bg-orange-100 text-orange-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !campaigns.length && !segments.length && !templates.length) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-400/5 via-transparent to-purple-600/5 rounded-3xl blur-3xl"></div>
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-blue-200/30 p-8">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-400 to-purple-600 rounded-2xl blur-lg opacity-50"></div>
|
||||
<div className="relative p-4 rounded-2xl bg-gradient-to-br from-blue-500 via-blue-500 to-purple-600 shadow-xl border border-blue-400/50">
|
||||
<Mail className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold bg-gradient-to-r from-slate-900 via-blue-700 to-slate-900 bg-clip-text text-transparent">
|
||||
Email Marketing & Campaigns
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">Create, manage, and track email campaigns</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-200 p-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ id: 'campaigns', label: 'Campaigns', icon: Mail },
|
||||
{ id: 'segments', label: 'Segments', icon: Target },
|
||||
{ id: 'templates', label: 'Templates', icon: FileText },
|
||||
{ id: 'drip-sequences', label: 'Drip Campaigns', icon: Layers },
|
||||
{ id: 'analytics', label: 'Analytics', icon: BarChart3 }
|
||||
].map(tab => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as CampaignTab)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl font-medium transition-all ${
|
||||
activeTab === tab.id
|
||||
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white shadow-lg'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-200 p-6">
|
||||
{activeTab === 'campaigns' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-xl font-semibold">Email Campaigns</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetCampaignForm();
|
||||
setShowCampaignModal(true);
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Campaign
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="sending">Sending</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="paused">Paused</option>
|
||||
</select>
|
||||
<select
|
||||
value={filters.campaign_type}
|
||||
onChange={(e) => setFilters({ ...filters, campaign_type: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="newsletter">Newsletter</option>
|
||||
<option value="promotional">Promotional</option>
|
||||
<option value="transactional">Transactional</option>
|
||||
<option value="abandoned_booking">Abandoned Booking</option>
|
||||
<option value="welcome">Welcome</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Campaigns Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-3 px-4 font-semibold">Name</th>
|
||||
<th className="text-left py-3 px-4 font-semibold">Type</th>
|
||||
<th className="text-left py-3 px-4 font-semibold">Status</th>
|
||||
<th className="text-left py-3 px-4 font-semibold">Recipients</th>
|
||||
<th className="text-left py-3 px-4 font-semibold">Open Rate</th>
|
||||
<th className="text-left py-3 px-4 font-semibold">Click Rate</th>
|
||||
<th className="text-left py-3 px-4 font-semibold">Date</th>
|
||||
<th className="text-left py-3 px-4 font-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{campaigns.map((campaign) => (
|
||||
<tr key={campaign.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-3 px-4 font-medium">{campaign.name}</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600">{campaign.campaign_type}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${getStatusColor(campaign.status)}`}>
|
||||
{campaign.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">{campaign.total_recipients}</td>
|
||||
<td className="py-3 px-4">
|
||||
{campaign.open_rate !== null && campaign.open_rate !== undefined
|
||||
? `${campaign.open_rate.toFixed(2)}%`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{campaign.click_rate !== null && campaign.click_rate !== undefined
|
||||
? `${campaign.click_rate.toFixed(2)}%`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600">
|
||||
{campaign.sent_at ? formatDate(campaign.sent_at) : formatDate(campaign.created_at)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleViewAnalytics(campaign.id)}
|
||||
className="px-3 py-1 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm"
|
||||
>
|
||||
Analytics
|
||||
</button>
|
||||
{campaign.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => handleSendCampaign(campaign.id)}
|
||||
className="px-3 py-1 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'segments' && (
|
||||
<SegmentsTab
|
||||
segments={segments}
|
||||
onRefresh={fetchSegments}
|
||||
onCreate={() => setShowSegmentModal(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'templates' && (
|
||||
<TemplatesTab
|
||||
templates={templates}
|
||||
onRefresh={fetchTemplates}
|
||||
onCreate={() => setShowTemplateModal(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'drip-sequences' && (
|
||||
<DripSequencesTab
|
||||
sequences={dripSequences}
|
||||
onRefresh={fetchDripSequences}
|
||||
onCreate={() => {
|
||||
resetDripForm();
|
||||
setShowDripModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'analytics' && analytics && (
|
||||
<AnalyticsTab analytics={analytics} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Campaign Modal */}
|
||||
{showCampaignModal && (
|
||||
<CampaignModal
|
||||
form={campaignForm}
|
||||
setForm={setCampaignForm}
|
||||
segments={segments}
|
||||
templates={templates}
|
||||
onSave={handleCreateCampaign}
|
||||
onClose={() => {
|
||||
setShowCampaignModal(false);
|
||||
resetCampaignForm();
|
||||
}}
|
||||
editing={!!editingItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Segment Modal */}
|
||||
{showSegmentModal && (
|
||||
<SegmentModal
|
||||
form={segmentForm}
|
||||
setForm={setSegmentForm}
|
||||
onSave={handleCreateSegment}
|
||||
onClose={() => {
|
||||
setShowSegmentModal(false);
|
||||
resetSegmentForm();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Template Modal */}
|
||||
{showTemplateModal && (
|
||||
<TemplateModal
|
||||
form={templateForm}
|
||||
setForm={setTemplateForm}
|
||||
onSave={handleCreateTemplate}
|
||||
onClose={() => {
|
||||
setShowTemplateModal(false);
|
||||
resetTemplateForm();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drip Sequence Modal */}
|
||||
{showDripModal && (
|
||||
<DripSequenceModal
|
||||
form={dripForm}
|
||||
setForm={setDripForm}
|
||||
onSave={handleCreateDripSequence}
|
||||
onClose={() => {
|
||||
setShowDripModal(false);
|
||||
resetDripForm();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Sub-components
|
||||
const SegmentsTab: React.FC<{
|
||||
segments: CampaignSegment[];
|
||||
onRefresh: () => void;
|
||||
onCreate: () => void;
|
||||
}> = ({ segments, onCreate }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-xl font-semibold">Segments</h3>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
||||
>
|
||||
Create Segment
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{segments.map((segment) => (
|
||||
<div key={segment.id} className="border rounded-xl p-4">
|
||||
<h4 className="font-semibold">{segment.name}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">{segment.description}</p>
|
||||
<p className="text-sm text-blue-600 mt-2">
|
||||
Estimated: {segment.estimated_count || 0} users
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TemplatesTab: React.FC<{
|
||||
templates: EmailTemplate[];
|
||||
onRefresh: () => void;
|
||||
onCreate: () => void;
|
||||
}> = ({ templates, onCreate }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-xl font-semibold">Email Templates</h3>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
||||
>
|
||||
Create Template
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{templates.map((template) => (
|
||||
<div key={template.id} className="border rounded-xl p-4">
|
||||
<h4 className="font-semibold">{template.name}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">{template.subject}</p>
|
||||
{template.category && (
|
||||
<span className="inline-block mt-2 px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs">
|
||||
{template.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DripSequencesTab: React.FC<{
|
||||
sequences: DripSequence[];
|
||||
onRefresh: () => void;
|
||||
onCreate: () => void;
|
||||
}> = ({ sequences, onCreate }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-xl font-semibold">Drip Sequences</h3>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Sequence
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{sequences.map((sequence) => (
|
||||
<div key={sequence.id} className="border rounded-xl p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-semibold">{sequence.name}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">{sequence.description}</p>
|
||||
<p className="text-sm text-blue-600 mt-2">
|
||||
{sequence.step_count} steps
|
||||
{sequence.trigger_event && ` • Trigger: ${sequence.trigger_event}`}
|
||||
</p>
|
||||
</div>
|
||||
<button className="px-3 py-1 bg-blue-500 text-white rounded-lg text-sm">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AnalyticsTab: React.FC<{ analytics: CampaignAnalytics }> = ({ analytics }) => (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xl font-semibold">Campaign Analytics</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="bg-blue-50 rounded-xl p-6 border border-blue-100">
|
||||
<p className="text-sm text-blue-600">Open Rate</p>
|
||||
<p className="text-3xl font-bold text-blue-800 mt-2">{analytics.open_rate.toFixed(2)}%</p>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-xl p-6 border border-green-100">
|
||||
<p className="text-sm text-green-600">Click Rate</p>
|
||||
<p className="text-3xl font-bold text-green-800 mt-2">{analytics.click_rate.toFixed(2)}%</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded-xl p-6 border border-purple-100">
|
||||
<p className="text-sm text-purple-600">Total Opened</p>
|
||||
<p className="text-3xl font-bold text-purple-800 mt-2">{analytics.total_opened}</p>
|
||||
</div>
|
||||
<div className="bg-orange-50 rounded-xl p-6 border border-orange-100">
|
||||
<p className="text-sm text-orange-600">Total Clicked</p>
|
||||
<p className="text-3xl font-bold text-orange-800 mt-2">{analytics.total_clicked}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CampaignModal: React.FC<{
|
||||
form: any;
|
||||
setForm: (form: any) => void;
|
||||
segments: CampaignSegment[];
|
||||
templates: EmailTemplate[];
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
editing: boolean;
|
||||
}> = ({ form, setForm, segments, templates, onSave, onClose, editing }) => (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h4 className="text-lg font-semibold">{editing ? 'Edit Campaign' : 'Create Campaign'}</h4>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Campaign Name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Subject"
|
||||
value={form.subject}
|
||||
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<select
|
||||
value={form.campaign_type}
|
||||
onChange={(e) => setForm({ ...form, campaign_type: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="newsletter">Newsletter</option>
|
||||
<option value="promotional">Promotional</option>
|
||||
<option value="transactional">Transactional</option>
|
||||
<option value="abandoned_booking">Abandoned Booking</option>
|
||||
<option value="welcome">Welcome</option>
|
||||
</select>
|
||||
<select
|
||||
value={form.segment_id || ''}
|
||||
onChange={(e) => setForm({ ...form, segment_id: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">No Segment (All Users)</option>
|
||||
{segments.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<textarea
|
||||
placeholder="HTML Content"
|
||||
value={form.html_content}
|
||||
onChange={(e) => setForm({ ...form, html_content: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
rows={10}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
||||
>
|
||||
{editing ? 'Update' : 'Create'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SegmentModal: React.FC<{
|
||||
form: any;
|
||||
setForm: (form: any) => void;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}> = ({ form, setForm, onSave, onClose }) => (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-2xl w-full">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h4 className="text-lg font-semibold">Create Segment</h4>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Segment Name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
rows={3}
|
||||
/>
|
||||
<select
|
||||
value={form.criteria.role}
|
||||
onChange={(e) => setForm({ ...form, criteria: { ...form.criteria, role: e.target.value } })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">All Roles</option>
|
||||
<option value="customer">Customer</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="staff">Staff</option>
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TemplateModal: React.FC<{
|
||||
form: any;
|
||||
setForm: (form: any) => void;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}> = ({ form, setForm, onSave, onClose }) => (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h4 className="text-lg font-semibold">Create Template</h4>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Template Name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Subject"
|
||||
value={form.subject}
|
||||
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="HTML Content"
|
||||
value={form.html_content}
|
||||
onChange={(e) => setForm({ ...form, html_content: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
rows={15}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DripSequenceModal: React.FC<{
|
||||
form: any;
|
||||
setForm: (form: any) => void;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}> = ({ form, setForm, onSave, onClose }) => (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-2xl w-full">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h4 className="text-lg font-semibold">Create Drip Sequence</h4>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Sequence Name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
rows={3}
|
||||
/>
|
||||
<select
|
||||
value={form.trigger_event}
|
||||
onChange={(e) => setForm({ ...form, trigger_event: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="">No Trigger (Manual)</option>
|
||||
<option value="user_signup">User Signup</option>
|
||||
<option value="booking_created">Booking Created</option>
|
||||
<option value="booking_cancelled">Booking Cancelled</option>
|
||||
<option value="check_in">Check In</option>
|
||||
<option value="check_out">Check Out</option>
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default EmailCampaignManagementPage;
|
||||
|
||||
538
Frontend/src/pages/admin/GroupBookingManagementPage.tsx
Normal file
538
Frontend/src/pages/admin/GroupBookingManagementPage.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Search, Eye, XCircle, CheckCircle, Loader2, Users, Plus } from 'lucide-react';
|
||||
import { groupBookingService, GroupBooking } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { formatDate } from '../../utils/format';
|
||||
import CreateGroupBookingModal from '../../components/shared/CreateGroupBookingModal';
|
||||
|
||||
const GroupBookingManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [groupBookings, setGroupBookings] = useState<GroupBooking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedBooking, setSelectedBooking] = useState<GroupBooking | null>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [confirmingBookingId, setConfirmingBookingId] = useState<number | null>(null);
|
||||
const [cancellingBookingId, setCancellingBookingId] = useState<number | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
status: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroupBookings();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchGroupBookings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await groupBookingService.getGroupBookings({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setGroupBookings(response.data.group_bookings);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load group bookings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmBooking = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to confirm this group booking?')) return;
|
||||
|
||||
try {
|
||||
setConfirmingBookingId(id);
|
||||
await groupBookingService.confirmGroupBooking(id);
|
||||
toast.success('Group booking confirmed successfully');
|
||||
await fetchGroupBookings();
|
||||
if (selectedBooking?.id === id) {
|
||||
const updated = await groupBookingService.getGroupBooking(id);
|
||||
setSelectedBooking(updated.data.group_booking);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || error.response?.data?.message || 'Unable to confirm booking');
|
||||
} finally {
|
||||
setConfirmingBookingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelBooking = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to cancel this group booking?')) return;
|
||||
|
||||
try {
|
||||
setCancellingBookingId(id);
|
||||
await groupBookingService.cancelGroupBooking(id, 'Cancelled by admin');
|
||||
toast.success('Group booking cancelled successfully');
|
||||
await fetchGroupBookings();
|
||||
if (selectedBooking?.id === id) {
|
||||
setShowDetailModal(false);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || error.response?.data?.message || 'Unable to cancel booking');
|
||||
} finally {
|
||||
setCancellingBookingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = async (booking: GroupBooking) => {
|
||||
try {
|
||||
const response = await groupBookingService.getGroupBooking(booking.id);
|
||||
setSelectedBooking(response.data.group_booking);
|
||||
setShowDetailModal(true);
|
||||
} catch (error: any) {
|
||||
toast.error('Unable to load booking details');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
draft: {
|
||||
bg: 'bg-gradient-to-r from-gray-50 to-slate-50',
|
||||
text: 'text-gray-700',
|
||||
label: 'Draft',
|
||||
border: 'border-gray-200'
|
||||
},
|
||||
pending: {
|
||||
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
|
||||
text: 'text-amber-800',
|
||||
label: 'Pending',
|
||||
border: 'border-amber-200'
|
||||
},
|
||||
confirmed: {
|
||||
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
|
||||
text: 'text-blue-800',
|
||||
label: 'Confirmed',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
partially_confirmed: {
|
||||
bg: 'bg-gradient-to-r from-purple-50 to-violet-50',
|
||||
text: 'text-purple-800',
|
||||
label: 'Partially Confirmed',
|
||||
border: 'border-purple-200'
|
||||
},
|
||||
checked_in: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: 'Checked In',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
checked_out: {
|
||||
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
|
||||
text: 'text-slate-700',
|
||||
label: 'Checked Out',
|
||||
border: 'border-slate-200'
|
||||
},
|
||||
cancelled: {
|
||||
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
|
||||
text: 'text-rose-800',
|
||||
label: 'Cancelled',
|
||||
border: 'border-rose-200'
|
||||
},
|
||||
};
|
||||
const badge = badges[status] || badges.draft;
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${badge.bg} ${badge.text} ${badge.border}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading && groupBookings.length === 0) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-6 flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Group Booking Management</h1>
|
||||
<p className="text-gray-600">Manage group bookings, room blocks, and member assignments</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Group Booking
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by booking number, group name..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="partially_confirmed">Partially Confirmed</option>
|
||||
<option value="checked_in">Checked In</option>
|
||||
<option value="checked_out">Checked Out</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group Bookings Table */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Booking Number
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Group Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Coordinator
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Dates
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Rooms / Guests
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Total Price
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{groupBookings.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-12 text-center text-gray-500">
|
||||
No group bookings found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
groupBookings.map((booking) => (
|
||||
<tr key={booking.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{booking.group_booking_number}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{booking.group_name || 'N/A'}
|
||||
</div>
|
||||
{booking.group_type && (
|
||||
<div className="text-xs text-gray-500">{booking.group_type}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{booking.coordinator.name}</div>
|
||||
<div className="text-xs text-gray-500">{booking.coordinator.email}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatDate(booking.check_in_date, 'short')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
to {formatDate(booking.check_out_date, 'short')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-900">
|
||||
<Users className="w-4 h-4" />
|
||||
{booking.total_rooms} rooms
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{booking.total_guests} guests</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{formatCurrency(booking.total_price)}
|
||||
</div>
|
||||
{booking.discount_amount > 0 && (
|
||||
<div className="text-xs text-green-600">
|
||||
-{formatCurrency(booking.discount_amount)} discount
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(booking.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleViewDetails(booking)}
|
||||
className="text-blue-600 hover:text-blue-900 flex items-center gap-1"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
View
|
||||
</button>
|
||||
{booking.status === 'draft' || booking.status === 'pending' ? (
|
||||
<button
|
||||
onClick={() => handleConfirmBooking(booking.id)}
|
||||
disabled={confirmingBookingId === booking.id}
|
||||
className="text-green-600 hover:text-green-900 flex items-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
{confirmingBookingId === booking.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
)}
|
||||
Confirm
|
||||
</button>
|
||||
) : null}
|
||||
{booking.status !== 'cancelled' && booking.status !== 'checked_out' ? (
|
||||
<button
|
||||
onClick={() => handleCancelBooking(booking.id)}
|
||||
disabled={cancellingBookingId === booking.id}
|
||||
className="text-red-600 hover:text-red-900 flex items-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
{cancellingBookingId === booking.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4" />
|
||||
)}
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t border-gray-200">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{showDetailModal && selectedBooking && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{selectedBooking.group_booking_number}
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">{selectedBooking.group_name || 'No group name'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDetailModal(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Booking Info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Coordinator</h3>
|
||||
<p className="text-gray-900">{selectedBooking.coordinator.name}</p>
|
||||
<p className="text-sm text-gray-600">{selectedBooking.coordinator.email}</p>
|
||||
{selectedBooking.coordinator.phone && (
|
||||
<p className="text-sm text-gray-600">{selectedBooking.coordinator.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Status</h3>
|
||||
{getStatusBadge(selectedBooking.status)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Check-in</h3>
|
||||
<p className="text-gray-900">{formatDate(selectedBooking.check_in_date, 'short')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Check-out</h3>
|
||||
<p className="text-gray-900">{formatDate(selectedBooking.check_out_date, 'short')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room Blocks */}
|
||||
{selectedBooking.room_blocks && selectedBooking.room_blocks.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Room Blocks</h3>
|
||||
<div className="space-y-3">
|
||||
{selectedBooking.room_blocks.map((block) => (
|
||||
<div key={block.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{block.room_type?.name || `Room Type ${block.room_type_id}`}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{block.rooms_blocked} rooms blocked • {block.rooms_confirmed} confirmed • {block.rooms_available} available
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-gray-900">
|
||||
{formatCurrency(block.rate_per_room)}/room
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Total: {formatCurrency(block.total_block_price)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members */}
|
||||
{selectedBooking.members && selectedBooking.members.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
Members ({selectedBooking.members.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedBooking.members.map((member) => (
|
||||
<div key={member.id} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{member.full_name}</p>
|
||||
{member.email && <p className="text-sm text-gray-600">{member.email}</p>}
|
||||
{member.phone && <p className="text-sm text-gray-600">{member.phone}</p>}
|
||||
{member.assigned_room_id && (
|
||||
<p className="text-sm text-blue-600">Room #{member.assigned_room_id}</p>
|
||||
)}
|
||||
</div>
|
||||
{member.individual_amount && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600">
|
||||
Amount: {formatCurrency(member.individual_amount)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Paid: {formatCurrency(member.individual_paid)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Pricing Summary</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Original Total:</span>
|
||||
<span className="text-gray-900">{formatCurrency(selectedBooking.original_total_price)}</span>
|
||||
</div>
|
||||
{selectedBooking.discount_amount > 0 && (
|
||||
<div className="flex justify-between text-green-600">
|
||||
<span>Discount ({selectedBooking.group_discount_percentage}%):</span>
|
||||
<span>-{formatCurrency(selectedBooking.discount_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-semibold text-lg border-t border-gray-200 pt-2">
|
||||
<span>Total Price:</span>
|
||||
<span>{formatCurrency(selectedBooking.total_price)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Amount Paid:</span>
|
||||
<span className="text-gray-900">{formatCurrency(selectedBooking.amount_paid)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-medium">
|
||||
<span className="text-gray-900">Balance Due:</span>
|
||||
<span className={selectedBooking.balance_due > 0 ? 'text-red-600' : 'text-green-600'}>
|
||||
{formatCurrency(selectedBooking.balance_due)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payments */}
|
||||
{selectedBooking.payments && selectedBooking.payments.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Payments</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedBooking.payments.map((payment) => (
|
||||
<div key={payment.id} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{formatCurrency(payment.amount)} - {payment.payment_method}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{payment.payment_type} • {payment.payment_status}
|
||||
</p>
|
||||
{payment.payment_date && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(payment.payment_date).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Group Booking Modal */}
|
||||
<CreateGroupBookingModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreateModal(false);
|
||||
fetchGroupBookings();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupBookingManagementPage;
|
||||
|
||||
@@ -43,7 +43,9 @@ const InvoiceManagementPage: React.FC = () => {
|
||||
if (response.status === 'success' && response.data) {
|
||||
let invoiceList = response.data.invoices || [];
|
||||
|
||||
|
||||
// Client-side filtering for search (only on current page results)
|
||||
// Note: This is a limitation - search only works on current page
|
||||
// For full search functionality, backend needs to support search parameter
|
||||
if (filters.search) {
|
||||
invoiceList = invoiceList.filter((inv) =>
|
||||
inv.invoice_number.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||
@@ -54,8 +56,15 @@ const InvoiceManagementPage: React.FC = () => {
|
||||
}
|
||||
|
||||
setInvoices(invoiceList);
|
||||
setTotalPages(response.data.total_pages || 1);
|
||||
setTotalItems(response.data.total || 0);
|
||||
// Only update pagination if not searching (to avoid incorrect counts)
|
||||
if (!filters.search) {
|
||||
setTotalPages(response.data.total_pages || 1);
|
||||
setTotalItems(response.data.total || 0);
|
||||
} else {
|
||||
// When searching, keep original pagination but show filtered count
|
||||
setTotalPages(response.data.total_pages || 1);
|
||||
setTotalItems(response.data.total || 0);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load invoices');
|
||||
|
||||
271
Frontend/src/pages/admin/NotificationManagementPage.tsx
Normal file
271
Frontend/src/pages/admin/NotificationManagementPage.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Smartphone,
|
||||
Send,
|
||||
Plus,
|
||||
Eye,
|
||||
Filter,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
import notificationService, { Notification } from '../../services/api/notificationService';
|
||||
import { formatDate } from '../../utils/format';
|
||||
import SendNotificationModal from '../../components/notifications/SendNotificationModal';
|
||||
import NotificationTemplatesModal from '../../components/notifications/NotificationTemplatesModal';
|
||||
|
||||
const NotificationManagementPage: React.FC = () => {
|
||||
const [showSendModal, setShowSendModal] = useState(false);
|
||||
const [showTemplatesModal, setShowTemplatesModal] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
notification_type: '',
|
||||
channel: '',
|
||||
status: '',
|
||||
});
|
||||
|
||||
const { data: notifications, loading, execute: fetchNotifications } = useAsync<Notification[]>(
|
||||
() => notificationService.getNotifications({
|
||||
notification_type: filters.notification_type || undefined,
|
||||
channel: filters.channel || undefined,
|
||||
status: filters.status || undefined,
|
||||
limit: 100,
|
||||
}).then(r => r.data || []),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const getChannelIcon = (channel: string) => {
|
||||
switch (channel) {
|
||||
case 'email':
|
||||
return <Mail className="w-5 h-5 text-blue-500" />;
|
||||
case 'sms':
|
||||
return <MessageSquare className="w-5 h-5 text-green-500" />;
|
||||
case 'push':
|
||||
return <Bell className="w-5 h-5 text-purple-500" />;
|
||||
case 'whatsapp':
|
||||
return <Smartphone className="w-5 h-5 text-emerald-500" />;
|
||||
default:
|
||||
return <Bell className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'sent':
|
||||
case 'delivered':
|
||||
return <CheckCircle2 className="w-5 h-5 text-green-500" />;
|
||||
case 'read':
|
||||
return <Eye className="w-5 h-5 text-blue-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-5 h-5 text-red-500" />;
|
||||
default:
|
||||
return <Clock className="w-5 h-5 text-amber-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const baseClasses = 'px-3 py-1 rounded-full text-xs font-semibold';
|
||||
switch (status) {
|
||||
case 'sent':
|
||||
case 'delivered':
|
||||
return `${baseClasses} bg-green-100 text-green-800 border border-green-200`;
|
||||
case 'read':
|
||||
return `${baseClasses} bg-blue-100 text-blue-800 border border-blue-200`;
|
||||
case 'failed':
|
||||
return `${baseClasses} bg-red-100 text-red-800 border border-red-200`;
|
||||
default:
|
||||
return `${baseClasses} bg-amber-100 text-amber-800 border border-amber-200`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Notification Management</h1>
|
||||
<p className="text-gray-600">Manage and send multi-channel notifications</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowTemplatesModal(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
Templates
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSendModal(true)}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Send Notification
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Filter className="w-5 h-5 text-gray-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Filters</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Type</label>
|
||||
<select
|
||||
value={filters.notification_type}
|
||||
onChange={(e) => setFilters({ ...filters, notification_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="booking_confirmation">Booking Confirmation</option>
|
||||
<option value="payment_receipt">Payment Receipt</option>
|
||||
<option value="pre_arrival_reminder">Pre-Arrival Reminder</option>
|
||||
<option value="check_in_reminder">Check-In Reminder</option>
|
||||
<option value="check_out_reminder">Check-Out Reminder</option>
|
||||
<option value="marketing_campaign">Marketing Campaign</option>
|
||||
<option value="loyalty_update">Loyalty Update</option>
|
||||
<option value="system_alert">System Alert</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Channel</label>
|
||||
<select
|
||||
value={filters.channel}
|
||||
onChange={(e) => setFilters({ ...filters, channel: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="">All Channels</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="push">Push</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="in_app">In-App</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Status</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="read">Read</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
{loading ? (
|
||||
<Loading fullScreen text="Loading notifications..." />
|
||||
) : !notifications || !Array.isArray(notifications) || notifications.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 border border-gray-100">
|
||||
<EmptyState
|
||||
title="No notifications found"
|
||||
description="Send your first notification or adjust your filters"
|
||||
action={{
|
||||
label: 'Send Notification',
|
||||
onClick: () => setShowSendModal(true),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gradient-to-r from-gray-50 to-gray-100">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Notification</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Channel</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Type</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Sent At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{notifications.map((notification) => (
|
||||
<tr key={notification.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{notification.subject || notification.notification_type.replace('_', ' ')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 line-clamp-1 mt-1">{notification.content}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getChannelIcon(notification.channel)}
|
||||
<span className="text-sm text-gray-700 capitalize">{notification.channel}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-sm text-gray-700 capitalize">
|
||||
{notification.notification_type.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(notification.status)}
|
||||
<span className={getStatusBadge(notification.status)}>
|
||||
{notification.status}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{notification.sent_at ? (
|
||||
<span className="text-sm text-gray-700">
|
||||
{formatDate(new Date(notification.sent_at), 'short')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">Not sent</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Send Notification Modal */}
|
||||
{showSendModal && (
|
||||
<SendNotificationModal
|
||||
onClose={() => setShowSendModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowSendModal(false);
|
||||
fetchNotifications();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Templates Modal */}
|
||||
{showTemplatesModal && (
|
||||
<NotificationTemplatesModal
|
||||
onClose={() => setShowTemplatesModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationManagementPage;
|
||||
|
||||
708
Frontend/src/pages/admin/PackageManagementPage.tsx
Normal file
708
Frontend/src/pages/admin/PackageManagementPage.tsx
Normal file
@@ -0,0 +1,708 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, X, Package as PackageIcon } from 'lucide-react';
|
||||
import { packageService, Package, PackageStatus, PackageItem, PackageItemType, CreatePackageData } from '../../services/api';
|
||||
import { roomService, Room } from '../../services/api';
|
||||
import { serviceService, Service } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
|
||||
const PackageManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [packages, setPackages] = useState<Package[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingPackage, setEditingPackage] = useState<Package | null>(null);
|
||||
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
status: '',
|
||||
room_type_id: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
const [formData, setFormData] = useState<CreatePackageData>({
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
status: 'active',
|
||||
base_price: undefined,
|
||||
price_modifier: 1.0,
|
||||
discount_percentage: undefined,
|
||||
room_type_id: undefined,
|
||||
min_nights: undefined,
|
||||
max_nights: undefined,
|
||||
valid_from: '',
|
||||
valid_to: '',
|
||||
image_url: '',
|
||||
highlights: [],
|
||||
terms_conditions: '',
|
||||
extra_data: undefined,
|
||||
items: [],
|
||||
});
|
||||
|
||||
const [newItem, setNewItem] = useState<Partial<PackageItem>>({
|
||||
item_type: 'service',
|
||||
item_name: '',
|
||||
item_description: '',
|
||||
quantity: 1,
|
||||
unit: 'per_stay',
|
||||
price: undefined,
|
||||
included: true,
|
||||
display_order: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPackages();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoomTypes();
|
||||
fetchServices();
|
||||
}, []);
|
||||
|
||||
const fetchRoomTypes = async () => {
|
||||
try {
|
||||
const response = await roomService.getRooms({ limit: 100, page: 1 });
|
||||
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
||||
response.data.rooms.forEach((room: Room) => {
|
||||
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
|
||||
allUniqueRoomTypes.set(room.room_type.id, {
|
||||
id: room.room_type.id,
|
||||
name: room.room_type.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (allUniqueRoomTypes.size > 0) {
|
||||
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch room types:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const response = await serviceService.getServices();
|
||||
if (response.data && response.data.services) {
|
||||
setServices(response.data.services);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch services:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPackages = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params: any = {
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
};
|
||||
if (filters.search) params.search = filters.search;
|
||||
if (filters.status) params.status = filters.status;
|
||||
if (filters.room_type_id) params.room_type_id = parseInt(filters.room_type_id);
|
||||
|
||||
const response = await packageService.getPackages(params);
|
||||
setPackages(response.data.packages);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Unable to load packages');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const submitData = {
|
||||
...formData,
|
||||
room_type_id: formData.room_type_id ? parseInt(formData.room_type_id.toString()) : undefined,
|
||||
base_price: formData.base_price || undefined,
|
||||
discount_percentage: formData.discount_percentage || undefined,
|
||||
valid_from: formData.valid_from || undefined,
|
||||
valid_to: formData.valid_to || undefined,
|
||||
};
|
||||
|
||||
if (editingPackage) {
|
||||
await packageService.updatePackage(editingPackage.id, submitData);
|
||||
toast.success('Package updated successfully');
|
||||
} else {
|
||||
await packageService.createPackage(submitData);
|
||||
toast.success('Package created successfully');
|
||||
}
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
fetchPackages();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (pkg: Package) => {
|
||||
setEditingPackage(pkg);
|
||||
setFormData({
|
||||
name: pkg.name,
|
||||
code: pkg.code,
|
||||
description: pkg.description || '',
|
||||
status: pkg.status,
|
||||
base_price: pkg.base_price,
|
||||
price_modifier: pkg.price_modifier,
|
||||
discount_percentage: pkg.discount_percentage,
|
||||
room_type_id: pkg.room_type_id,
|
||||
min_nights: pkg.min_nights,
|
||||
max_nights: pkg.max_nights,
|
||||
valid_from: pkg.valid_from?.split('T')[0] || '',
|
||||
valid_to: pkg.valid_to?.split('T')[0] || '',
|
||||
image_url: pkg.image_url || '',
|
||||
highlights: pkg.highlights || [],
|
||||
terms_conditions: pkg.terms_conditions || '',
|
||||
items: pkg.items || [],
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this package?')) return;
|
||||
|
||||
try {
|
||||
await packageService.deletePackage(id);
|
||||
toast.success('Package deleted successfully');
|
||||
fetchPackages();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Unable to delete package');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingPackage(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
status: 'active',
|
||||
base_price: undefined,
|
||||
price_modifier: 1.0,
|
||||
discount_percentage: undefined,
|
||||
room_type_id: undefined,
|
||||
min_nights: undefined,
|
||||
max_nights: undefined,
|
||||
valid_from: '',
|
||||
valid_to: '',
|
||||
image_url: '',
|
||||
highlights: [],
|
||||
terms_conditions: '',
|
||||
extra_data: undefined,
|
||||
items: [],
|
||||
});
|
||||
setNewItem({
|
||||
item_type: 'service',
|
||||
item_name: '',
|
||||
item_description: '',
|
||||
quantity: 1,
|
||||
unit: 'per_stay',
|
||||
price: undefined,
|
||||
included: true,
|
||||
display_order: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
if (!newItem.item_name) {
|
||||
toast.error('Please enter item name');
|
||||
return;
|
||||
}
|
||||
setFormData({
|
||||
...formData,
|
||||
items: [
|
||||
...(formData.items || []),
|
||||
{
|
||||
...newItem,
|
||||
display_order: formData.items?.length || 0,
|
||||
} as PackageItem,
|
||||
],
|
||||
});
|
||||
setNewItem({
|
||||
item_type: 'service',
|
||||
item_name: '',
|
||||
item_description: '',
|
||||
quantity: 1,
|
||||
unit: 'per_stay',
|
||||
price: undefined,
|
||||
included: true,
|
||||
display_order: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
const newItems = [...(formData.items || [])];
|
||||
newItems.splice(index, 1);
|
||||
setFormData({ ...formData, items: newItems });
|
||||
};
|
||||
|
||||
const addHighlight = () => {
|
||||
const highlight = prompt('Enter highlight:');
|
||||
if (highlight) {
|
||||
setFormData({
|
||||
...formData,
|
||||
highlights: [...(formData.highlights || []), highlight],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removeHighlight = (index: number) => {
|
||||
const newHighlights = [...(formData.highlights || [])];
|
||||
newHighlights.splice(index, 1);
|
||||
setFormData({ ...formData, highlights: newHighlights });
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
active: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: 'Active',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
inactive: {
|
||||
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
|
||||
text: 'text-slate-700',
|
||||
label: 'Inactive',
|
||||
border: 'border-slate-200'
|
||||
},
|
||||
scheduled: {
|
||||
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
|
||||
text: 'text-blue-800',
|
||||
label: 'Scheduled',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
expired: {
|
||||
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
|
||||
text: 'text-rose-800',
|
||||
label: 'Expired',
|
||||
border: 'border-rose-200'
|
||||
},
|
||||
};
|
||||
const badge = badges[status] || badges.active;
|
||||
return (
|
||||
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading && packages.length === 0) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
<div className="flex justify-between items-center animate-fade-in">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
Package Management
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-3 text-lg font-light">Manage package deals and bundles</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add Package
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or code..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
<select
|
||||
value={filters.room_type_id}
|
||||
onChange={(e) => setFilters({ ...filters, room_type_id: e.target.value })}
|
||||
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<option value="">All Room Types</option>
|
||||
{roomTypes.map((rt) => (
|
||||
<option key={rt.id} value={rt.id}>{rt.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Packages Table */}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Code</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Name</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Items</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Pricing</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Room Type</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Status</th>
|
||||
<th className="px-8 py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-100">
|
||||
{packages.map((pkg, index) => (
|
||||
<tr
|
||||
key={pkg.id}
|
||||
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
|
||||
>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg">
|
||||
<PackageIcon className="w-4 h-4 text-amber-600" />
|
||||
</div>
|
||||
<span className="text-sm font-mono font-bold bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">{pkg.code}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5">
|
||||
<div className="text-sm font-semibold text-slate-900">{pkg.name}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{pkg.description}</div>
|
||||
</td>
|
||||
<td className="px-8 py-5">
|
||||
<div className="text-sm text-slate-600">
|
||||
{pkg.items?.length || 0} item(s)
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm">
|
||||
{pkg.base_price ? (
|
||||
<span className="font-bold text-emerald-600">{formatCurrency(pkg.base_price)}</span>
|
||||
) : pkg.discount_percentage ? (
|
||||
<span className="font-bold text-emerald-600">{pkg.discount_percentage}% off</span>
|
||||
) : (
|
||||
<span className="text-slate-600">Custom pricing</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm text-slate-600">{pkg.room_type_name || 'All Types'}</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{getStatusBadge(pkg.status)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(pkg)}
|
||||
className="p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all duration-200 shadow-sm hover:shadow-md border border-blue-200 hover:border-blue-300"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(pkg.id)}
|
||||
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{packages.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<PackageIcon className="w-16 h-16 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-slate-500">No packages found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="px-8 py-5 border-t border-slate-200">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-5xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 px-8 py-6 flex justify-between items-center rounded-t-2xl">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{editingPackage ? 'Edit Package' : 'Create Package'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
}}
|
||||
className="p-2 rounded-lg text-white hover:bg-white/20 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-8 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Code *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
disabled={!!editingPackage}
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all disabled:bg-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Status *</label>
|
||||
<select
|
||||
required
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as PackageStatus })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Room Type</label>
|
||||
<select
|
||||
value={formData.room_type_id || ''}
|
||||
onChange={(e) => setFormData({ ...formData, room_type_id: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
>
|
||||
<option value="">All Room Types</option>
|
||||
{roomTypes.map((rt) => (
|
||||
<option key={rt.id} value={rt.id}>{rt.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Pricing</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Base Price</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.base_price || ''}
|
||||
onChange={(e) => setFormData({ ...formData, base_price: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Fixed price per night</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Price Modifier</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.price_modifier}
|
||||
onChange={(e) => setFormData({ ...formData, price_modifier: parseFloat(e.target.value) || 1.0 })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Discount %</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.discount_percentage || ''}
|
||||
onChange={(e) => setFormData({ ...formData, discount_percentage: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Package Items</h3>
|
||||
<div className="space-y-4">
|
||||
{formData.items?.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-4 p-4 bg-slate-50 rounded-xl">
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-slate-900">{item.item_name}</div>
|
||||
<div className="text-sm text-slate-600">{item.item_type} - Qty: {item.quantity} {item.unit}</div>
|
||||
{item.price && <div className="text-sm text-emerald-600">{formatCurrency(item.price)}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(index)}
|
||||
className="p-2 text-rose-600 hover:bg-rose-50 rounded-lg"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 bg-amber-50 rounded-xl">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 mb-1">Type</label>
|
||||
<select
|
||||
value={newItem.item_type}
|
||||
onChange={(e) => setNewItem({ ...newItem, item_type: e.target.value as PackageItemType })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="service">Service</option>
|
||||
<option value="breakfast">Breakfast</option>
|
||||
<option value="activity">Activity</option>
|
||||
<option value="amenity">Amenity</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newItem.item_name}
|
||||
onChange={(e) => setNewItem({ ...newItem, item_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
placeholder="Item name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 mb-1">Quantity</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={newItem.quantity}
|
||||
onChange={(e) => setNewItem({ ...newItem, quantity: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
className="w-full px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-semibold"
|
||||
>
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Highlights</h3>
|
||||
<div className="space-y-2">
|
||||
{formData.highlights?.map((highlight, index) => (
|
||||
<div key={index} className="flex items-center gap-2 p-2 bg-slate-50 rounded-lg">
|
||||
<span className="flex-1 text-sm text-slate-700">{highlight}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeHighlight(index)}
|
||||
className="p-1 text-rose-600 hover:bg-rose-50 rounded"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addHighlight}
|
||||
className="px-4 py-2 border-2 border-dashed border-slate-300 text-slate-600 rounded-lg hover:border-amber-400 hover:text-amber-600 transition-colors text-sm font-semibold"
|
||||
>
|
||||
+ Add Highlight
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 pt-6 border-t border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
}}
|
||||
className="px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all shadow-lg hover:shadow-xl"
|
||||
>
|
||||
{editingPackage ? 'Update' : 'Create'} Package
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PackageManagementPage;
|
||||
|
||||
@@ -564,26 +564,26 @@ const PageContentDashboard: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-10">
|
||||
<div className="max-w-7xl mx-auto px-2 sm:px-3 md:px-4 lg:px-6 xl:px-8 py-2 sm:py-4 md:py-6 lg:py-8 space-y-3 sm:space-y-4 md:space-y-6 lg:space-y-8">
|
||||
{/* Luxury Header */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-purple-400/5 via-transparent to-indigo-600/5 rounded-3xl blur-3xl"></div>
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-purple-200/30 p-8 md:p-10">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-8">
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-400 to-indigo-600 rounded-2xl blur-lg opacity-50"></div>
|
||||
<div className="relative p-4 rounded-2xl bg-gradient-to-br from-purple-500 via-purple-500 to-indigo-600 shadow-xl border border-purple-400/50">
|
||||
<Globe className="w-8 h-8 text-white" />
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-xl sm:rounded-2xl md:rounded-3xl shadow-2xl border border-purple-200/30 p-3 sm:p-4 md:p-6 lg:p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 sm:gap-6 md:gap-8">
|
||||
<div className="flex items-start gap-3 sm:gap-4 md:gap-5">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-400 to-indigo-600 rounded-xl sm:rounded-2xl blur-lg opacity-50"></div>
|
||||
<div className="relative p-2.5 sm:p-3 md:p-4 rounded-xl sm:rounded-2xl bg-gradient-to-br from-purple-500 via-purple-500 to-indigo-600 shadow-xl border border-purple-400/50">
|
||||
<Globe className="w-5 h-5 sm:w-6 sm:h-6 md:w-8 md:h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold bg-gradient-to-r from-slate-900 via-purple-700 to-slate-900 bg-clip-text text-transparent">
|
||||
<div className="space-y-2 sm:space-y-3 flex-1">
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-3xl font-extrabold bg-gradient-to-r from-slate-900 via-purple-700 to-slate-900 bg-clip-text text-transparent">
|
||||
Page Content Management
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-gray-600 text-base md:text-lg max-w-2xl leading-relaxed">
|
||||
<p className="text-gray-600 text-xs sm:text-sm md:text-sm max-w-2xl leading-relaxed">
|
||||
Centralized control for all frontend pages and SEO optimization
|
||||
</p>
|
||||
</div>
|
||||
@@ -591,36 +591,38 @@ const PageContentDashboard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Premium Tab Navigation */}
|
||||
<div className="mt-10 pt-8 border-t border-gradient-to-r from-transparent via-purple-200/30 to-transparent">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
group relative flex items-center gap-3 px-6 py-3.5 rounded-xl font-semibold text-sm
|
||||
transition-all duration-300 overflow-hidden
|
||||
${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-purple-500 via-purple-500 to-indigo-600 text-white shadow-xl shadow-purple-500/40 scale-105'
|
||||
: 'bg-white/80 text-gray-700 border border-gray-200/60 hover:border-purple-300/60 hover:bg-gradient-to-r hover:from-purple-50/50 hover:to-indigo-50/30 hover:shadow-lg hover:scale-102'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
|
||||
)}
|
||||
<Icon className={`w-5 h-5 transition-transform duration-300 ${isActive ? 'text-white' : 'text-gray-600 group-hover:text-purple-600 group-hover:scale-110'}`} />
|
||||
<span className="relative z-10">{tab.label}</span>
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-purple-300 via-purple-400 to-indigo-400"></div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="mt-4 sm:mt-6 md:mt-8 lg:mt-10 pt-4 sm:pt-6 md:pt-8 border-t border-gradient-to-r from-transparent via-purple-200/30 to-transparent">
|
||||
<div className="overflow-x-auto -mx-2 sm:-mx-3 px-2 sm:px-3 scrollbar-hide">
|
||||
<div className="flex gap-2 sm:gap-3 min-w-max sm:min-w-0 sm:flex-wrap">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
group relative flex items-center gap-1.5 sm:gap-2 md:gap-3 px-3 sm:px-4 md:px-6 py-2 sm:py-2.5 md:py-3.5 rounded-lg sm:rounded-xl font-semibold text-xs sm:text-sm flex-shrink-0
|
||||
transition-all duration-300 overflow-hidden
|
||||
${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-purple-500 via-purple-500 to-indigo-600 text-white shadow-xl shadow-purple-500/40 scale-105'
|
||||
: 'bg-white/80 text-gray-700 border border-gray-200/60 hover:border-purple-300/60 hover:bg-gradient-to-r hover:from-purple-50/50 hover:to-indigo-50/30 hover:shadow-lg hover:scale-102'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
|
||||
)}
|
||||
<Icon className={`w-3.5 h-3.5 sm:w-4 sm:h-4 md:w-5 md:h-5 transition-transform duration-300 flex-shrink-0 ${isActive ? 'text-white' : 'text-gray-600 group-hover:text-purple-600 group-hover:scale-110'}`} />
|
||||
<span className="relative z-10 whitespace-nowrap">{tab.label}</span>
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 sm:h-1 bg-gradient-to-r from-purple-300 via-purple-400 to-indigo-400"></div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -628,7 +630,7 @@ const PageContentDashboard: React.FC = () => {
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5 md:gap-6 lg:gap-8">
|
||||
{[
|
||||
{ id: 'home' as PageType, label: 'Home Page', icon: Home, color: 'blue', description: 'Manage hero section, featured content' },
|
||||
{ id: 'contact' as PageType, label: 'Contact Page', icon: Mail, color: 'green', description: 'Manage contact information and form' },
|
||||
|
||||
@@ -35,15 +35,67 @@ const PaymentManagementPage: React.FC = () => {
|
||||
const fetchPayments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await paymentService.getPayments({
|
||||
...filters,
|
||||
// Backend only supports: booking_id, status, page, limit
|
||||
// Remove search, method, from, to from API call and handle client-side
|
||||
const apiParams: any = {
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setPayments(response.data.payments);
|
||||
};
|
||||
|
||||
if (filters.method) {
|
||||
// Note: Backend doesn't support method filter, will filter client-side
|
||||
}
|
||||
|
||||
if (filters.from || filters.to) {
|
||||
// Note: Backend doesn't support date range filter, will filter client-side
|
||||
}
|
||||
|
||||
const response = await paymentService.getPayments(apiParams);
|
||||
let paymentsList = response.data.payments || [];
|
||||
|
||||
// Client-side filtering for search, method, and date range
|
||||
// Note: This only filters current page results
|
||||
if (filters.search) {
|
||||
paymentsList = paymentsList.filter((p) =>
|
||||
(p.transaction_id && p.transaction_id.toLowerCase().includes(filters.search.toLowerCase())) ||
|
||||
(p.booking?.booking_number && p.booking.booking_number.toLowerCase().includes(filters.search.toLowerCase())) ||
|
||||
(p.booking?.user?.name && p.booking.user.name.toLowerCase().includes(filters.search.toLowerCase())) ||
|
||||
(p.booking?.user?.email && p.booking.user.email.toLowerCase().includes(filters.search.toLowerCase()))
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.method) {
|
||||
paymentsList = paymentsList.filter((p) => p.payment_method === filters.method);
|
||||
}
|
||||
|
||||
if (filters.from) {
|
||||
const fromDate = new Date(filters.from);
|
||||
paymentsList = paymentsList.filter((p) => {
|
||||
const paymentDate = p.payment_date ? new Date(p.payment_date) : (p.createdAt ? new Date(p.createdAt) : null);
|
||||
return paymentDate && paymentDate >= fromDate;
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.to) {
|
||||
const toDate = new Date(filters.to);
|
||||
toDate.setHours(23, 59, 59, 999); // Include entire day
|
||||
paymentsList = paymentsList.filter((p) => {
|
||||
const paymentDate = p.payment_date ? new Date(p.payment_date) : (p.createdAt ? new Date(p.createdAt) : null);
|
||||
return paymentDate && paymentDate <= toDate;
|
||||
});
|
||||
}
|
||||
|
||||
setPayments(paymentsList);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
// Only update pagination if not filtering (to avoid incorrect counts)
|
||||
if (!filters.search && !filters.method && !filters.from && !filters.to) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
} else {
|
||||
// Keep original pagination when filtering
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load payments list');
|
||||
@@ -226,6 +278,7 @@ const PaymentManagementPage: React.FC = () => {
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Customer</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Method</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Type</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Status</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Amount</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Payment Date</th>
|
||||
</tr>
|
||||
|
||||
774
Frontend/src/pages/admin/RatePlanManagementPage.tsx
Normal file
774
Frontend/src/pages/admin/RatePlanManagementPage.tsx
Normal file
@@ -0,0 +1,774 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, X, Tag } from 'lucide-react';
|
||||
import { ratePlanService, RatePlan, RatePlanType, RatePlanStatus, CreateRatePlanData } from '../../services/api';
|
||||
import { roomService, Room } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
|
||||
const RatePlanManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [ratePlans, setRatePlans] = useState<RatePlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingRatePlan, setEditingRatePlan] = useState<RatePlan | null>(null);
|
||||
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
status: '',
|
||||
plan_type: '',
|
||||
room_type_id: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
const [formData, setFormData] = useState<CreateRatePlanData>({
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
plan_type: 'BAR',
|
||||
status: 'active',
|
||||
base_price_modifier: 1.0,
|
||||
discount_percentage: 0,
|
||||
fixed_discount: 0,
|
||||
room_type_id: undefined,
|
||||
min_nights: undefined,
|
||||
max_nights: undefined,
|
||||
advance_days_required: undefined,
|
||||
valid_from: '',
|
||||
valid_to: '',
|
||||
is_refundable: true,
|
||||
requires_deposit: false,
|
||||
deposit_percentage: 0,
|
||||
cancellation_hours: undefined,
|
||||
corporate_code: '',
|
||||
requires_verification: false,
|
||||
verification_type: '',
|
||||
long_stay_nights: undefined,
|
||||
is_package: false,
|
||||
package_id: undefined,
|
||||
priority: 100,
|
||||
extra_data: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRatePlans();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoomTypes();
|
||||
}, []);
|
||||
|
||||
const fetchRoomTypes = async () => {
|
||||
try {
|
||||
const response = await roomService.getRooms({ limit: 100, page: 1 });
|
||||
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
||||
response.data.rooms.forEach((room: Room) => {
|
||||
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
|
||||
allUniqueRoomTypes.set(room.room_type.id, {
|
||||
id: room.room_type.id,
|
||||
name: room.room_type.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (allUniqueRoomTypes.size > 0) {
|
||||
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch room types:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRatePlans = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params: any = {
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
};
|
||||
if (filters.search) params.search = filters.search;
|
||||
if (filters.status) params.status = filters.status;
|
||||
if (filters.plan_type) params.plan_type = filters.plan_type;
|
||||
if (filters.room_type_id) params.room_type_id = parseInt(filters.room_type_id);
|
||||
|
||||
const response = await ratePlanService.getRatePlans(params);
|
||||
setRatePlans(response.data.rate_plans);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Unable to load rate plans');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const submitData = {
|
||||
...formData,
|
||||
room_type_id: formData.room_type_id ? parseInt(formData.room_type_id.toString()) : undefined,
|
||||
discount_percentage: formData.discount_percentage || undefined,
|
||||
fixed_discount: formData.fixed_discount || undefined,
|
||||
deposit_percentage: formData.deposit_percentage || undefined,
|
||||
valid_from: formData.valid_from || undefined,
|
||||
valid_to: formData.valid_to || undefined,
|
||||
};
|
||||
|
||||
if (editingRatePlan) {
|
||||
await ratePlanService.updateRatePlan(editingRatePlan.id, submitData);
|
||||
toast.success('Rate plan updated successfully');
|
||||
} else {
|
||||
await ratePlanService.createRatePlan(submitData);
|
||||
toast.success('Rate plan created successfully');
|
||||
}
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
fetchRatePlans();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (ratePlan: RatePlan) => {
|
||||
setEditingRatePlan(ratePlan);
|
||||
setFormData({
|
||||
name: ratePlan.name,
|
||||
code: ratePlan.code,
|
||||
description: ratePlan.description || '',
|
||||
plan_type: ratePlan.plan_type,
|
||||
status: ratePlan.status,
|
||||
base_price_modifier: ratePlan.base_price_modifier,
|
||||
discount_percentage: ratePlan.discount_percentage,
|
||||
fixed_discount: ratePlan.fixed_discount,
|
||||
room_type_id: ratePlan.room_type_id,
|
||||
min_nights: ratePlan.min_nights,
|
||||
max_nights: ratePlan.max_nights,
|
||||
advance_days_required: ratePlan.advance_days_required,
|
||||
valid_from: ratePlan.valid_from?.split('T')[0] || '',
|
||||
valid_to: ratePlan.valid_to?.split('T')[0] || '',
|
||||
is_refundable: ratePlan.is_refundable,
|
||||
requires_deposit: ratePlan.requires_deposit,
|
||||
deposit_percentage: ratePlan.deposit_percentage,
|
||||
cancellation_hours: ratePlan.cancellation_hours,
|
||||
corporate_code: ratePlan.corporate_code || '',
|
||||
requires_verification: ratePlan.requires_verification,
|
||||
verification_type: ratePlan.verification_type || '',
|
||||
long_stay_nights: ratePlan.long_stay_nights,
|
||||
is_package: ratePlan.is_package,
|
||||
package_id: ratePlan.package_id,
|
||||
priority: ratePlan.priority,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this rate plan?')) return;
|
||||
|
||||
try {
|
||||
await ratePlanService.deleteRatePlan(id);
|
||||
toast.success('Rate plan deleted successfully');
|
||||
fetchRatePlans();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Unable to delete rate plan');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingRatePlan(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
plan_type: 'BAR',
|
||||
status: 'active',
|
||||
base_price_modifier: 1.0,
|
||||
discount_percentage: 0,
|
||||
fixed_discount: 0,
|
||||
room_type_id: undefined,
|
||||
min_nights: undefined,
|
||||
max_nights: undefined,
|
||||
advance_days_required: undefined,
|
||||
valid_from: '',
|
||||
valid_to: '',
|
||||
is_refundable: true,
|
||||
requires_deposit: false,
|
||||
deposit_percentage: 0,
|
||||
cancellation_hours: undefined,
|
||||
corporate_code: '',
|
||||
requires_verification: false,
|
||||
verification_type: '',
|
||||
long_stay_nights: undefined,
|
||||
is_package: false,
|
||||
package_id: undefined,
|
||||
priority: 100,
|
||||
extra_data: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
active: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: 'Active',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
inactive: {
|
||||
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
|
||||
text: 'text-slate-700',
|
||||
label: 'Inactive',
|
||||
border: 'border-slate-200'
|
||||
},
|
||||
scheduled: {
|
||||
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
|
||||
text: 'text-blue-800',
|
||||
label: 'Scheduled',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
expired: {
|
||||
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
|
||||
text: 'text-rose-800',
|
||||
label: 'Expired',
|
||||
border: 'border-rose-200'
|
||||
},
|
||||
};
|
||||
const badge = badges[status] || badges.active;
|
||||
return (
|
||||
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getPlanTypeBadge = (type: string) => {
|
||||
const types: Record<string, { bg: string; text: string; label: string }> = {
|
||||
BAR: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'BAR' },
|
||||
non_refundable: { bg: 'bg-red-100', text: 'text-red-800', label: 'Non-Refundable' },
|
||||
advance_purchase: { bg: 'bg-purple-100', text: 'text-purple-800', label: 'Advance Purchase' },
|
||||
corporate: { bg: 'bg-indigo-100', text: 'text-indigo-800', label: 'Corporate' },
|
||||
government: { bg: 'bg-green-100', text: 'text-green-800', label: 'Government' },
|
||||
military: { bg: 'bg-amber-100', text: 'text-amber-800', label: 'Military' },
|
||||
long_stay: { bg: 'bg-teal-100', text: 'text-teal-800', label: 'Long Stay' },
|
||||
package: { bg: 'bg-pink-100', text: 'text-pink-800', label: 'Package' },
|
||||
};
|
||||
const typeInfo = types[type] || types.BAR;
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${typeInfo.bg} ${typeInfo.text}`}>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading && ratePlans.length === 0) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
<div className="flex justify-between items-center animate-fade-in">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
Rate Plan Management
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-3 text-lg font-light">Manage pricing plans and rate structures</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add Rate Plan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or code..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.plan_type}
|
||||
onChange={(e) => setFilters({ ...filters, plan_type: e.target.value })}
|
||||
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="BAR">BAR</option>
|
||||
<option value="non_refundable">Non-Refundable</option>
|
||||
<option value="advance_purchase">Advance Purchase</option>
|
||||
<option value="corporate">Corporate</option>
|
||||
<option value="government">Government</option>
|
||||
<option value="military">Military</option>
|
||||
<option value="long_stay">Long Stay</option>
|
||||
<option value="package">Package</option>
|
||||
</select>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
<select
|
||||
value={filters.room_type_id}
|
||||
onChange={(e) => setFilters({ ...filters, room_type_id: e.target.value })}
|
||||
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<option value="">All Room Types</option>
|
||||
{roomTypes.map((rt) => (
|
||||
<option key={rt.id} value={rt.id}>{rt.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate Plans Table */}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Code</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Name</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Type</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Pricing</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Room Type</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Status</th>
|
||||
<th className="px-8 py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-100">
|
||||
{ratePlans.map((plan) => (
|
||||
<tr
|
||||
key={plan.id}
|
||||
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
|
||||
>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg">
|
||||
<Tag className="w-4 h-4 text-amber-600" />
|
||||
</div>
|
||||
<span className="text-sm font-mono font-bold bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">{plan.code}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5">
|
||||
<div className="text-sm font-semibold text-slate-900">{plan.name}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{plan.description}</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{getPlanTypeBadge(plan.plan_type)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm">
|
||||
{plan.discount_percentage ? (
|
||||
<span className="font-bold text-emerald-600">{plan.discount_percentage}% off</span>
|
||||
) : plan.fixed_discount ? (
|
||||
<span className="font-bold text-emerald-600">{formatCurrency(plan.fixed_discount)} off</span>
|
||||
) : (
|
||||
<span className="text-slate-600">{plan.base_price_modifier}x base</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm text-slate-600">{plan.room_type_name || 'All Types'}</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{getStatusBadge(plan.status)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(plan)}
|
||||
className="p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all duration-200 shadow-sm hover:shadow-md border border-blue-200 hover:border-blue-300"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(plan.id)}
|
||||
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{ratePlans.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Tag className="w-16 h-16 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-slate-500">No rate plans found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="px-8 py-5 border-t border-slate-200">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 px-8 py-6 flex justify-between items-center rounded-t-2xl">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{editingRatePlan ? 'Edit Rate Plan' : 'Create Rate Plan'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
}}
|
||||
className="p-2 rounded-lg text-white hover:bg-white/20 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-8 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Code *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
disabled={!!editingRatePlan}
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all disabled:bg-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Plan Type *</label>
|
||||
<select
|
||||
required
|
||||
value={formData.plan_type}
|
||||
onChange={(e) => setFormData({ ...formData, plan_type: e.target.value as RatePlanType })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
>
|
||||
<option value="BAR">BAR (Best Available Rate)</option>
|
||||
<option value="non_refundable">Non-Refundable</option>
|
||||
<option value="advance_purchase">Advance Purchase</option>
|
||||
<option value="corporate">Corporate</option>
|
||||
<option value="government">Government</option>
|
||||
<option value="military">Military</option>
|
||||
<option value="long_stay">Long Stay</option>
|
||||
<option value="package">Package</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Status *</label>
|
||||
<select
|
||||
required
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as RatePlanStatus })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Room Type</label>
|
||||
<select
|
||||
value={formData.room_type_id || ''}
|
||||
onChange={(e) => setFormData({ ...formData, room_type_id: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
>
|
||||
<option value="">All Room Types</option>
|
||||
{roomTypes.map((rt) => (
|
||||
<option key={rt.id} value={rt.id}>{rt.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Priority</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 100 })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Lower number = higher priority</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Pricing</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Base Price Modifier</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.base_price_modifier}
|
||||
onChange={(e) => setFormData({ ...formData, base_price_modifier: parseFloat(e.target.value) || 1.0 })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">1.0 = 100%, 0.9 = 90%</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Discount %</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.discount_percentage || ''}
|
||||
onChange={(e) => setFormData({ ...formData, discount_percentage: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Fixed Discount</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.fixed_discount || ''}
|
||||
onChange={(e) => setFormData({ ...formData, fixed_discount: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Restrictions</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Min Nights</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.min_nights || ''}
|
||||
onChange={(e) => setFormData({ ...formData, min_nights: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Max Nights</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.max_nights || ''}
|
||||
onChange={(e) => setFormData({ ...formData, max_nights: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Advance Days Required</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.advance_days_required || ''}
|
||||
onChange={(e) => setFormData({ ...formData, advance_days_required: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Cancellation Hours</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.cancellation_hours || ''}
|
||||
onChange={(e) => setFormData({ ...formData, cancellation_hours: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Valid From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.valid_from}
|
||||
onChange={(e) => setFormData({ ...formData, valid_from: e.target.value })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Valid To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.valid_to}
|
||||
onChange={(e) => setFormData({ ...formData, valid_to: e.target.value })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Options</h3>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_refundable}
|
||||
onChange={(e) => setFormData({ ...formData, is_refundable: e.target.checked })}
|
||||
className="w-5 h-5 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-700">Refundable</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.requires_deposit}
|
||||
onChange={(e) => setFormData({ ...formData, requires_deposit: e.target.checked })}
|
||||
className="w-5 h-5 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-700">Requires Deposit</span>
|
||||
</label>
|
||||
{formData.requires_deposit && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Deposit Percentage</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.deposit_percentage || ''}
|
||||
onChange={(e) => setFormData({ ...formData, deposit_percentage: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(formData.plan_type === 'corporate' || formData.plan_type === 'government' || formData.plan_type === 'military') && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Corporate/Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.corporate_code || ''}
|
||||
onChange={(e) => setFormData({ ...formData, corporate_code: e.target.value })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.requires_verification}
|
||||
onChange={(e) => setFormData({ ...formData, requires_verification: e.target.checked })}
|
||||
className="w-5 h-5 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-700">Requires Verification</span>
|
||||
</label>
|
||||
{formData.requires_verification && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Verification Type</label>
|
||||
<select
|
||||
value={formData.verification_type || ''}
|
||||
onChange={(e) => setFormData({ ...formData, verification_type: e.target.value || undefined })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
>
|
||||
<option value="">Select Type</option>
|
||||
<option value="corporate_id">Corporate ID</option>
|
||||
<option value="government_id">Government ID</option>
|
||||
<option value="military_id">Military ID</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{formData.plan_type === 'long_stay' && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2">Long Stay Nights</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.long_stay_nights || ''}
|
||||
onChange={(e) => setFormData({ ...formData, long_stay_nights: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 pt-6 border-t border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
}}
|
||||
className="px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all shadow-lg hover:shadow-xl"
|
||||
>
|
||||
{editingRatePlan ? 'Update' : 'Create'} Rate Plan
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatePlanManagementPage;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -564,25 +564,25 @@ const RoomManagementPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
<div className="space-y-4 sm:space-y-6 md:space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-3 sm:-m-4 md:-m-6 p-4 sm:p-6 md:p-8">
|
||||
{}
|
||||
<div className="flex justify-between items-center animate-fade-in">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4 animate-fade-in">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-2">
|
||||
<div className="h-1 w-12 sm:w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-xl sm:text-2xl md:text-2xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
Room Management
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-3 text-lg font-light">Manage hotel room information</p>
|
||||
<p className="text-slate-600 mt-2 sm:mt-3 text-xs sm:text-sm md:text-sm font-light">Manage hotel room information</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 w-full sm:w-auto">
|
||||
{selectedRooms.length > 0 && (
|
||||
<button
|
||||
onClick={handleBulkDelete}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-rose-500 to-rose-600 text-white rounded-xl font-semibold hover:from-rose-600 hover:to-rose-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
className="flex items-center justify-center gap-2 px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-rose-500 to-rose-600 text-white rounded-lg sm:rounded-xl font-semibold hover:from-rose-600 hover:to-rose-700 transition-all duration-200 shadow-lg hover:shadow-xl text-xs sm:text-sm w-full sm:w-auto"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
<Trash2 className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
Delete Selected ({selectedRooms.length})
|
||||
</button>
|
||||
)}
|
||||
@@ -591,16 +591,16 @@ const RoomManagementPage: React.FC = () => {
|
||||
resetForm();
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
className="flex items-center justify-center gap-2 px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg sm:rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl text-xs sm:text-sm w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<Plus className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
Add Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
|
||||
|
||||
1563
Frontend/src/pages/admin/SecurityManagementPage.tsx
Normal file
1563
Frontend/src/pages/admin/SecurityManagementPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -115,32 +115,32 @@ const ServiceManagementPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
<div className="space-y-4 sm:space-y-6 md:space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-3 sm:-m-4 md:-m-6 p-4 sm:p-6 md:p-8">
|
||||
{}
|
||||
<div className="flex justify-between items-center animate-fade-in">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4 animate-fade-in">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-2">
|
||||
<div className="h-1 w-12 sm:w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-xl sm:text-2xl md:text-2xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
Service Management
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-3 text-lg font-light">Manage hotel services</p>
|
||||
<p className="text-slate-600 mt-2 sm:mt-3 text-xs sm:text-sm md:text-sm font-light">Manage hotel services</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
className="flex items-center justify-center gap-2 px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg sm:rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl text-xs sm:text-sm w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<Plus className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
Add Service
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
398
Frontend/src/pages/admin/TaskManagementPage.tsx
Normal file
398
Frontend/src/pages/admin/TaskManagementPage.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
CheckSquare,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Plus,
|
||||
Filter,
|
||||
Search,
|
||||
Calendar,
|
||||
User,
|
||||
Building2,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
TrendingUp,
|
||||
MoreVertical,
|
||||
Edit,
|
||||
Trash2,
|
||||
Play,
|
||||
Pause,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
import taskService, { Task, TaskStatistics } from '../../services/api/taskService';
|
||||
import { formatDate } from '../../utils/format';
|
||||
import TaskDetailModal from '../../components/tasks/TaskDetailModal';
|
||||
import CreateTaskModal from '../../components/tasks/CreateTaskModal';
|
||||
import TaskFilters from '../../components/tasks/TaskFilters';
|
||||
|
||||
type TaskStatus = 'pending' | 'assigned' | 'in_progress' | 'completed' | 'cancelled' | 'overdue';
|
||||
type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
|
||||
const TaskManagementPage: React.FC = () => {
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [showTaskDetail, setShowTaskDetail] = useState(false);
|
||||
const [showCreateTask, setShowCreateTask] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
status: '' as string,
|
||||
priority: '' as string,
|
||||
task_type: '',
|
||||
assigned_to: '',
|
||||
search: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
const { data: tasks, loading: tasksLoading, execute: fetchTasks } = useAsync<Task[]>(
|
||||
() => taskService.getTasks({
|
||||
status: filters.status || undefined,
|
||||
priority: filters.priority || undefined,
|
||||
task_type: filters.task_type || undefined,
|
||||
assigned_to: filters.assigned_to ? parseInt(filters.assigned_to) : undefined,
|
||||
skip: (currentPage - 1) * itemsPerPage,
|
||||
limit: itemsPerPage,
|
||||
}).then(r => {
|
||||
// Handle response structure: { status: 'success', data: [...] }
|
||||
// apiClient returns axios response, so r.data is the response body
|
||||
const responseData = r.data;
|
||||
const tasksArray = responseData?.data || responseData || [];
|
||||
return Array.isArray(tasksArray) ? tasksArray : [];
|
||||
}).catch(error => {
|
||||
console.error('Error fetching tasks:', error);
|
||||
return [];
|
||||
}),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const { data: statistics, loading: statsLoading, execute: fetchStatistics } = useAsync<TaskStatistics>(
|
||||
() => taskService.getTaskStatistics().then(r => r.data),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const handleTaskClick = async (task: Task) => {
|
||||
try {
|
||||
const response = await taskService.getTask(task.id);
|
||||
setSelectedTask(response.data.data);
|
||||
setShowTaskDetail(true);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to load task details');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskComplete = async (taskId: number) => {
|
||||
try {
|
||||
await taskService.completeTask(taskId);
|
||||
toast.success('Task completed successfully');
|
||||
fetchTasks();
|
||||
fetchStatistics();
|
||||
if (selectedTask?.id === taskId) {
|
||||
setShowTaskDetail(false);
|
||||
setSelectedTask(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to complete task');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskStart = async (taskId: number) => {
|
||||
try {
|
||||
await taskService.startTask(taskId);
|
||||
toast.success('Task started');
|
||||
fetchTasks();
|
||||
if (selectedTask?.id === taskId) {
|
||||
const response = await taskService.getTask(taskId);
|
||||
setSelectedTask(response.data.data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to start task');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: TaskStatus) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-5 h-5 text-green-500" />;
|
||||
case 'in_progress':
|
||||
return <Play className="w-5 h-5 text-blue-500" />;
|
||||
case 'overdue':
|
||||
return <AlertCircle className="w-5 h-5 text-red-500" />;
|
||||
case 'cancelled':
|
||||
return <XCircle className="w-5 h-5 text-gray-400" />;
|
||||
default:
|
||||
return <Clock className="w-5 h-5 text-amber-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: TaskStatus) => {
|
||||
const baseClasses = 'px-3 py-1 rounded-full text-xs font-semibold';
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return `${baseClasses} bg-green-100 text-green-800 border border-green-200`;
|
||||
case 'in_progress':
|
||||
return `${baseClasses} bg-blue-100 text-blue-800 border border-blue-200`;
|
||||
case 'overdue':
|
||||
return `${baseClasses} bg-red-100 text-red-800 border border-red-200`;
|
||||
case 'cancelled':
|
||||
return `${baseClasses} bg-gray-100 text-gray-800 border border-gray-200`;
|
||||
case 'assigned':
|
||||
return `${baseClasses} bg-purple-100 text-purple-800 border border-purple-200`;
|
||||
default:
|
||||
return `${baseClasses} bg-amber-100 text-amber-800 border border-amber-200`;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: TaskPriority) => {
|
||||
const baseClasses = 'px-2 py-1 rounded text-xs font-semibold';
|
||||
switch (priority) {
|
||||
case 'urgent':
|
||||
return `${baseClasses} bg-red-100 text-red-800 border border-red-200`;
|
||||
case 'high':
|
||||
return `${baseClasses} bg-orange-100 text-orange-800 border border-orange-200`;
|
||||
case 'medium':
|
||||
return `${baseClasses} bg-yellow-100 text-yellow-800 border border-yellow-200`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 text-gray-800 border border-gray-200`;
|
||||
}
|
||||
};
|
||||
|
||||
const isOverdue = (dueDate?: string) => {
|
||||
if (!dueDate) return false;
|
||||
return new Date(dueDate) < new Date() && !selectedTask?.completed_at;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Task Management</h1>
|
||||
<p className="text-gray-600">Manage and track all tasks and workflows</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateTask(true)}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
{statistics && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Total Tasks</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{statistics.total}</p>
|
||||
</div>
|
||||
<CheckSquare className="w-8 h-8 text-indigo-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">In Progress</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{statistics.in_progress}</p>
|
||||
</div>
|
||||
<Play className="w-8 h-8 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Completed</p>
|
||||
<p className="text-2xl font-bold text-green-600">{statistics.completed}</p>
|
||||
</div>
|
||||
<CheckCircle2 className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Overdue</p>
|
||||
<p className="text-2xl font-bold text-red-600">{statistics.overdue}</p>
|
||||
</div>
|
||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<TaskFilters filters={filters} onFiltersChange={setFilters} />
|
||||
|
||||
{/* Tasks List */}
|
||||
{tasksLoading ? (
|
||||
<Loading fullScreen text="Loading tasks..." />
|
||||
) : !tasks || tasks.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 border border-gray-100">
|
||||
<EmptyState
|
||||
title="No tasks found"
|
||||
description="Create a new task or adjust your filters"
|
||||
action={{
|
||||
label: 'Create Task',
|
||||
onClick: () => setShowCreateTask(true),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gradient-to-r from-gray-50 to-gray-100">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Task</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Priority</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Assigned To</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Due Date</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{(tasks || []).map((task) => (
|
||||
<tr
|
||||
key={task.id}
|
||||
className="hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
onClick={() => handleTaskClick(task)}
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(task.status)}
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">{task.title}</p>
|
||||
{task.description && (
|
||||
<p className="text-xs text-gray-500 line-clamp-1">{task.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={getStatusBadge(task.status)}>{task.status.replace('_', ' ')}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={getPriorityBadge(task.priority)}>{task.priority}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{task.assigned_to_name ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-700">{task.assigned_to_name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">Unassigned</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{task.due_date ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className={`w-4 h-4 ${isOverdue(task.due_date) ? 'text-red-500' : 'text-gray-400'}`} />
|
||||
<span className={`text-sm ${isOverdue(task.due_date) ? 'text-red-600 font-semibold' : 'text-gray-700'}`}>
|
||||
{formatDate(new Date(task.due_date), 'short')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">No due date</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{task.status === 'assigned' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTaskStart(task.id);
|
||||
}}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="Start Task"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{task.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTaskComplete(task.id);
|
||||
}}
|
||||
className="p-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
||||
title="Complete Task"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{tasks && tasks.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-4 py-2 bg-white border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">Page {currentPage}</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => p + 1)}
|
||||
disabled={tasks.length < itemsPerPage}
|
||||
className="px-4 py-2 bg-white border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Detail Modal */}
|
||||
{showTaskDetail && selectedTask && (
|
||||
<TaskDetailModal
|
||||
task={selectedTask}
|
||||
onClose={() => {
|
||||
setShowTaskDetail(false);
|
||||
setSelectedTask(null);
|
||||
}}
|
||||
onUpdate={() => {
|
||||
fetchTasks();
|
||||
fetchStatistics();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Task Modal */}
|
||||
{showCreateTask && (
|
||||
<CreateTaskModal
|
||||
onClose={() => setShowCreateTask(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreateTask(false);
|
||||
fetchTasks();
|
||||
fetchStatistics();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskManagementPage;
|
||||
|
||||
@@ -189,26 +189,26 @@ const UserManagementPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
<div className="space-y-4 sm:space-y-6 md:space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-3 sm:-m-4 md:-m-6 p-4 sm:p-6 md:p-8">
|
||||
{}
|
||||
<div className="flex justify-between items-center animate-fade-in">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4 animate-fade-in">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-2">
|
||||
<div className="h-1 w-12 sm:w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-xl sm:text-2xl md:text-2xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
User Management
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-3 text-lg font-light">Manage accounts and permissions</p>
|
||||
<p className="text-slate-600 mt-2 sm:mt-3 text-xs sm:text-sm md:text-sm font-light">Manage accounts and permissions</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
className="flex items-center gap-2 px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg sm:rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl text-xs sm:text-sm w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<Plus className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
@@ -339,7 +339,7 @@ const UserManagementPage: React.FC = () => {
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-6 py-5 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-amber-100">
|
||||
<h2 className="text-lg sm:text-xl md:text-xl font-bold text-amber-100">
|
||||
{editingUser ? 'Update User' : 'Add New User'}
|
||||
</h2>
|
||||
<p className="text-amber-200/80 text-sm font-light mt-1">
|
||||
|
||||
215
Frontend/src/pages/admin/WorkflowManagementPage.tsx
Normal file
215
Frontend/src/pages/admin/WorkflowManagementPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
import workflowService, { Workflow } from '../../services/api/workflowService';
|
||||
import WorkflowBuilder from '../../components/workflows/WorkflowBuilder';
|
||||
import WorkflowDetailModal from '../../components/workflows/WorkflowDetailModal';
|
||||
|
||||
const WorkflowManagementPage: React.FC = () => {
|
||||
const [showBuilder, setShowBuilder] = useState(false);
|
||||
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
|
||||
const [selectedWorkflow, setSelectedWorkflow] = useState<Workflow | null>(null);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
|
||||
const { data: workflows, loading, execute: fetchWorkflows } = useAsync<Workflow[]>(
|
||||
() => workflowService.getWorkflows().then(r => r.data.data || []),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this workflow?')) return;
|
||||
|
||||
try {
|
||||
await workflowService.deleteWorkflow(id);
|
||||
toast.success('Workflow deleted successfully');
|
||||
fetchWorkflows();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to delete workflow');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (workflow: Workflow) => {
|
||||
setEditingWorkflow(workflow);
|
||||
setShowBuilder(true);
|
||||
};
|
||||
|
||||
const handleView = async (workflow: Workflow) => {
|
||||
try {
|
||||
const response = await workflowService.getWorkflow(workflow.id);
|
||||
setSelectedWorkflow(response.data.data);
|
||||
setShowDetail(true);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to load workflow details');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <CheckCircle2 className="w-5 h-5 text-green-500" />;
|
||||
case 'inactive':
|
||||
return <Clock className="w-5 h-5 text-amber-500" />;
|
||||
default:
|
||||
return <XCircle className="w-5 h-5 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
pre_arrival: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
room_preparation: 'bg-green-100 text-green-800 border-green-200',
|
||||
maintenance: 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
guest_communication: 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
follow_up: 'bg-pink-100 text-pink-800 border-pink-200',
|
||||
custom: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
};
|
||||
return colors[type] || colors.custom;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Workflow Management</h1>
|
||||
<p className="text-gray-600">Create and manage automated workflows</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingWorkflow(null);
|
||||
setShowBuilder(true);
|
||||
}}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Workflow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflows List */}
|
||||
{loading ? (
|
||||
<Loading fullScreen text="Loading workflows..." />
|
||||
) : !workflows || !Array.isArray(workflows) || workflows.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 border border-gray-100">
|
||||
<EmptyState
|
||||
title="No workflows found"
|
||||
description="Create your first workflow to automate tasks"
|
||||
action={{
|
||||
label: 'Create Workflow',
|
||||
onClick: () => setShowBuilder(true),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{workflows.map((workflow) => (
|
||||
<div
|
||||
key={workflow.id}
|
||||
className="bg-white rounded-xl shadow-lg p-6 border border-gray-100 hover:shadow-xl transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(workflow.status)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{workflow.name}</h3>
|
||||
<span className={`px-2 py-1 rounded text-xs font-semibold border ${getTypeColor(workflow.workflow_type)}`}>
|
||||
{workflow.workflow_type.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{workflow.description && (
|
||||
<p className="text-sm text-gray-600 mb-4 line-clamp-2">{workflow.description}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">Trigger:</span>
|
||||
<span className="font-medium text-gray-700">{workflow.trigger.replace('_', ' ')}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">Steps:</span>
|
||||
<span className="font-medium text-gray-700">{workflow.steps.length}</span>
|
||||
</div>
|
||||
{workflow.sla_hours && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">SLA:</span>
|
||||
<span className="font-medium text-gray-700">{workflow.sla_hours} hours</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => handleView(workflow)}
|
||||
className="flex-1 px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(workflow)}
|
||||
className="flex-1 px-3 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(workflow.id)}
|
||||
className="px-3 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow Builder Modal */}
|
||||
{showBuilder && (
|
||||
<WorkflowBuilder
|
||||
workflow={editingWorkflow}
|
||||
onClose={() => {
|
||||
setShowBuilder(false);
|
||||
setEditingWorkflow(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowBuilder(false);
|
||||
setEditingWorkflow(null);
|
||||
fetchWorkflows();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Workflow Detail Modal */}
|
||||
{showDetail && selectedWorkflow && (
|
||||
<WorkflowDetailModal
|
||||
workflow={selectedWorkflow}
|
||||
onClose={() => {
|
||||
setShowDetail(false);
|
||||
setSelectedWorkflow(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowManagementPage;
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
export { default as DashboardPage } from './DashboardPage';
|
||||
export { default as RoomManagementPage } from './RoomManagementPage';
|
||||
/**
|
||||
* Admin Pages
|
||||
*
|
||||
* All pages accessible only to administrators
|
||||
*/
|
||||
|
||||
export { default as AdminDashboardPage } from './DashboardPage';
|
||||
export { default as UserManagementPage } from './UserManagementPage';
|
||||
export { default as GuestProfilePage } from './GuestProfilePage';
|
||||
export { default as BookingManagementPage } from './BookingManagementPage';
|
||||
export { default as GroupBookingManagementPage } from './GroupBookingManagementPage';
|
||||
export { default as BusinessDashboardPage } from './BusinessDashboardPage';
|
||||
export { default as ReceptionDashboardPage } from './ReceptionDashboardPage';
|
||||
export { default as AdvancedRoomManagementPage } from './AdvancedRoomManagementPage';
|
||||
export { default as PageContentDashboardPage } from './PageContentDashboard';
|
||||
export { default as AnalyticsDashboardPage } from './AnalyticsDashboardPage';
|
||||
export { default as TaskManagementPage } from './TaskManagementPage';
|
||||
export { default as WorkflowManagementPage } from './WorkflowManagementPage';
|
||||
export { default as NotificationManagementPage } from './NotificationManagementPage';
|
||||
export { default as SettingsPage } from './SettingsPage';
|
||||
export { default as InvoiceManagementPage } from './InvoiceManagementPage';
|
||||
export { default as PaymentManagementPage } from './PaymentManagementPage';
|
||||
export { default as ServiceManagementPage } from './ServiceManagementPage';
|
||||
export { default as ReviewManagementPage } from './ReviewManagementPage';
|
||||
export { default as PromotionManagementPage } from './PromotionManagementPage';
|
||||
export { default as CheckInPage } from './CheckInPage';
|
||||
export { default as CheckOutPage } from './CheckOutPage';
|
||||
export { default as AuditLogsPage } from './AuditLogsPage';
|
||||
export { default as CurrencySettingsPage } from './CurrencySettingsPage';
|
||||
export { default as CookieSettingsPage } from './CookieSettingsPage';
|
||||
export { default as StripeSettingsPage } from './StripeSettingsPage';
|
||||
export { default as LoyaltyManagementPage } from './LoyaltyManagementPage';
|
||||
export { default as BookingManagementPage } from './BookingManagementPage';
|
||||
export { default as RatePlanManagementPage } from './RatePlanManagementPage';
|
||||
export { default as PackageManagementPage } from './PackageManagementPage';
|
||||
export { default as SecurityManagementPage } from './SecurityManagementPage';
|
||||
export { default as EmailCampaignManagementPage } from './EmailCampaignManagementPage';
|
||||
|
||||
163
Frontend/src/pages/customer/BoricaReturnPage.tsx
Normal file
163
Frontend/src/pages/customer/BoricaReturnPage.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { confirmBoricaPayment } from '../../services/api/paymentService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
const BoricaReturnPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const bookingId = searchParams.get('bookingId');
|
||||
|
||||
useEffect(() => {
|
||||
const confirmPayment = async () => {
|
||||
if (!bookingId) {
|
||||
setError('Missing booking information');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Extract all parameters from URL (Borica sends POST data, but we'll handle GET params)
|
||||
const responseData: Record<string, string> = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
responseData[key] = value;
|
||||
});
|
||||
|
||||
// If no params, try to get from POST data (would need server-side handling)
|
||||
// For now, we'll use the bookingId to fetch payment status
|
||||
if (Object.keys(responseData).length === 0 || !responseData.ORDER) {
|
||||
// Try to confirm with booking ID only
|
||||
// In a real implementation, Borica would POST the data to a server endpoint
|
||||
setError('Payment response data not found. Please check your payment status.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await confirmBoricaPayment(responseData);
|
||||
|
||||
if (response.success) {
|
||||
setSuccess(true);
|
||||
toast.success('Payment confirmed successfully!');
|
||||
|
||||
setTimeout(() => {
|
||||
navigate(`/bookings/${bookingId}`);
|
||||
}, 2000);
|
||||
} else {
|
||||
setError(response.message || 'Payment confirmation failed');
|
||||
toast.error(response.message || 'Payment confirmation failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Failed to confirm payment';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
confirmPayment();
|
||||
}, [bookingId, searchParams, navigate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl mx-auto text-center
|
||||
backdrop-blur-xl shadow-2xl shadow-black/20">
|
||||
<Loader2 className="w-12 h-12 sm:w-16 sm:h-16 animate-spin text-[#d4af37] mx-auto mb-4" />
|
||||
<h1 className="text-xl sm:text-2xl font-serif font-semibold text-white mb-2">
|
||||
Processing Payment...
|
||||
</h1>
|
||||
<p className="text-gray-400 text-sm sm:text-base">
|
||||
Please wait while we confirm your payment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl mx-auto text-center
|
||||
backdrop-blur-xl shadow-2xl shadow-black/20">
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-green-500/20 to-green-600/20
|
||||
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
|
||||
border border-green-500/30 shadow-lg shadow-green-500/20">
|
||||
<CheckCircle className="w-10 h-10 sm:w-12 sm:h-12 text-green-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-green-300 mb-3 tracking-wide">
|
||||
Payment Successful!
|
||||
</h1>
|
||||
<p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide px-2">
|
||||
Your payment has been confirmed. Redirecting to booking details...
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate(`/bookings/${bookingId}`)}
|
||||
className="bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] px-6 py-2 sm:px-8 sm:py-3 rounded-sm
|
||||
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||
transition-all duration-300 font-medium tracking-wide
|
||||
shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base w-full sm:w-auto"
|
||||
>
|
||||
View Booking
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-red-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl mx-auto text-center
|
||||
backdrop-blur-xl shadow-2xl shadow-black/20">
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-red-500/20 to-red-600/20
|
||||
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
|
||||
border border-red-500/30 shadow-lg shadow-red-500/20">
|
||||
<XCircle className="w-10 h-10 sm:w-12 sm:h-12 text-red-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-red-300 mb-3 tracking-wide">
|
||||
Payment Failed
|
||||
</h1>
|
||||
<p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide px-2">
|
||||
{error || 'There was an issue processing your payment. Please try again or contact support.'}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => navigate(`/bookings/${bookingId}`)}
|
||||
className="bg-gray-700/50 text-gray-300 px-6 py-2 sm:px-8 sm:py-3 rounded-sm
|
||||
hover:bg-gray-700 transition-all duration-300 font-medium tracking-wide
|
||||
text-sm sm:text-base w-full sm:w-auto"
|
||||
>
|
||||
View Booking
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] px-6 py-2 sm:px-8 sm:py-3 rounded-sm
|
||||
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||
transition-all duration-300 font-medium tracking-wide
|
||||
shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base w-full sm:w-auto"
|
||||
>
|
||||
Go Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoricaReturnPage;
|
||||
|
||||
179
Frontend/src/pages/customer/GroupBookingPage.tsx
Normal file
179
Frontend/src/pages/customer/GroupBookingPage.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Users, Calendar, Building2, DollarSign, CheckCircle, ArrowRight } from 'lucide-react';
|
||||
import { groupBookingService, GroupBooking } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { formatDate } from '../../utils/format';
|
||||
import CreateGroupBookingModal from '../../components/shared/CreateGroupBookingModal';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const GroupBookingPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [groupBookings, setGroupBookings] = useState<GroupBooking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroupBookings();
|
||||
}, []);
|
||||
|
||||
const fetchGroupBookings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await groupBookingService.getMyGroupBookings();
|
||||
setGroupBookings(response.data.group_bookings);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load group bookings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string }> = {
|
||||
draft: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Draft' },
|
||||
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Pending' },
|
||||
confirmed: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Confirmed' },
|
||||
partially_confirmed: { bg: 'bg-purple-100', text: 'text-purple-800', label: 'Partially Confirmed' },
|
||||
checked_in: { bg: 'bg-green-100', text: 'text-green-800', label: 'Checked In' },
|
||||
checked_out: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Checked Out' },
|
||||
cancelled: { bg: 'bg-red-100', text: 'text-red-800', label: 'Cancelled' },
|
||||
};
|
||||
const badge = badges[status] || badges.draft;
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Group Bookings</h1>
|
||||
<p className="text-gray-600">
|
||||
Manage your group bookings and coordinate room blocks for your group
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 shadow-lg"
|
||||
>
|
||||
<Users className="w-5 h-5" />
|
||||
Create Group Booking
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groupBookings.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
|
||||
<Users className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">No Group Bookings Yet</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Create your first group booking to block multiple rooms for your group
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Create Group Booking
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6">
|
||||
{groupBookings.map((booking) => (
|
||||
<div
|
||||
key={booking.id}
|
||||
className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => navigate(`/admin/group-bookings?view=${booking.id}`)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-1">
|
||||
{booking.group_name || booking.group_booking_number}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">Booking #{booking.group_booking_number}</p>
|
||||
{booking.group_type && (
|
||||
<span className="inline-block mt-2 px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-xs">
|
||||
{booking.group_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{getStatusBadge(booking.status)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Check-in</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{formatDate(booking.check_in_date, 'short')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Check-out</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{formatDate(booking.check_out_date, 'short')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Rooms / Guests</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{booking.total_rooms} rooms • {booking.total_guests} guests
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Total Price</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(booking.total_price)}
|
||||
</p>
|
||||
{booking.discount_amount > 0 && (
|
||||
<p className="text-sm text-green-600">
|
||||
Saved {formatCurrency(booking.discount_amount)} with group discount
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-blue-600">
|
||||
<span className="text-sm font-medium">View Details</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Group Booking Modal */}
|
||||
<CreateGroupBookingModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreateModal(false);
|
||||
fetchGroupBookings();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupBookingPage;
|
||||
|
||||
24
Frontend/src/pages/customer/index.ts
Normal file
24
Frontend/src/pages/customer/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Customer Pages
|
||||
*
|
||||
* All pages accessible to customers
|
||||
*/
|
||||
|
||||
export { default as CustomerDashboardPage } from './DashboardPage';
|
||||
export { default as RoomListPage } from './RoomListPage';
|
||||
export { default as RoomDetailPage } from './RoomDetailPage';
|
||||
export { default as SearchResultsPage } from './SearchResultsPage';
|
||||
export { default as FavoritesPage } from './FavoritesPage';
|
||||
export { default as MyBookingsPage } from './MyBookingsPage';
|
||||
export { default as BookingSuccessPage } from './BookingSuccessPage';
|
||||
export { default as BookingDetailPage } from './BookingDetailPage';
|
||||
export { default as FullPaymentPage } from './FullPaymentPage';
|
||||
export { default as PaymentConfirmationPage } from './PaymentConfirmationPage';
|
||||
export { default as PaymentResultPage } from './PaymentResultPage';
|
||||
export { default as PayPalReturnPage } from './PayPalReturnPage';
|
||||
export { default as PayPalCancelPage } from './PayPalCancelPage';
|
||||
export { default as InvoicePage } from './InvoicePage';
|
||||
export { default as ProfilePage } from './ProfilePage';
|
||||
export { default as LoyaltyPage } from './LoyaltyPage';
|
||||
export { default as GroupBookingPage } from './GroupBookingPage';
|
||||
|
||||
394
Frontend/src/pages/staff/AdvancedRoomManagementPage.tsx
Normal file
394
Frontend/src/pages/staff/AdvancedRoomManagementPage.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Hotel,
|
||||
Wrench,
|
||||
Sparkles,
|
||||
ClipboardCheck,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Crown,
|
||||
Calendar,
|
||||
Clock,
|
||||
MapPin,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import advancedRoomService, {
|
||||
RoomStatusBoardItem,
|
||||
} from '../../services/api/advancedRoomService';
|
||||
import { roomService } from '../../services/api';
|
||||
import MaintenanceManagement from '../../components/shared/MaintenanceManagement';
|
||||
import HousekeepingManagement from '../../components/shared/HousekeepingManagement';
|
||||
import InspectionManagement from '../../components/shared/InspectionManagement';
|
||||
|
||||
type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections';
|
||||
|
||||
const AdvancedRoomManagementPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('status-board');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rooms, setRooms] = useState<RoomStatusBoardItem[]>([]);
|
||||
const [selectedFloor, setSelectedFloor] = useState<number | null>(null);
|
||||
const [floors, setFloors] = useState<number[]>([]);
|
||||
const [expandedRooms, setExpandedRooms] = useState<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoomStatusBoard();
|
||||
fetchFloors();
|
||||
}, [selectedFloor]);
|
||||
|
||||
const fetchRoomStatusBoard = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await advancedRoomService.getRoomStatusBoard(selectedFloor || undefined);
|
||||
if (response.status === 'success') {
|
||||
setRooms(response.data.rooms);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to fetch room status board');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFloors = async () => {
|
||||
try {
|
||||
const response = await roomService.getRooms({ limit: 1000, page: 1 });
|
||||
if (response.data?.rooms) {
|
||||
const uniqueFloors = Array.from(
|
||||
new Set(response.data.rooms.map((r: any) => r.floor).filter((f: any) => f != null))
|
||||
).sort((a: any, b: any) => a - b) as number[];
|
||||
setFloors(uniqueFloors);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch floors:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRoomExpansion = (roomId: number) => {
|
||||
const newExpanded = new Set(expandedRooms);
|
||||
if (newExpanded.has(roomId)) {
|
||||
newExpanded.delete(roomId);
|
||||
} else {
|
||||
newExpanded.add(roomId);
|
||||
}
|
||||
setExpandedRooms(newExpanded);
|
||||
};
|
||||
|
||||
// Group rooms by floor
|
||||
const roomsByFloor = useMemo(() => {
|
||||
const grouped: Record<number, RoomStatusBoardItem[]> = {};
|
||||
rooms.forEach(room => {
|
||||
if (!grouped[room.floor]) {
|
||||
grouped[room.floor] = [];
|
||||
}
|
||||
grouped[room.floor].push(room);
|
||||
});
|
||||
return grouped;
|
||||
}, [rooms]);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return {
|
||||
bg: 'bg-gradient-to-br from-emerald-50 via-green-50 to-emerald-100',
|
||||
border: 'border-emerald-300/50',
|
||||
text: 'text-emerald-800',
|
||||
badge: 'bg-gradient-to-r from-emerald-500 to-green-600 text-white',
|
||||
shadow: 'shadow-emerald-200/50'
|
||||
};
|
||||
case 'occupied':
|
||||
return {
|
||||
bg: 'bg-gradient-to-br from-blue-50 via-indigo-50 to-blue-100',
|
||||
border: 'border-blue-300/50',
|
||||
text: 'text-blue-800',
|
||||
badge: 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white',
|
||||
shadow: 'shadow-blue-200/50'
|
||||
};
|
||||
case 'maintenance':
|
||||
return {
|
||||
bg: 'bg-gradient-to-br from-red-50 via-rose-50 to-red-100',
|
||||
border: 'border-red-300/50',
|
||||
text: 'text-red-800',
|
||||
badge: 'bg-gradient-to-r from-red-500 to-rose-600 text-white',
|
||||
shadow: 'shadow-red-200/50'
|
||||
};
|
||||
case 'cleaning':
|
||||
return {
|
||||
bg: 'bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-100',
|
||||
border: 'border-amber-300/50',
|
||||
text: 'text-amber-800',
|
||||
badge: 'bg-gradient-to-r from-amber-500 to-yellow-600 text-white',
|
||||
shadow: 'shadow-amber-200/50'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-gradient-to-br from-gray-50 via-slate-50 to-gray-100',
|
||||
border: 'border-gray-300/50',
|
||||
text: 'text-gray-800',
|
||||
badge: 'bg-gradient-to-r from-gray-500 to-slate-600 text-white',
|
||||
shadow: 'shadow-gray-200/50'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const iconClass = "w-5 h-5";
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return <CheckCircle className={iconClass} />;
|
||||
case 'occupied':
|
||||
return <Users className={iconClass} />;
|
||||
case 'maintenance':
|
||||
return <Wrench className={iconClass} />;
|
||||
case 'cleaning':
|
||||
return <Sparkles className={iconClass} />;
|
||||
default:
|
||||
return <AlertTriangle className={iconClass} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return 'Available';
|
||||
case 'occupied':
|
||||
return 'Occupied';
|
||||
case 'maintenance':
|
||||
return 'Maintenance';
|
||||
case 'cleaning':
|
||||
return 'Cleaning';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && rooms.length === 0) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Advanced Room Management</h1>
|
||||
<p className="text-gray-600">Manage room status, maintenance, housekeeping, and inspections</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{[
|
||||
{ id: 'status-board' as Tab, label: 'Room Status Board', icon: Hotel },
|
||||
{ id: 'maintenance' as Tab, label: 'Maintenance', icon: Wrench },
|
||||
{ id: 'housekeeping' as Tab, label: 'Housekeeping', icon: Sparkles },
|
||||
{ id: 'inspections' as Tab, label: 'Inspections', icon: ClipboardCheck },
|
||||
].map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
flex items-center space-x-2 py-4 px-1 border-b-2 font-medium text-sm
|
||||
${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Status Board Tab */}
|
||||
{activeTab === 'status-board' && (
|
||||
<div className="space-y-8">
|
||||
{/* Header Controls */}
|
||||
<div className="flex items-center justify-between bg-gradient-to-r from-slate-50 to-gray-50 rounded-xl p-4 border border-slate-200/50 shadow-sm">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-3 bg-white rounded-lg px-4 py-2 shadow-sm border border-slate-200">
|
||||
<Filter className="w-5 h-5 text-slate-600" />
|
||||
<select
|
||||
value={selectedFloor || ''}
|
||||
onChange={(e) => setSelectedFloor(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="border-0 bg-transparent text-sm font-medium text-slate-700 focus:outline-none focus:ring-0 cursor-pointer"
|
||||
>
|
||||
<option value="">All Floors</option>
|
||||
{floors.map((floor) => (
|
||||
<option key={floor} value={floor}>
|
||||
Floor {floor}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
<span className="font-semibold text-slate-900">{rooms.length}</span> rooms
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchRoomStatusBoard}
|
||||
className="flex items-center space-x-2 px-5 py-2.5 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg hover:from-blue-700 hover:to-indigo-700 transition-all duration-200 shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Floors Display */}
|
||||
{Object.keys(roomsByFloor).length === 0 ? (
|
||||
<div className="text-center py-16 bg-gradient-to-br from-slate-50 to-gray-50 rounded-2xl border border-slate-200">
|
||||
<Hotel className="w-20 h-20 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-slate-500 text-lg font-medium">No rooms found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{Object.entries(roomsByFloor)
|
||||
.sort(([a], [b]) => parseInt(b) - parseInt(a))
|
||||
.map(([floor, floorRooms]) => (
|
||||
<div key={floor} className="space-y-4">
|
||||
{/* Floor Header */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-1 h-12 bg-gradient-to-b from-blue-600 to-indigo-600 rounded-full"></div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 flex items-center space-x-2">
|
||||
<MapPin className="w-6 h-6 text-blue-600" />
|
||||
<span>Floor {floor}</span>
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 mt-0.5">
|
||||
{floorRooms.length} {floorRooms.length === 1 ? 'room' : 'rooms'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-slate-200 via-slate-300 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
{/* Rooms Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{floorRooms.map((room) => {
|
||||
const statusColors = getStatusColor(room.status);
|
||||
return (
|
||||
<div
|
||||
key={room.id}
|
||||
className={`
|
||||
group relative overflow-hidden rounded-xl border-2 transition-all duration-300
|
||||
${statusColors.bg} ${statusColors.border}
|
||||
hover:shadow-xl hover:scale-[1.02] cursor-pointer
|
||||
${expandedRooms.has(room.id) ? 'shadow-lg' : 'shadow-md'}
|
||||
`}
|
||||
onClick={() => toggleRoomExpansion(room.id)}
|
||||
>
|
||||
{/* Status Badge */}
|
||||
<div className={`absolute top-3 right-3 px-3 py-1 rounded-full text-xs font-semibold shadow-lg ${statusColors.badge} flex items-center space-x-1.5`}>
|
||||
{getStatusIcon(room.status)}
|
||||
<span>{getStatusLabel(room.status)}</span>
|
||||
</div>
|
||||
|
||||
{/* Room Content */}
|
||||
<div className="p-5 pt-4">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-2xl font-bold text-slate-900 font-mono">{room.room_number}</h3>
|
||||
{room.room_type && (
|
||||
<span className="text-xs font-medium text-slate-600 bg-white/60 px-2 py-1 rounded">
|
||||
{room.room_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedRooms.has(room.id) && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-300/30 space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
{room.current_booking && (
|
||||
<div className="bg-white/60 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center space-x-2 text-sm font-semibold text-slate-700">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>Guest</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-900">{room.current_booking.guest_name}</p>
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>Check-out: {new Date(room.current_booking.check_out).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{room.active_maintenance && (
|
||||
<div className="bg-red-50/80 rounded-lg p-3 space-y-2 border border-red-200/50">
|
||||
<div className="flex items-center space-x-2 text-sm font-semibold text-red-800">
|
||||
<Wrench className="w-4 h-4" />
|
||||
<span>Maintenance</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-900">{room.active_maintenance.title}</p>
|
||||
<p className="text-xs text-red-700 capitalize">{room.active_maintenance.type}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{room.pending_housekeeping_count > 0 && (
|
||||
<div className="bg-amber-50/80 rounded-lg p-3 space-y-2 border border-amber-200/50">
|
||||
<div className="flex items-center space-x-2 text-sm font-semibold text-amber-800">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>Housekeeping</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-amber-900">
|
||||
{room.pending_housekeeping_count} pending {room.pending_housekeeping_count === 1 ? 'task' : 'tasks'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!room.current_booking && !room.active_maintenance && room.pending_housekeeping_count === 0 && (
|
||||
<div className="text-center py-3">
|
||||
<CheckCircle className="w-8 h-8 text-emerald-500 mx-auto mb-2" />
|
||||
<p className="text-sm text-slate-600 font-medium">All Clear</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapse Indicator */}
|
||||
<div className="mt-4 flex justify-center">
|
||||
{expandedRooms.has(room.id) ? (
|
||||
<ChevronUp className="w-5 h-5 text-slate-500 group-hover:text-slate-700 transition-colors" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-slate-500 group-hover:text-slate-700 transition-colors" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Corner */}
|
||||
<div className={`absolute bottom-0 right-0 w-20 h-20 ${statusColors.bg} opacity-10 rounded-tl-full`}></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Maintenance Tab */}
|
||||
{activeTab === 'maintenance' && <MaintenanceManagement />}
|
||||
|
||||
{/* Housekeeping Tab */}
|
||||
{activeTab === 'housekeeping' && <HousekeepingManagement />}
|
||||
|
||||
{/* Inspections Tab */}
|
||||
{activeTab === 'inspections' && <InspectionManagement />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedRoomManagementPage;
|
||||
|
||||
1803
Frontend/src/pages/staff/AnalyticsDashboardPage.tsx
Normal file
1803
Frontend/src/pages/staff/AnalyticsDashboardPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
707
Frontend/src/pages/staff/BookingManagementPage.tsx
Normal file
707
Frontend/src/pages/staff/BookingManagementPage.tsx
Normal file
@@ -0,0 +1,707 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Search, Eye, XCircle, CheckCircle, Loader2, FileText, Plus } from 'lucide-react';
|
||||
import { bookingService, Booking, invoiceService } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../utils/format';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CreateBookingModal from '../../components/shared/CreateBookingModal';
|
||||
|
||||
const BookingManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const navigate = useNavigate();
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [updatingBookingId, setUpdatingBookingId] = useState<number | null>(null);
|
||||
const [cancellingBookingId, setCancellingBookingId] = useState<number | null>(null);
|
||||
const [creatingInvoice, setCreatingInvoice] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
status: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBookings();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchBookings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await bookingService.getAllBookings({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setBookings(response.data.bookings);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load bookings list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (id: number, status: string) => {
|
||||
try {
|
||||
setUpdatingBookingId(id);
|
||||
await bookingService.updateBooking(id, { status } as any);
|
||||
toast.success('Status updated successfully');
|
||||
await fetchBookings();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to update status');
|
||||
} finally {
|
||||
setUpdatingBookingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelBooking = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to cancel this booking?')) return;
|
||||
|
||||
try {
|
||||
setCancellingBookingId(id);
|
||||
await bookingService.cancelBooking(id);
|
||||
toast.success('Booking cancelled successfully');
|
||||
await fetchBookings();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to cancel booking');
|
||||
} finally {
|
||||
setCancellingBookingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateInvoice = async (bookingId: number) => {
|
||||
try {
|
||||
setCreatingInvoice(true);
|
||||
|
||||
const invoiceData = {
|
||||
booking_id: Number(bookingId),
|
||||
};
|
||||
|
||||
const response = await invoiceService.createInvoice(invoiceData);
|
||||
|
||||
if (response.status === 'success' && response.data?.invoice) {
|
||||
toast.success('Invoice created successfully!');
|
||||
setShowDetailModal(false);
|
||||
navigate(`/staff/invoices/${response.data.invoice.id}`);
|
||||
} else {
|
||||
throw new Error('Failed to create invoice');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || 'Unable to create invoice';
|
||||
toast.error(errorMessage);
|
||||
console.error('Invoice creation error:', error);
|
||||
} finally {
|
||||
setCreatingInvoice(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
pending: {
|
||||
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
|
||||
text: 'text-amber-800',
|
||||
label: 'Pending confirmation',
|
||||
border: 'border-amber-200'
|
||||
},
|
||||
confirmed: {
|
||||
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
|
||||
text: 'text-blue-800',
|
||||
label: 'Confirmed',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
checked_in: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: 'Checked in',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
checked_out: {
|
||||
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
|
||||
text: 'text-slate-700',
|
||||
label: 'Checked out',
|
||||
border: 'border-slate-200'
|
||||
},
|
||||
cancelled: {
|
||||
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
|
||||
text: 'text-rose-800',
|
||||
label: '❌ Canceled',
|
||||
border: 'border-rose-200'
|
||||
},
|
||||
};
|
||||
const badge = badges[status] || badges.pending;
|
||||
return (
|
||||
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border ${badge.bg} ${badge.text} ${badge.border} shadow-sm`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
{/* Header with Create Button */}
|
||||
<div className="animate-fade-in">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-3xl sm:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
Booking Management
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-3 text-base sm:text-lg font-light">Manage and track all hotel bookings with precision</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl whitespace-nowrap w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Booking
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by booking number, guest name..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="w-full px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="pending">Pending confirmation</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="checked_in">Checked in</option>
|
||||
<option value="checked_out">Checked out</option>
|
||||
<option value="cancelled">Canceled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Booking Number</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Customer</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Room</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Check-in/out</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Total Price</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Status</th>
|
||||
<th className="px-8 py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-100">
|
||||
{bookings.map((booking, index) => (
|
||||
<tr
|
||||
key={booking.id}
|
||||
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
|
||||
style={{ animationDelay: `${index * 0.05}s` }}
|
||||
>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-bold text-slate-900 group-hover:text-amber-700 transition-colors font-mono">
|
||||
{booking.booking_number}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-slate-900">{booking.guest_info?.full_name || booking.user?.name}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{booking.guest_info?.email || booking.user?.email}</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-slate-800">
|
||||
<span className="text-amber-600 font-semibold">Room {booking.room?.room_number}</span>
|
||||
<span className="text-slate-400 mx-2">•</span>
|
||||
<span className="text-slate-600">{booking.room?.room_type?.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-slate-900">
|
||||
{parseDateLocal(booking.check_in_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
→ {parseDateLocal(booking.check_out_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{(() => {
|
||||
const completedPayments = booking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
) || [];
|
||||
const amountPaid = completedPayments.reduce(
|
||||
(sum, p) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
const remainingDue = booking.total_price - amountPaid;
|
||||
const hasPayments = completedPayments.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm font-bold text-slate-900 bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">
|
||||
{formatCurrency(booking.total_price)}
|
||||
</div>
|
||||
{hasPayments && (
|
||||
<div className="text-xs mt-1">
|
||||
<div className="text-green-600 font-medium">
|
||||
Paid: {formatCurrency(amountPaid)}
|
||||
</div>
|
||||
{remainingDue > 0 && (
|
||||
<div className="text-amber-600 font-medium">
|
||||
Due: {formatCurrency(remainingDue)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{getStatusBadge(booking.status)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedBooking(booking);
|
||||
setShowDetailModal(true);
|
||||
}}
|
||||
className="p-2 rounded-lg text-slate-600 hover:text-amber-600 hover:bg-amber-50 transition-all duration-200 shadow-sm hover:shadow-md border border-slate-200 hover:border-amber-300"
|
||||
title="View details"
|
||||
>
|
||||
<Eye className="w-5 h-5" />
|
||||
</button>
|
||||
{booking.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(booking.id, 'confirmed')}
|
||||
disabled={updatingBookingId === booking.id || cancellingBookingId === booking.id}
|
||||
className="p-2 rounded-lg text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow-md border border-emerald-200 hover:border-emerald-300"
|
||||
title="Confirm"
|
||||
>
|
||||
{updatingBookingId === booking.id ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCancelBooking(booking.id)}
|
||||
disabled={updatingBookingId === booking.id || cancellingBookingId === booking.id}
|
||||
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
|
||||
title="Cancel"
|
||||
>
|
||||
{cancellingBookingId === booking.id ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{booking.status === 'confirmed' && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(booking.id, 'checked_in')}
|
||||
disabled={updatingBookingId === booking.id || cancellingBookingId === booking.id}
|
||||
className="p-2 rounded-lg text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow-md border border-emerald-200 hover:border-emerald-300"
|
||||
title="Check-in"
|
||||
>
|
||||
{updatingBookingId === booking.id ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{showDetailModal && selectedBooking && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 animate-fade-in">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden animate-scale-in border border-slate-200">
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-amber-100 mb-1">Booking Details</h2>
|
||||
<p className="text-amber-200/80 text-sm font-light">Comprehensive booking information</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDetailModal(false)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="p-8 overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
<div className="space-y-6">
|
||||
{}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Booking Number</label>
|
||||
<p className="text-xl font-bold text-slate-900 font-mono">{selectedBooking.booking_number}</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Status</label>
|
||||
<div className="mt-1">{getStatusBadge(selectedBooking.status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-amber-50/50 to-yellow-50/50 p-6 rounded-xl border border-amber-100">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-amber-400 to-amber-600 rounded-full"></div>
|
||||
Customer Information
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-bold text-slate-900">{selectedBooking.guest_info?.full_name || selectedBooking.user?.name}</p>
|
||||
<p className="text-slate-600">{selectedBooking.guest_info?.email || selectedBooking.user?.email}</p>
|
||||
<p className="text-slate-600">{selectedBooking.guest_info?.phone || selectedBooking.user?.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-blue-50/50 to-indigo-50/50 p-6 rounded-xl border border-blue-100">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-blue-400 to-blue-600 rounded-full"></div>
|
||||
Room Information
|
||||
</label>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
<span className="text-amber-600">Room {selectedBooking.room?.room_number}</span>
|
||||
<span className="text-slate-400 mx-2">•</span>
|
||||
<span className="text-slate-700">{selectedBooking.room?.room_type?.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Check-in Date</label>
|
||||
<p className="text-base font-semibold text-slate-900">{parseDateLocal(selectedBooking.check_in_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Check-out Date</label>
|
||||
<p className="text-base font-semibold text-slate-900">{parseDateLocal(selectedBooking.check_out_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Number of Guests</label>
|
||||
<p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-indigo-50/50 to-purple-50/50 p-6 rounded-xl border border-indigo-100">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-indigo-400 to-indigo-600 rounded-full"></div>
|
||||
Payment Information
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Payment Method</p>
|
||||
<p className="text-base font-semibold text-slate-900">
|
||||
{selectedBooking.payment_method === 'cash'
|
||||
? '💵 Pay at Hotel'
|
||||
: selectedBooking.payment_method === 'stripe'
|
||||
? '💳 Stripe (Card)'
|
||||
: selectedBooking.payment_method === 'paypal'
|
||||
? '💳 PayPal'
|
||||
: selectedBooking.payment_method || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Payment Status</p>
|
||||
<p className={`text-base font-semibold ${
|
||||
selectedBooking.payment_status === 'paid'
|
||||
? 'text-green-600'
|
||||
: selectedBooking.payment_status === 'refunded'
|
||||
? 'text-orange-600'
|
||||
: 'text-red-600'
|
||||
}`}>
|
||||
{selectedBooking.payment_status === 'paid'
|
||||
? '✅ Paid'
|
||||
: selectedBooking.payment_status === 'refunded'
|
||||
? '💰 Refunded'
|
||||
: '❌ Unpaid'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{(selectedBooking as any).service_usages && (selectedBooking as any).service_usages.length > 0 && (
|
||||
<div className="bg-gradient-to-br from-purple-50/50 to-pink-50/50 p-6 rounded-xl border border-purple-100">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-purple-400 to-purple-600 rounded-full"></div>
|
||||
Additional Services
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{(selectedBooking as any).service_usages.map((service: any, idx: number) => (
|
||||
<div key={service.id || idx} className="flex justify-between items-center py-2 border-b border-purple-100 last:border-0">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{service.service_name || service.name || 'Service'}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatCurrency(service.unit_price || service.price || 0)} × {service.quantity || 1}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{formatCurrency(service.total_price || (service.unit_price || service.price || 0) * (service.quantity || 1))}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{}
|
||||
{(() => {
|
||||
const completedPayments = selectedBooking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
) || [];
|
||||
const allPayments = selectedBooking.payments || [];
|
||||
const amountPaid = completedPayments.reduce(
|
||||
(sum, p) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
const remainingDue = selectedBooking.total_price - amountPaid;
|
||||
const hasPayments = allPayments.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasPayments && (
|
||||
<div className="bg-gradient-to-br from-teal-50/50 to-cyan-50/50 p-6 rounded-xl border border-teal-100">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-teal-400 to-teal-600 rounded-full"></div>
|
||||
Payment History
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{allPayments.map((payment: any, idx: number) => (
|
||||
<div key={payment.id || idx} className="p-3 bg-white rounded-lg border border-teal-100">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{formatCurrency(payment.amount || 0)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{payment.payment_type === 'deposit' ? 'Deposit (20%)' : payment.payment_type === 'remaining' ? 'Remaining Payment' : 'Full Payment'}
|
||||
{' • '}
|
||||
{payment.payment_method === 'stripe' ? 'Stripe' : payment.payment_method === 'paypal' ? 'PayPal' : payment.payment_method || 'Cash'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
|
||||
payment.payment_status === 'completed' || payment.payment_status === 'paid'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: payment.payment_status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{payment.payment_status === 'completed' || payment.payment_status === 'paid' ? 'Paid' : payment.payment_status || 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
{payment.transaction_id && (
|
||||
<p className="text-xs text-slate-400 font-mono">ID: {payment.transaction_id}</p>
|
||||
)}
|
||||
{payment.payment_date && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{new Date(payment.payment_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-green-50 via-emerald-50 to-green-50 p-6 rounded-xl border-2 border-green-200 shadow-lg mb-4">
|
||||
<label className="text-xs font-semibold text-green-700 uppercase tracking-wider mb-2 block">Amount Paid</label>
|
||||
<p className="text-3xl font-bold bg-gradient-to-r from-green-600 via-emerald-700 to-green-600 bg-clip-text text-transparent">
|
||||
{formatCurrency(amountPaid)}
|
||||
</p>
|
||||
{hasPayments && completedPayments.length > 0 && (
|
||||
<p className="text-xs text-green-600 mt-2">
|
||||
{completedPayments.length} payment{completedPayments.length !== 1 ? 's' : ''} completed
|
||||
{amountPaid > 0 && selectedBooking.total_price > 0 && (
|
||||
<span className="ml-2">
|
||||
({((amountPaid / selectedBooking.total_price) * 100).toFixed(0)}% of total)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{amountPaid === 0 && !hasPayments && (
|
||||
<p className="text-sm text-gray-500 mt-2">No payments made yet</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
{remainingDue > 0 && (
|
||||
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg mb-4">
|
||||
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Remaining Due (To be paid)</label>
|
||||
<p className="text-3xl font-bold text-amber-600">
|
||||
{formatCurrency(remainingDue)}
|
||||
</p>
|
||||
{selectedBooking.total_price > 0 && (
|
||||
<p className="text-xs text-amber-600 mt-2">
|
||||
({((remainingDue / selectedBooking.total_price) * 100).toFixed(0)}% of total)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-slate-50 to-gray-50 p-6 rounded-xl border-2 border-slate-200 shadow-lg">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2 block">Total Booking Price</label>
|
||||
<p className="text-2xl font-bold text-slate-700">
|
||||
{formatCurrency(selectedBooking.total_price)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
This is the total amount for the booking
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-6 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-slate-400 to-slate-600 rounded-full"></div>
|
||||
Booking Metadata
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedBooking.createdAt && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Created At</p>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{new Date(selectedBooking.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedBooking.updatedAt && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Last Updated</p>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{new Date(selectedBooking.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedBooking.requires_deposit !== undefined && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Deposit Required</p>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{selectedBooking.requires_deposit ? 'Yes (20%)' : 'No'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedBooking.deposit_paid !== undefined && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Deposit Paid</p>
|
||||
<p className={`text-sm font-medium ${selectedBooking.deposit_paid ? 'text-green-600' : 'text-amber-600'}`}>
|
||||
{selectedBooking.deposit_paid ? '✅ Yes' : '❌ No'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{selectedBooking.notes && (
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-6 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3 block">Special Notes</label>
|
||||
<p className="text-slate-700 leading-relaxed">{selectedBooking.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="mt-8 pt-6 border-t border-slate-200 flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => handleCreateInvoice(selectedBooking.id)}
|
||||
disabled={creatingInvoice}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
{creatingInvoice ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Creating Invoice...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-5 h-5" />
|
||||
Create Invoice
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDetailModal(false)}
|
||||
className="px-8 py-3 bg-gradient-to-r from-slate-700 to-slate-800 text-white rounded-xl font-semibold hover:from-slate-800 hover:to-slate-900 transition-all duration-200 shadow-lg hover:shadow-xl border border-slate-600"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Booking Modal */}
|
||||
<CreateBookingModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreateModal(false);
|
||||
fetchBookings();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingManagementPage;
|
||||
1319
Frontend/src/pages/staff/GuestProfilePage.tsx
Normal file
1319
Frontend/src/pages/staff/GuestProfilePage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1161
Frontend/src/pages/staff/LoyaltyManagementPage.tsx
Normal file
1161
Frontend/src/pages/staff/LoyaltyManagementPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
317
Frontend/src/pages/staff/PaymentManagementPage.tsx
Normal file
317
Frontend/src/pages/staff/PaymentManagementPage.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { paymentService } from '../../services/api';
|
||||
import type { Payment } from '../../services/api/paymentService';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import { ExportButton } from '../../components/common';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { formatDate } from '../../utils/format';
|
||||
|
||||
const PaymentManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
method: '',
|
||||
from: '',
|
||||
to: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPayments();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchPayments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await paymentService.getPayments({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setPayments(response.data.payments);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load payments list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getMethodBadge = (method: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
cash: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: 'Cash',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
bank_transfer: {
|
||||
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
|
||||
text: 'text-blue-800',
|
||||
label: 'Bank transfer',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
stripe: {
|
||||
bg: 'bg-gradient-to-r from-indigo-50 to-purple-50',
|
||||
text: 'text-indigo-800',
|
||||
label: 'Stripe',
|
||||
border: 'border-indigo-200'
|
||||
},
|
||||
paypal: {
|
||||
bg: 'bg-gradient-to-r from-blue-50 to-cyan-50',
|
||||
text: 'text-blue-800',
|
||||
label: 'PayPal',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
credit_card: {
|
||||
bg: 'bg-gradient-to-r from-purple-50 to-pink-50',
|
||||
text: 'text-purple-800',
|
||||
label: 'Credit card',
|
||||
border: 'border-purple-200'
|
||||
},
|
||||
};
|
||||
const badge = badges[method] || badges.cash;
|
||||
return (
|
||||
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getPaymentStatusBadge = (status: string) => {
|
||||
const statusConfig: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
completed: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: '✅ Paid',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
pending: {
|
||||
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
|
||||
text: 'text-amber-800',
|
||||
label: '⏳ Pending',
|
||||
border: 'border-amber-200'
|
||||
},
|
||||
failed: {
|
||||
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
|
||||
text: 'text-rose-800',
|
||||
label: '❌ Failed',
|
||||
border: 'border-rose-200'
|
||||
},
|
||||
refunded: {
|
||||
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
|
||||
text: 'text-slate-700',
|
||||
label: '💰 Refunded',
|
||||
border: 'border-slate-200'
|
||||
},
|
||||
};
|
||||
const config = statusConfig[status] || statusConfig.pending;
|
||||
return (
|
||||
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${config.bg} ${config.text} ${config.border}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
{}
|
||||
<div className="animate-fade-in">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
Payment Management
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-3 text-lg font-light">Track payment transactions</p>
|
||||
</div>
|
||||
<ExportButton
|
||||
data={payments.map(p => ({
|
||||
'Transaction ID': p.transaction_id || `PAY-${p.id}`,
|
||||
'Booking Number': p.booking?.booking_number || 'N/A',
|
||||
'Customer': p.booking?.user?.full_name || p.booking?.user?.email || 'N/A',
|
||||
'Payment Method': p.payment_method || 'N/A',
|
||||
'Payment Type': p.payment_type || 'N/A',
|
||||
'Amount': formatCurrency(p.amount || 0),
|
||||
'Status': p.payment_status,
|
||||
'Payment Date': p.payment_date ? formatDate(p.payment_date) : 'N/A',
|
||||
'Created At': p.created_at ? formatDate(p.created_at) : 'N/A'
|
||||
}))}
|
||||
filename="payments"
|
||||
title="Payment Transactions Report"
|
||||
customHeaders={{
|
||||
'Transaction ID': 'Transaction ID',
|
||||
'Booking Number': 'Booking Number',
|
||||
'Customer': 'Customer',
|
||||
'Payment Method': 'Payment Method',
|
||||
'Payment Type': 'Payment Type',
|
||||
'Amount': 'Amount',
|
||||
'Status': 'Status',
|
||||
'Payment Date': 'Payment Date',
|
||||
'Created At': 'Created At'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.method}
|
||||
onChange={(e) => setFilters({ ...filters, method: e.target.value })}
|
||||
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<option value="">All methods</option>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="stripe">Stripe</option>
|
||||
<option value="credit_card">Credit card</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.from}
|
||||
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
|
||||
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
|
||||
placeholder="From date"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.to}
|
||||
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
|
||||
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
|
||||
placeholder="To date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Transaction ID</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Booking Number</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Customer</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Method</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Type</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Amount</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Payment Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-100">
|
||||
{payments.map((payment, index) => (
|
||||
<tr
|
||||
key={payment.id}
|
||||
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
|
||||
style={{ animationDelay: `${index * 0.05}s` }}
|
||||
>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-bold text-slate-900 font-mono">{payment.transaction_id || `PAY-${payment.id}`}</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-amber-600">{payment.booking?.booking_number}</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-slate-900">{payment.booking?.user?.name}</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{getMethodBadge(payment.payment_method)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{payment.payment_type === 'deposit' ? (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
|
||||
Deposit (20%)
|
||||
</span>
|
||||
) : payment.payment_type === 'remaining' ? (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 border border-blue-200">
|
||||
Remaining
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
|
||||
Full Payment
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{getPaymentStatusBadge(payment.payment_status)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||
{formatCurrency(payment.amount)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm text-slate-600">
|
||||
{new Date(payment.payment_date || payment.createdAt || '').toLocaleDateString('en-US')}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-amber-500 via-amber-600 to-amber-700 rounded-2xl shadow-2xl p-8 text-white animate-fade-in" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2 text-amber-100">Total Revenue</h3>
|
||||
<p className="text-4xl font-bold">
|
||||
{formatCurrency(payments
|
||||
.filter(p => p.payment_status === 'completed')
|
||||
.reduce((sum, p) => sum + p.amount, 0))}
|
||||
</p>
|
||||
<p className="text-sm mt-3 text-amber-100/90">
|
||||
Total {payments.filter(p => p.payment_status === 'completed').length} paid transaction{payments.filter(p => p.payment_status === 'completed').length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/20 backdrop-blur-sm p-6 rounded-2xl">
|
||||
<div className="text-5xl font-bold text-white/80">{payments.filter(p => p.payment_status === 'completed').length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentManagementPage;
|
||||
3262
Frontend/src/pages/staff/ReceptionDashboardPage.tsx
Normal file
3262
Frontend/src/pages/staff/ReceptionDashboardPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
16
Frontend/src/pages/staff/index.ts
Normal file
16
Frontend/src/pages/staff/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Staff Pages
|
||||
*
|
||||
* All pages accessible only to staff members
|
||||
*/
|
||||
|
||||
export { default as StaffDashboardPage } from './DashboardPage';
|
||||
export { default as ChatManagementPage } from './ChatManagementPage';
|
||||
export { default as BookingManagementPage } from './BookingManagementPage';
|
||||
export { default as ReceptionDashboardPage } from './ReceptionDashboardPage';
|
||||
export { default as PaymentManagementPage } from './PaymentManagementPage';
|
||||
export { default as AnalyticsDashboardPage } from './AnalyticsDashboardPage';
|
||||
export { default as LoyaltyManagementPage } from './LoyaltyManagementPage';
|
||||
export { default as GuestProfilePage } from './GuestProfilePage';
|
||||
export { default as AdvancedRoomManagementPage } from './AdvancedRoomManagementPage';
|
||||
|
||||
29
Frontend/src/routes/accountantRoutes.tsx
Normal file
29
Frontend/src/routes/accountantRoutes.tsx
Normal 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;
|
||||
|
||||
65
Frontend/src/routes/adminRoutes.tsx
Normal file
65
Frontend/src/routes/adminRoutes.tsx
Normal 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;
|
||||
|
||||
42
Frontend/src/routes/customerRoutes.tsx
Normal file
42
Frontend/src/routes/customerRoutes.tsx
Normal 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;
|
||||
|
||||
13
Frontend/src/routes/index.ts
Normal file
13
Frontend/src/routes/index.ts
Normal 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';
|
||||
|
||||
50
Frontend/src/routes/publicRoutes.tsx
Normal file
50
Frontend/src/routes/publicRoutes.tsx
Normal 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;
|
||||
|
||||
39
Frontend/src/routes/staffRoutes.tsx
Normal file
39
Frontend/src/routes/staffRoutes.tsx
Normal 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;
|
||||
|
||||
268
Frontend/src/services/api/advancedRoomService.ts
Normal file
268
Frontend/src/services/api/advancedRoomService.ts
Normal 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;
|
||||
|
||||
327
Frontend/src/services/api/analyticsService.ts
Normal file
327
Frontend/src/services/api/analyticsService.ts
Normal 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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
192
Frontend/src/services/api/emailCampaignService.ts
Normal file
192
Frontend/src/services/api/emailCampaignService.ts
Normal 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;
|
||||
|
||||
286
Frontend/src/services/api/groupBookingService.ts
Normal file
286
Frontend/src/services/api/groupBookingService.ts
Normal 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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
125
Frontend/src/services/api/notificationService.ts
Normal file
125
Frontend/src/services/api/notificationService.ts
Normal 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;
|
||||
|
||||
190
Frontend/src/services/api/packageService.ts
Normal file
190
Frontend/src/services/api/packageService.ts
Normal 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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
198
Frontend/src/services/api/ratePlanService.ts
Normal file
198
Frontend/src/services/api/ratePlanService.ts
Normal 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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
249
Frontend/src/services/api/securityService.ts
Normal file
249
Frontend/src/services/api/securityService.ts
Normal 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;
|
||||
|
||||
@@ -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>(
|
||||
|
||||
141
Frontend/src/services/api/taskService.ts
Normal file
141
Frontend/src/services/api/taskService.ts
Normal 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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
123
Frontend/src/services/api/workflowService.ts
Normal file
123
Frontend/src/services/api/workflowService.ts
Normal 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
Reference in New Issue
Block a user