This commit is contained in:
Iliyan Angelov
2025-12-04 01:07:34 +02:00
parent 5fb50983a9
commit 3d634b4fce
92 changed files with 9678 additions and 221 deletions

View File

@@ -63,6 +63,7 @@ const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
const LoyaltyPage = lazy(() => import('./pages/customer/LoyaltyPage'));
const GroupBookingPage = lazy(() => import('./pages/customer/GroupBookingPage'));
const ComplaintPage = lazy(() => import('./pages/customer/ComplaintPage'));
const GuestRequestsPage = lazy(() => import('./pages/customer/GuestRequestsPage'));
const GDPRPage = lazy(() => import('./pages/customer/GDPRPage'));
const GDPRDeletionConfirmPage = lazy(() => import('./pages/customer/GDPRDeletionConfirmPage'));
const AboutPage = lazy(() => import('./features/content/pages/AboutPage'));
@@ -118,6 +119,10 @@ const StaffLoyaltyManagementPage = lazy(() => import('./pages/staff/LoyaltyManag
const StaffGuestProfilePage = lazy(() => import('./pages/staff/GuestProfilePage'));
const StaffAdvancedRoomManagementPage = lazy(() => import('./pages/staff/AdvancedRoomManagementPage'));
const ChatManagementPage = lazy(() => import('./pages/staff/ChatManagementPage'));
const GuestRequestManagementPage = lazy(() => import('./pages/staff/GuestRequestManagementPage'));
const GuestCommunicationPage = lazy(() => import('./pages/staff/GuestCommunicationPage'));
const IncidentComplaintManagementPage = lazy(() => import('./pages/staff/IncidentComplaintManagementPage'));
const UpsellManagementPage = lazy(() => import('./pages/staff/UpsellManagementPage'));
const StaffLayout = lazy(() => import('./pages/StaffLayout'));
const AccountantDashboardPage = lazy(() => import('./pages/accountant/DashboardPage'));
@@ -452,6 +457,16 @@ function App() {
</ErrorBoundaryRoute>
}
/>
<Route
path="requests"
element={
<ErrorBoundaryRoute>
<CustomerRoute>
<GuestRequestsPage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
<Route
path="gdpr"
element={
@@ -714,6 +729,22 @@ function App() {
path="advanced-rooms"
element={<StaffAdvancedRoomManagementPage />}
/>
<Route
path="guest-requests"
element={<GuestRequestManagementPage />}
/>
<Route
path="guest-communication"
element={<GuestCommunicationPage />}
/>
<Route
path="incidents-complaints"
element={<IncidentComplaintManagementPage />}
/>
<Route
path="upsells"
element={<UpsellManagementPage />}
/>
<Route
path="profile"
element={<StaffProfilePage />}

View File

@@ -0,0 +1,80 @@
import apiClient from '../../../shared/services/apiClient';
export interface Complaint {
id: number;
guest_id: number;
guest_name?: string;
booking_id?: number;
room_id?: number;
room_number?: string;
category: string;
priority: string;
status: string;
title: string;
description: string;
attachments?: string[];
assigned_to?: number;
assigned_staff_name?: string;
resolved_at?: string;
resolution_notes?: string;
created_at: string;
updated_at: string;
}
export interface ComplaintUpdate {
id: number;
complaint_id: number;
update_type: string;
description: string;
updated_by: number;
updated_by_name?: string;
created_at: string;
}
const complaintService = {
async getComplaints(params?: {
status?: string;
priority?: string;
category?: string;
assigned_to?: number;
page?: number;
limit?: number;
}) {
const response = await apiClient.get('/complaints', { params });
return response.data;
},
async getComplaint(complaintId: number) {
const response = await apiClient.get(`/complaints/${complaintId}`);
return response.data;
},
async updateComplaint(complaintId: number, data: {
status?: string;
priority?: string;
assigned_to?: number;
resolution_notes?: string;
}) {
const response = await apiClient.put(`/complaints/${complaintId}`, data);
return response.data;
},
async resolveComplaint(complaintId: number, data: {
resolution_notes: string;
compensation_amount?: number;
}) {
const response = await apiClient.post(`/complaints/${complaintId}/resolve`, data);
return response.data;
},
async addComplaintUpdate(complaintId: number, data: {
description: string;
update_type?: string;
}) {
const response = await apiClient.post(`/complaints/${complaintId}/updates`, data);
return response.data;
},
};
export default complaintService;

View File

@@ -0,0 +1,82 @@
import apiClient from '../../../shared/services/apiClient';
export interface GuestRequest {
id: number;
booking_id: number;
room_id: number;
room_number?: string;
user_id: number;
guest_name?: string;
guest_email?: string;
request_type: string;
status: string;
priority: string;
title: string;
description?: string;
guest_notes?: string;
staff_notes?: string;
assigned_to?: number;
assigned_staff_name?: string;
fulfilled_by?: number;
fulfilled_staff_name?: string;
requested_at: string;
started_at?: string;
fulfilled_at?: string;
response_time_minutes?: number;
fulfillment_time_minutes?: number;
}
const guestRequestService = {
async getGuestRequests(params?: {
status?: string;
request_type?: string;
room_id?: number;
assigned_to?: number;
priority?: string;
page?: number;
limit?: number;
}) {
const response = await apiClient.get('/guest-requests', { params });
return response.data;
},
async getGuestRequest(requestId: number) {
const response = await apiClient.get(`/guest-requests/${requestId}`);
return response.data;
},
async createGuestRequest(data: {
booking_id: number;
room_id: number;
request_type: string;
title: string;
description?: string;
priority?: string;
guest_notes?: string;
}) {
const response = await apiClient.post('/guest-requests', data);
return response.data;
},
async updateGuestRequest(requestId: number, data: {
status?: string;
assigned_to?: number;
staff_notes?: string;
}) {
const response = await apiClient.put(`/guest-requests/${requestId}`, data);
return response.data;
},
async assignRequest(requestId: number) {
const response = await apiClient.post(`/guest-requests/${requestId}/assign`);
return response.data;
},
async fulfillRequest(requestId: number, staff_notes?: string) {
const response = await apiClient.post(`/guest-requests/${requestId}/fulfill`, { staff_notes });
return response.data;
},
};
export default guestRequestService;

View File

@@ -240,7 +240,7 @@ const HousekeepingManagement: React.FC = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingTask) {
if (editingTask && editingTask.id) {
// For staff, only allow updating status and checklist items
if (!isAdmin) {
const data = {

View File

@@ -290,6 +290,7 @@ const MaintenanceManagement: React.FC = () => {
<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">Reported By</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>
@@ -317,6 +318,9 @@ const MaintenanceManagement: React.FC = () => {
{record.priority}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{record.reported_by_name || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(record.scheduled_start).toLocaleDateString()}
</td>
@@ -620,12 +624,12 @@ const MaintenanceManagement: React.FC = () => {
<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>
<label className="block text-sm font-medium text-gray-500">Description</label>
<p className="mt-1 text-sm text-gray-900 whitespace-pre-wrap">
{viewingRecord.description || 'No description provided'}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
@@ -642,6 +646,21 @@ const MaintenanceManagement: React.FC = () => {
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{viewingRecord.reported_by_name && (
<div>
<label className="block text-sm font-medium text-gray-500">Reported By</label>
<p className="mt-1 text-sm text-gray-900">{viewingRecord.reported_by_name}</p>
</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>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Scheduled Start</label>
@@ -655,10 +674,17 @@ const MaintenanceManagement: React.FC = () => {
)}
</div>
{viewingRecord.assigned_staff_name && (
{viewingRecord.notes && (
<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>
<label className="block text-sm font-medium text-gray-500">Notes</label>
<p className="mt-1 text-sm text-gray-900 whitespace-pre-wrap">{viewingRecord.notes}</p>
</div>
)}
{viewingRecord.completion_notes && (
<div>
<label className="block text-sm font-medium text-gray-500">Completion Notes</label>
<p className="mt-1 text-sm text-gray-900 whitespace-pre-wrap">{viewingRecord.completion_notes}</p>
</div>
)}

View File

@@ -0,0 +1,140 @@
import apiClient from '../../../shared/services/apiClient';
export interface InventoryItem {
id: number;
name: string;
description?: string;
category: string;
unit: string;
current_quantity: number;
minimum_quantity: number;
maximum_quantity?: number;
reorder_quantity?: number;
unit_cost?: number;
supplier?: string;
storage_location?: string;
is_active: boolean;
is_tracked: boolean;
barcode?: string;
sku?: string;
is_low_stock: boolean;
created_at?: string;
}
export interface InventoryTransaction {
id: number;
transaction_type: string;
quantity: number;
quantity_before: number;
quantity_after: number;
notes?: string;
cost?: number;
transaction_date: string;
}
export interface ReorderRequest {
id: number;
item_id: number;
item_name: string;
requested_quantity: number;
current_quantity: number;
minimum_quantity: number;
status: string;
priority: string;
notes?: string;
requested_at: string;
}
const inventoryService = {
async getInventoryItems(params?: {
category?: string;
low_stock?: boolean;
is_active?: boolean;
page?: number;
limit?: number;
}) {
const response = await apiClient.get('/inventory/items', { params });
return response.data;
},
async getInventoryItem(itemId: number) {
const response = await apiClient.get(`/inventory/items/${itemId}`);
return response.data;
},
async createInventoryItem(data: {
name: string;
description?: string;
category: string;
unit: string;
minimum_quantity: number;
maximum_quantity?: number;
reorder_quantity?: number;
unit_cost?: number;
supplier?: string;
supplier_contact?: string;
storage_location?: string;
barcode?: string;
sku?: string;
notes?: string;
}) {
const response = await apiClient.post('/inventory/items', data);
return response.data;
},
async updateInventoryItem(itemId: number, data: Partial<InventoryItem>) {
const response = await apiClient.put(`/inventory/items/${itemId}`, data);
return response.data;
},
async createTransaction(data: {
item_id: number;
transaction_type: string;
quantity: number;
notes?: string;
cost?: number;
reference_type?: string;
reference_id?: number;
}) {
const response = await apiClient.post('/inventory/transactions', data);
return response.data;
},
async getItemTransactions(itemId: number, params?: { page?: number; limit?: number }) {
const response = await apiClient.get(`/inventory/items/${itemId}/transactions`, { params });
return response.data;
},
async createReorderRequest(data: {
item_id: number;
requested_quantity: number;
priority?: string;
notes?: string;
}) {
const response = await apiClient.post('/inventory/reorder-requests', data);
return response.data;
},
async getReorderRequests(params?: { status?: string; page?: number; limit?: number }) {
const response = await apiClient.get('/inventory/reorder-requests', { params });
return response.data;
},
async recordTaskConsumption(data: {
task_id: number;
item_id: number;
quantity: number;
notes?: string;
}) {
const response = await apiClient.post('/inventory/task-consumption', data);
return response.data;
},
async getLowStockItems() {
const response = await apiClient.get('/inventory/low-stock');
return response.data;
},
};
export default inventoryService;

View File

@@ -15,6 +15,8 @@ export interface MaintenanceRecord {
actual_end?: string;
assigned_to?: number;
assigned_staff_name?: string;
reported_by?: number;
reported_by_name?: string;
priority: 'low' | 'medium' | 'high' | 'urgent';
blocks_room: boolean;
block_start?: string;
@@ -22,7 +24,9 @@ export interface MaintenanceRecord {
estimated_cost?: number;
actual_cost?: number;
notes?: string;
completion_notes?: string;
created_at: string;
updated_at?: string;
}
export interface HousekeepingTask {
@@ -44,6 +48,7 @@ export interface HousekeepingTask {
actual_duration_minutes?: number;
room_status?: 'available' | 'occupied' | 'maintenance' | 'cleaning';
is_room_status_only?: boolean; // Flag to indicate this is from room status, not a task
photos?: string[]; // Array of photo URLs
}
export interface ChecklistItem {
@@ -185,6 +190,7 @@ const advancedRoomService = {
date?: string;
page?: number;
limit?: number;
include_cleaning_rooms?: boolean;
}) {
const response = await apiClient.get('/advanced-rooms/housekeeping', { params });
return response.data;
@@ -212,11 +218,36 @@ const advancedRoomService = {
quality_score?: number;
inspected_by?: number;
inspection_notes?: string;
photos?: string[];
assigned_to?: number;
}) {
const response = await apiClient.put(`/advanced-rooms/housekeeping/${taskId}`, data);
return response.data;
},
async uploadHousekeepingTaskPhoto(taskId: number, file: File) {
const formData = new FormData();
formData.append('image', file);
const response = await apiClient.post(`/advanced-rooms/housekeeping/${taskId}/upload-photo`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
async reportMaintenanceIssue(taskId: number, data: {
title: string;
description: string;
maintenance_type?: 'corrective' | 'emergency';
priority?: 'low' | 'medium' | 'high' | 'urgent';
blocks_room?: boolean;
notes?: string;
}) {
const response = await apiClient.post(`/advanced-rooms/housekeeping/${taskId}/report-maintenance-issue`, data);
return response.data;
},
// Room Inspections
async getRoomInspections(params?: {
room_id?: number;

View File

@@ -0,0 +1,150 @@
import apiClient from '../../../shared/services/apiClient';
export interface StaffShift {
id: number;
staff_id: number;
staff_name?: string;
shift_date: string;
shift_type: 'morning' | 'afternoon' | 'night' | 'full_day' | 'custom';
start_time: string;
end_time: string;
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'no_show';
actual_start_time?: string;
actual_end_time?: string;
break_duration_minutes?: number;
department?: string;
notes?: string;
handover_notes?: string;
tasks_completed?: number;
tasks_assigned?: number;
assigned_by_name?: string;
}
export interface StaffTask {
id: number;
shift_id?: number;
staff_id: number;
staff_name?: string;
title: string;
description?: string;
task_type: string;
priority: 'low' | 'normal' | 'high' | 'urgent';
status: 'pending' | 'assigned' | 'in_progress' | 'completed' | 'cancelled' | 'on_hold';
scheduled_start?: string;
scheduled_end?: string;
actual_start?: string;
actual_end?: string;
estimated_duration_minutes?: number;
actual_duration_minutes?: number;
due_date?: string;
related_booking_id?: number;
related_room_id?: number;
related_guest_request_id?: number;
related_maintenance_id?: number;
notes?: string;
completion_notes?: string;
assigned_by_name?: string;
}
const staffShiftService = {
async getShifts(params?: {
staff_id?: number;
shift_date?: string;
status?: string;
department?: string;
page?: number;
limit?: number;
}) {
const response = await apiClient.get('/staff-shifts', { params });
return response.data;
},
async getShift(shiftId: number) {
const response = await apiClient.get(`/staff-shifts/${shiftId}`);
return response.data;
},
async createShift(data: {
staff_id?: number;
shift_date: string;
shift_type: string;
start_time: string;
end_time: string;
status?: string;
break_duration_minutes?: number;
department?: string;
notes?: string;
}) {
const response = await apiClient.post('/staff-shifts', data);
return response.data;
},
async updateShift(shiftId: number, data: {
shift_date?: string;
shift_type?: string;
start_time?: string;
end_time?: string;
status?: string;
break_duration_minutes?: number;
department?: string;
notes?: string;
handover_notes?: string;
}) {
const response = await apiClient.put(`/staff-shifts/${shiftId}`, data);
return response.data;
},
async getTasks(params?: {
staff_id?: number;
shift_id?: number;
status?: string;
priority?: string;
page?: number;
limit?: number;
}) {
const response = await apiClient.get('/staff-shifts/tasks', { params });
return response.data;
},
async createTask(data: {
shift_id?: number;
staff_id?: number;
title: string;
description?: string;
task_type?: string;
priority?: string;
status?: string;
scheduled_start?: string;
scheduled_end?: string;
estimated_duration_minutes?: number;
due_date?: string;
related_booking_id?: number;
related_room_id?: number;
related_guest_request_id?: number;
related_maintenance_id?: number;
notes?: string;
}) {
const response = await apiClient.post('/staff-shifts/tasks', data);
return response.data;
},
async updateTask(taskId: number, data: {
title?: string;
description?: string;
priority?: string;
status?: string;
completion_notes?: string;
notes?: string;
}) {
const response = await apiClient.put(`/staff-shifts/tasks/${taskId}`, data);
return response.data;
},
async getWorkloadSummary(date?: string) {
const response = await apiClient.get('/staff-shifts/workload', { params: { date } });
return response.data;
},
};
export default staffShiftService;

View File

@@ -83,7 +83,7 @@ const AccountantDashboardPage: React.FC = () => {
if (response.success && response.data?.payments) {
setRecentPayments(response.data.payments);
// Calculate financial summary
const completedPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'completed' || p.payment_status === 'paid');
const completedPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'completed');
const pendingPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'pending');
const totalRevenue = completedPayments.reduce((sum: number, p: Payment) => sum + (p.amount || 0), 0);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { Search, Plus, Edit, Trash2, Eye, FileText, Filter } from 'lucide-react';
import invoiceService, { Invoice } from '../../features/payments/services/invoiceService';
import { toast } from 'react-toastify';

View File

@@ -680,8 +680,7 @@ const BannerManagementPage: React.FC = () => {
</form>
</div>
</div>
</div>
</div>
</div>
</div>
)}

View File

@@ -513,8 +513,7 @@ const PromotionManagementPage: React.FC = () => {
</form>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>

View File

@@ -0,0 +1,457 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Plus,
Clock,
CheckCircle,
XCircle,
AlertCircle,
Bell,
MessageSquare,
Package,
Wrench,
Sparkles,
RefreshCw,
Filter,
} from 'lucide-react';
import { toast } from 'react-toastify';
import guestRequestService, { GuestRequest } from '../../features/guestRequests/services/guestRequestService';
import { getMyBookings, Booking } from '../../features/bookings/services/bookingService';
import { formatDate, formatRelativeTime } from '../../shared/utils/format';
import { logger } from '../../shared/utils/logger';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import useAuthStore from '../../store/useAuthStore';
const GuestRequestsPage: React.FC = () => {
const { userInfo } = useAuthStore();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [requests, setRequests] = useState<GuestRequest[]>([]);
const [bookings, setBookings] = useState<Booking[]>([]);
const [showCreateModal, setShowCreateModal] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [filterStatus, setFilterStatus] = useState<string>('');
const [requestForm, setRequestForm] = useState({
booking_id: '',
room_id: '',
request_type: 'other',
title: '',
description: '',
priority: 'normal',
guest_notes: '',
});
const requestTypes = [
{ value: 'extra_towels', label: 'Extra Towels', icon: Package },
{ value: 'extra_pillows', label: 'Extra Pillows', icon: Package },
{ value: 'room_cleaning', label: 'Room Cleaning', icon: Sparkles },
{ value: 'turndown_service', label: 'Turndown Service', icon: Sparkles },
{ value: 'amenities', label: 'Amenities', icon: Package },
{ value: 'maintenance', label: 'Maintenance', icon: Wrench },
{ value: 'room_service', label: 'Room Service', icon: Bell },
{ value: 'other', label: 'Other', icon: MessageSquare },
];
useEffect(() => {
fetchData();
}, [filterStatus]);
const fetchData = async () => {
try {
setLoading(true);
// Fetch active bookings - only checked-in bookings can create requests
const bookingsResponse = await getMyBookings();
if (bookingsResponse.success && bookingsResponse.data?.bookings) {
// Only allow requests for checked-in bookings (guests must be in the room)
const checkedInBookings = bookingsResponse.data.bookings.filter(
(b: Booking) => b.status === 'checked_in'
);
setBookings(checkedInBookings);
}
// Fetch guest requests
const params: any = {};
if (filterStatus) {
params.status = filterStatus;
}
const requestsResponse = await guestRequestService.getGuestRequests(params);
if (requestsResponse.status === 'success' && requestsResponse.data?.requests) {
setRequests(requestsResponse.data.requests);
}
} catch (error: any) {
logger.error('Error fetching data', error);
toast.error('Failed to load requests');
} finally {
setLoading(false);
}
};
const handleCreateRequest = async () => {
if (!requestForm.booking_id || !requestForm.room_id || !requestForm.title.trim()) {
toast.error('Please fill in all required fields');
return;
}
try {
await guestRequestService.createGuestRequest({
booking_id: parseInt(requestForm.booking_id),
room_id: parseInt(requestForm.room_id),
request_type: requestForm.request_type,
title: requestForm.title,
description: requestForm.description,
priority: requestForm.priority,
guest_notes: requestForm.guest_notes,
});
toast.success('Request submitted successfully! Our staff will attend to it shortly.');
setShowCreateModal(false);
setRequestForm({
booking_id: '',
room_id: '',
request_type: 'other',
title: '',
description: '',
priority: 'normal',
guest_notes: '',
});
await fetchData();
} catch (error: any) {
logger.error('Error creating request', error);
toast.error(error.response?.data?.detail || 'Failed to submit request');
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'fulfilled':
return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'in_progress':
return <Clock className="w-5 h-5 text-blue-500" />;
case 'cancelled':
return <XCircle className="w-5 h-5 text-red-500" />;
default:
return <AlertCircle className="w-5 h-5 text-amber-500" />;
}
};
const getStatusBadge = (status: string) => {
const styles = {
pending: 'bg-amber-100 text-amber-800 border-amber-200',
in_progress: 'bg-blue-100 text-blue-800 border-blue-200',
fulfilled: 'bg-green-100 text-green-800 border-green-200',
cancelled: 'bg-red-100 text-red-800 border-red-200',
};
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${styles[status as keyof typeof styles] || styles.pending}`}>
{status.replace('_', ' ').toUpperCase()}
</span>
);
};
const getPriorityBadge = (priority: string) => {
const styles = {
low: 'bg-gray-100 text-gray-800',
normal: 'bg-blue-100 text-blue-800',
high: 'bg-orange-100 text-orange-800',
urgent: 'bg-red-100 text-red-800',
};
return (
<span className={`px-2 py-1 rounded text-xs font-medium ${styles[priority as keyof typeof styles] || styles.normal}`}>
{priority.toUpperCase()}
</span>
);
};
const getRequestTypeLabel = (type: string) => {
return requestTypes.find(t => t.value === type)?.label || type.replace('_', ' ');
};
if (loading) {
return <Loading />;
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Guest Requests</h1>
<p className="text-gray-600">Submit and track your service requests</p>
</div>
<button
onClick={() => {
if (bookings.length === 0) {
toast.info('You need to be checked in to submit a request. Please check in first or contact reception.');
return;
}
setShowCreateModal(true);
}}
className="flex items-center space-x-2 px-6 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 shadow-lg hover:shadow-xl font-medium"
>
<Plus className="w-5 h-5" />
<span>New Request</span>
</button>
</div>
{/* Filters */}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Filter className="w-5 h-5 text-gray-500" />
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="fulfilled">Fulfilled</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<button
onClick={fetchData}
className="flex items-center space-x-2 px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<RefreshCw className="w-4 h-4" />
<span>Refresh</span>
</button>
</div>
</div>
{/* Info Banner */}
{bookings.length === 0 && (
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-sm font-semibold text-blue-900 mb-1">Check-in Required</h3>
<p className="text-sm text-blue-800">
You need to be checked in to submit service requests. Once you're in your room, you can request extra towels, room cleaning, maintenance, and more.
</p>
</div>
</div>
</div>
)}
{/* Requests List */}
{requests.length === 0 ? (
<EmptyState
icon={MessageSquare}
title={bookings.length === 0 ? "Check in to submit requests" : "No requests yet"}
description={bookings.length === 0
? "You need to be checked in to submit service requests. Please check in first or contact reception."
: "Submit your first request to get started"
}
actionLabel={bookings.length > 0 ? "Create Request" : undefined}
onAction={bookings.length > 0 ? () => setShowCreateModal(true) : undefined}
/>
) : (
<div className="grid gap-6">
{requests.map((request) => (
<div
key={request.id}
className="bg-white rounded-xl shadow-md hover:shadow-lg transition-shadow p-6 border border-gray-200"
>
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
{getStatusIcon(request.status)}
<h3 className="text-lg font-semibold text-gray-900">{request.title}</h3>
{getPriorityBadge(request.priority)}
</div>
<p className="text-sm text-gray-600 mb-2">
<span className="font-medium">Type:</span> {getRequestTypeLabel(request.request_type)}
{request.room_number && (
<>
{' '}
<span className="font-medium">Room:</span> {request.room_number}
</>
)}
</p>
{request.description && (
<p className="text-gray-700 mb-2">{request.description}</p>
)}
{request.staff_notes && (
<div className="mt-3 p-3 bg-blue-50 rounded-lg border border-blue-200">
<p className="text-sm font-medium text-blue-900 mb-1">Staff Response:</p>
<p className="text-sm text-blue-800">{request.staff_notes}</p>
</div>
)}
</div>
<div className="ml-4 text-right">
{getStatusBadge(request.status)}
<p className="text-xs text-gray-500 mt-2">
{formatRelativeTime(request.requested_at)}
</p>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
<div className="text-sm text-gray-600">
{request.assigned_staff_name && (
<span>Assigned to: <span className="font-medium">{request.assigned_staff_name}</span></span>
)}
</div>
{request.fulfilled_at && (
<div className="text-sm text-gray-600">
Fulfilled: {formatDate(request.fulfilled_at)}
</div>
)}
</div>
</div>
))}
</div>
)}
{/* Create Request Modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-2xl 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-xl font-bold text-gray-900">Create New Request</h2>
<button
onClick={() => setShowCreateModal(false)}
className="text-gray-400 hover:text-gray-600"
>
<XCircle className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Booking *
</label>
<select
value={requestForm.booking_id}
onChange={(e) => {
const booking = bookings.find(b => b.id === parseInt(e.target.value));
setRequestForm({
...requestForm,
booking_id: e.target.value,
room_id: booking?.room_id?.toString() || '',
});
setSelectedBooking(booking);
}}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]"
required
>
<option value="">Select Booking</option>
{bookings.map((booking) => (
<option key={booking.id} value={booking.id}>
Booking #{booking.booking_number} - Room {booking.room?.room_number || booking.room_id}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Request Type *
</label>
<select
value={requestForm.request_type}
onChange={(e) => setRequestForm({ ...requestForm, request_type: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]"
required
>
{requestTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Title *
</label>
<input
type="text"
value={requestForm.title}
onChange={(e) => setRequestForm({ ...requestForm, title: e.target.value })}
placeholder="Brief description of your request"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={requestForm.description}
onChange={(e) => setRequestForm({ ...requestForm, description: e.target.value })}
placeholder="Additional details about your request"
rows={4}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Priority
</label>
<select
value={requestForm.priority}
onChange={(e) => setRequestForm({ ...requestForm, priority: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]"
>
<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-medium text-gray-700 mb-1">
Additional Notes
</label>
<textarea
value={requestForm.guest_notes}
onChange={(e) => setRequestForm({ ...requestForm, guest_notes: e.target.value })}
placeholder="Any special instructions or preferences"
rows={3}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]"
/>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Cancel
</button>
<button
onClick={handleCreateRequest}
className="px-4 py-2 text-sm text-white bg-gradient-to-r from-[#d4af37] to-[#c9a227] rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-colors font-medium"
>
Submit Request
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default GuestRequestsPage;

File diff suppressed because it is too large Load Diff

View File

@@ -13,21 +13,29 @@ import {
ChevronUp,
Calendar,
MapPin,
ArrowRight,
Search,
X,
ArrowUpRight,
} from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { logger } from '../../shared/utils/logger';
import advancedRoomService, {
RoomStatusBoardItem,
OptimalRoomAssignmentRequest,
} from '../../features/rooms/services/advancedRoomService';
import roomService from '../../features/rooms/services/roomService';
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
import MaintenanceManagement from '../../features/hotel_services/components/MaintenanceManagement';
import HousekeepingManagement from '../../features/hotel_services/components/HousekeepingManagement';
import InspectionManagement from '../../features/hotel_services/components/InspectionManagement';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections';
type Tab = 'status-board' | 'room-assignment' | 'maintenance' | 'housekeeping' | 'inspections';
const AdvancedRoomManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [activeTab, setActiveTab] = useState<Tab>('status-board');
const [loading, setLoading] = useState(true);
const [rooms, setRooms] = useState<RoomStatusBoardItem[]>([]);
@@ -35,6 +43,17 @@ const AdvancedRoomManagementPage: React.FC = () => {
const [floors, setFloors] = useState<number[]>([]);
const [expandedRooms, setExpandedRooms] = useState<Set<number>>(new Set());
// Room assignment state
const [assigningRoom, setAssigningRoom] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [bookingSearch, setBookingSearch] = useState('');
const [bookingSearchResults, setBookingSearchResults] = useState<Booking[]>([]);
const [searchingBookings, setSearchingBookings] = useState(false);
const [availableRoomsForAssignment, setAvailableRoomsForAssignment] = useState<RoomStatusBoardItem[]>([]);
const [loadingAvailableRooms, setLoadingAvailableRooms] = useState(false);
const [showUpgradeSuggestions, setShowUpgradeSuggestions] = useState(false);
const [upgradeSuggestions, setUpgradeSuggestions] = useState<RoomStatusBoardItem[]>([]);
useEffect(() => {
fetchRoomStatusBoard();
fetchFloors();
@@ -166,6 +185,135 @@ const AdvancedRoomManagementPage: React.FC = () => {
}
};
// Room assignment handlers
const handleSearchBookings = async () => {
if (!bookingSearch.trim()) {
setBookingSearchResults([]);
return;
}
try {
setSearchingBookings(true);
const response = await bookingService.getAllBookings({
search: bookingSearch,
status: 'confirmed',
limit: 10,
});
setBookingSearchResults(response.data.bookings || []);
} catch (error: any) {
toast.error('Failed to search bookings');
logger.error('Error searching bookings', error);
} finally {
setSearchingBookings(false);
}
};
useEffect(() => {
const timeoutId = setTimeout(() => {
if (bookingSearch.trim()) {
handleSearchBookings();
} else {
setBookingSearchResults([]);
}
}, 300);
return () => clearTimeout(timeoutId);
}, [bookingSearch]);
const handleSelectBooking = async (booking: Booking) => {
setSelectedBooking(booking);
setBookingSearch('');
setBookingSearchResults([]);
// Fetch available rooms for the booking dates
try {
setLoadingAvailableRooms(true);
const response = await roomService.searchAvailableRooms({
from: booking.check_in_date,
to: booking.check_out_date,
limit: 100,
});
// Get room status board to match with available rooms
const statusBoardResponse = await advancedRoomService.getRoomStatusBoard();
const allRooms = statusBoardResponse.data.rooms || [];
// Filter to show only available rooms that match the booking's room type or better
const availableRooms = allRooms.filter(room => {
const isAvailable = room.status === 'available';
const matchesType = !booking.room?.room_type ||
room.room_type === booking.room.room_type.name ||
(room.room_type && booking.room.room_type);
return isAvailable && matchesType;
});
setAvailableRoomsForAssignment(availableRooms);
// Get upgrade suggestions (better room types)
const upgradeRooms = allRooms.filter(room => {
return room.status === 'available' &&
room.room_type &&
room.room_type !== booking.room?.room_type?.name;
});
setUpgradeSuggestions(upgradeRooms.slice(0, 5));
} catch (error: any) {
toast.error('Failed to load available rooms');
logger.error('Error loading available rooms', error);
} finally {
setLoadingAvailableRooms(false);
}
};
const handleAssignRoom = async (roomId: number, roomNumber: string) => {
if (!selectedBooking) return;
try {
setAssigningRoom(true);
await bookingService.updateBooking(selectedBooking.id, {
room_id: roomId,
} as any);
toast.success(`Room ${roomNumber} assigned successfully!`);
setSelectedBooking(null);
setAvailableRoomsForAssignment([]);
setUpgradeSuggestions([]);
fetchRoomStatusBoard();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to assign room');
logger.error('Error assigning room', error);
} finally {
setAssigningRoom(false);
}
};
const handleGetOptimalRoom = async () => {
if (!selectedBooking) return;
try {
setLoadingAvailableRooms(true);
const request: OptimalRoomAssignmentRequest = {
room_type_id: selectedBooking.room?.room_type?.id || 0,
check_in: selectedBooking.check_in_date,
check_out: selectedBooking.check_out_date,
num_guests: selectedBooking.guest_count || 1,
};
const response = await advancedRoomService.assignOptimalRoom(request);
if (response.status === 'success' && response.data?.recommended_room) {
const recommendedRoom = response.data.recommended_room;
toast.success(`Optimal room suggestion: Room ${recommendedRoom.room_number}`);
// Auto-select the recommended room
handleAssignRoom(recommendedRoom.id, recommendedRoom.room_number);
}
} catch (error: any) {
toast.error('Failed to get optimal room suggestion');
logger.error('Error getting optimal room', error);
} finally {
setLoadingAvailableRooms(false);
}
};
if (loading && rooms.length === 0) {
return <Loading />;
}
@@ -182,6 +330,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
<nav className="-mb-px flex space-x-8">
{[
{ id: 'status-board' as Tab, label: 'Room Status Board', icon: Hotel },
{ id: 'room-assignment' as Tab, label: 'Room Assignment', icon: ArrowRight },
{ id: 'maintenance' as Tab, label: 'Maintenance', icon: Wrench },
{ id: 'housekeeping' as Tab, label: 'Housekeeping', icon: Sparkles },
{ id: 'inspections' as Tab, label: 'Inspections', icon: ClipboardCheck },
@@ -377,6 +526,228 @@ const AdvancedRoomManagementPage: React.FC = () => {
</div>
)}
{/* Room Assignment Tab */}
{activeTab === 'room-assignment' && (
<div className="space-y-8">
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-gradient-to-br from-indigo-500/10 to-purple-500/10 border border-indigo-200/40">
<ArrowRight className="w-6 h-6 text-indigo-600" />
</div>
<h2 className="text-3xl font-extrabold text-gray-900">Room Assignment</h2>
</div>
<p className="text-gray-600 text-base max-w-2xl leading-relaxed">
Assign or change rooms for bookings with visual room selection
</p>
</div>
</div>
{/* Booking Selection */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-indigo-100 flex items-center justify-center">
<span className="text-indigo-600 font-bold">1</span>
</div>
Select Booking
</h3>
{!selectedBooking ? (
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
value={bookingSearch}
onChange={(e) => setBookingSearch(e.target.value)}
placeholder="Search by booking number, guest name, or email..."
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-gray-200 rounded-xl focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100 transition-all duration-200 text-gray-700"
/>
</div>
{bookingSearchResults.length > 0 && (
<div className="border-2 border-gray-200 rounded-xl overflow-hidden max-h-96 overflow-y-auto">
{bookingSearchResults.map((booking) => (
<div
key={booking.id}
onClick={() => handleSelectBooking(booking)}
className="p-4 hover:bg-indigo-50 cursor-pointer border-b border-gray-100 last:border-b-0 transition-colors"
>
<div className="flex items-center justify-between">
<div>
<div className="font-bold text-gray-900">{booking.booking_number}</div>
<div className="text-sm text-gray-600">
{booking.guest_info?.full_name || booking.user?.name}
</div>
<div className="text-xs text-gray-500 mt-1">
{new Date(booking.check_in_date).toLocaleDateString()} - {new Date(booking.check_out_date).toLocaleDateString()}
</div>
</div>
<div className="text-right">
<div className="text-sm font-semibold text-gray-900">
Room {booking.room?.room_number || 'N/A'}
</div>
<div className="text-xs text-gray-500">{booking.room?.room_type?.name}</div>
</div>
</div>
</div>
))}
</div>
)}
{searchingBookings && (
<div className="text-center py-8">
<RefreshCw className="w-8 h-8 text-indigo-600 animate-spin mx-auto mb-2" />
<p className="text-gray-600">Searching bookings...</p>
</div>
)}
</div>
) : (
<div className="bg-indigo-50 rounded-xl p-6 border-2 border-indigo-200">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="text-lg font-bold text-gray-900">{selectedBooking.booking_number}</h4>
<span className="px-2 py-1 bg-indigo-600 text-white text-xs font-semibold rounded">
{selectedBooking.status}
</span>
</div>
<div className="text-sm text-gray-700 space-y-1">
<div><strong>Guest:</strong> {selectedBooking.guest_info?.full_name || selectedBooking.user?.name}</div>
<div><strong>Current Room:</strong> Room {selectedBooking.room?.room_number || 'N/A'} - {selectedBooking.room?.room_type?.name}</div>
<div><strong>Dates:</strong> {new Date(selectedBooking.check_in_date).toLocaleDateString()} to {new Date(selectedBooking.check_out_date).toLocaleDateString()}</div>
<div><strong>Guests:</strong> {selectedBooking.guest_count || 1}</div>
</div>
</div>
<button
onClick={() => {
setSelectedBooking(null);
setAvailableRoomsForAssignment([]);
setUpgradeSuggestions([]);
}}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-white rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
{/* Room Selection */}
{selectedBooking && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold text-gray-900 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-indigo-100 flex items-center justify-center">
<span className="text-indigo-600 font-bold">2</span>
</div>
Select New Room
</h3>
<button
onClick={handleGetOptimalRoom}
disabled={loadingAvailableRooms}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-indigo-500 to-purple-600 text-white rounded-lg hover:from-indigo-600 hover:to-purple-700 font-medium disabled:opacity-50 transition-all"
>
<ArrowUpRight className="w-4 h-4" />
Get Optimal Room
</button>
</div>
{loadingAvailableRooms ? (
<div className="text-center py-12 bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50">
<RefreshCw className="w-8 h-8 text-indigo-600 animate-spin mx-auto mb-2" />
<p className="text-gray-600">Loading available rooms...</p>
</div>
) : availableRoomsForAssignment.length === 0 ? (
<div className="text-center py-12 bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50">
<Hotel className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-gray-600">No available rooms found for selected dates</p>
</div>
) : (
<>
{showUpgradeSuggestions && upgradeSuggestions.length > 0 && (
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 rounded-xl p-6 border-2 border-amber-200">
<h4 className="font-bold text-amber-900 mb-4 flex items-center gap-2">
<ArrowUpRight className="w-5 h-5" />
Upgrade Suggestions
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{upgradeSuggestions.map((room) => {
const statusColors = getStatusColor(room.status);
return (
<div
key={room.id}
className={`p-4 rounded-xl border-2 ${statusColors.bg} ${statusColors.border} cursor-pointer hover:shadow-lg transition-all`}
onClick={() => handleAssignRoom(room.id, room.room_number)}
>
<div className="font-bold text-lg">{room.room_number}</div>
<div className="text-sm text-gray-700">{room.room_type}</div>
</div>
);
})}
</div>
</div>
)}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex items-center justify-between mb-6">
<h4 className="font-bold text-gray-900">Available Rooms ({availableRoomsForAssignment.length})</h4>
{upgradeSuggestions.length > 0 && (
<button
onClick={() => setShowUpgradeSuggestions(!showUpgradeSuggestions)}
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium"
>
{showUpgradeSuggestions ? 'Hide' : 'Show'} Upgrade Options
</button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{availableRoomsForAssignment.map((room) => {
const statusColors = getStatusColor(room.status);
const isCurrentRoom = room.id === selectedBooking.room_id;
return (
<div
key={room.id}
className={`
p-5 rounded-xl border-2 transition-all cursor-pointer
${isCurrentRoom
? 'border-blue-500 bg-blue-50'
: `${statusColors.bg} ${statusColors.border} hover:shadow-lg`
}
${assigningRoom ? 'opacity-50 cursor-not-allowed' : ''}
`}
onClick={() => !isCurrentRoom && !assigningRoom && handleAssignRoom(room.id, room.room_number)}
>
<div className="flex items-center justify-between mb-2">
<div className="font-bold text-xl text-gray-900">{room.room_number}</div>
{isCurrentRoom && (
<span className="px-2 py-1 bg-blue-600 text-white text-xs font-semibold rounded">
Current
</span>
)}
</div>
<div className="text-sm text-gray-700 mb-3">{room.room_type}</div>
{!isCurrentRoom && (
<button
disabled={assigningRoom}
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium disabled:opacity-50 transition-colors"
>
{assigningRoom ? 'Assigning...' : 'Assign Room'}
</button>
)}
</div>
);
})}
</div>
</div>
</>
)}
</div>
)}
</div>
)}
{/* Maintenance Tab */}
{activeTab === 'maintenance' && <MaintenanceManagement />}

View File

@@ -0,0 +1,708 @@
import React, { useState, useEffect } from 'react';
import {
Mail,
MessageSquare,
Phone,
Send,
Search,
Filter,
Plus,
Edit,
Eye,
X,
Clock,
User,
Calendar,
RefreshCw,
FileText,
Bell,
} from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate, formatRelativeTime } from '../../shared/utils/format';
import { logger } from '../../shared/utils/logger';
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
import userService from '../../features/auth/services/userService';
import apiClient from '../../shared/services/apiClient';
import Pagination from '../../shared/components/Pagination';
interface CommunicationTemplate {
id: number;
name: string;
notification_type: string;
channel: string;
subject?: string;
content: string;
is_active: boolean;
}
interface GuestCommunication {
id: number;
user_id: number;
guest_name?: string;
communication_type: string;
direction: string;
subject?: string;
content: string;
booking_id?: number;
is_automated: boolean;
created_at: string;
staff_name?: string;
}
type Tab = 'send' | 'history' | 'templates';
const GuestCommunicationPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<Tab>('send');
const [loading, setLoading] = useState(false);
// Send communication state
const [guestSearch, setGuestSearch] = useState('');
const [guestSearchResults, setGuestSearchResults] = useState<any[]>([]);
const [selectedGuest, setSelectedGuest] = useState<any>(null);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [communicationForm, setCommunicationForm] = useState({
communication_type: 'email',
subject: '',
content: '',
booking_id: null as number | null,
});
const [sending, setSending] = useState(false);
// History state
const [communications, setCommunications] = useState<GuestCommunication[]>([]);
const [historyPage, setHistoryPage] = useState(1);
const [historyTotalPages, setHistoryTotalPages] = useState(1);
const [historyFilters, setHistoryFilters] = useState({
search: '',
communication_type: '',
direction: '',
});
// Templates state
const [templates, setTemplates] = useState<CommunicationTemplate[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<CommunicationTemplate | null>(null);
const [showTemplateModal, setShowTemplateModal] = useState(false);
useEffect(() => {
if (activeTab === 'history') {
fetchCommunications();
} else if (activeTab === 'templates') {
fetchTemplates();
}
}, [activeTab, historyPage, historyFilters]);
useEffect(() => {
if (guestSearch.length >= 2) {
const timeoutId = setTimeout(() => {
searchGuests();
}, 300);
return () => clearTimeout(timeoutId);
} else {
setGuestSearchResults([]);
}
}, [guestSearch]);
const searchGuests = async () => {
try {
const response = await userService.getUsers({
search: guestSearch,
role: 'customer',
limit: 10,
});
setGuestSearchResults(response.data.users || []);
} catch (error) {
logger.error('Error searching guests', error);
}
};
const handleSelectGuest = async (guest: any) => {
setSelectedGuest(guest);
setGuestSearch('');
setGuestSearchResults([]);
// Fetch guest's active bookings
try {
const response = await bookingService.getAllBookings({
search: guest.email || guest.phone,
limit: 10,
});
if (response.data?.bookings && response.data.bookings.length > 0) {
setSelectedBooking(response.data.bookings[0]);
setCommunicationForm({ ...communicationForm, booking_id: response.data.bookings[0].id });
}
} catch (error) {
logger.error('Error fetching bookings', error);
}
};
const handleSendCommunication = async () => {
if (!selectedGuest) {
toast.error('Please select a guest');
return;
}
if (!communicationForm.content.trim()) {
toast.error('Please enter message content');
return;
}
if (communicationForm.communication_type === 'email' && !communicationForm.subject.trim()) {
toast.error('Please enter email subject');
return;
}
try {
setSending(true);
// Create guest communication record
await apiClient.post('/guest-profiles/communications', {
user_id: selectedGuest.id,
communication_type: communicationForm.communication_type,
direction: 'outbound',
subject: communicationForm.subject || undefined,
content: communicationForm.content,
booking_id: communicationForm.booking_id || undefined,
is_automated: false,
});
// Send notification
await apiClient.post('/notifications/send', {
user_id: selectedGuest.id,
notification_type: 'custom',
channel: communicationForm.communication_type === 'email' ? 'email' : 'sms',
subject: communicationForm.subject || undefined,
content: communicationForm.content,
booking_id: communicationForm.booking_id || undefined,
});
toast.success('Communication sent successfully!');
// Reset form
setSelectedGuest(null);
setSelectedBooking(null);
setCommunicationForm({
communication_type: 'email',
subject: '',
content: '',
booking_id: null,
});
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to send communication');
logger.error('Error sending communication', error);
} finally {
setSending(false);
}
};
const fetchCommunications = async () => {
try {
setLoading(true);
const params: any = {
page: historyPage,
limit: 20,
};
if (historyFilters.communication_type) {
params.communication_type = historyFilters.communication_type;
}
if (historyFilters.direction) {
params.direction = historyFilters.direction;
}
const response = await apiClient.get('/guest-profiles/communications', { params });
if (response.data?.communications) {
let filtered = response.data.communications;
if (historyFilters.search) {
const searchLower = historyFilters.search.toLowerCase();
filtered = filtered.filter((comm: GuestCommunication) =>
comm.guest_name?.toLowerCase().includes(searchLower) ||
comm.subject?.toLowerCase().includes(searchLower) ||
comm.content?.toLowerCase().includes(searchLower)
);
}
setCommunications(filtered);
setHistoryTotalPages(response.data.pagination?.total_pages || 1);
}
} catch (error: any) {
toast.error('Failed to load communications');
logger.error('Error fetching communications', error);
} finally {
setLoading(false);
}
};
const fetchTemplates = async () => {
try {
setLoading(true);
const response = await apiClient.get('/notifications/templates');
if (response.data?.templates) {
setTemplates(response.data.templates);
}
} catch (error: any) {
toast.error('Failed to load templates');
logger.error('Error fetching templates', error);
} finally {
setLoading(false);
}
};
const handleUseTemplate = (template: CommunicationTemplate) => {
setSelectedTemplate(template);
setCommunicationForm({
...communicationForm,
subject: template.subject || '',
content: template.content,
communication_type: template.channel === 'email' ? 'email' : 'sms',
});
setShowTemplateModal(false);
setActiveTab('send');
};
const getCommunicationIcon = (type: string) => {
switch (type) {
case 'email':
return Mail;
case 'sms':
return MessageSquare;
case 'phone':
return Phone;
default:
return Bell;
}
};
const getCommunicationColor = (type: string) => {
switch (type) {
case 'email':
return 'bg-blue-100 text-blue-800';
case 'sms':
return 'bg-green-100 text-green-800';
case 'phone':
return 'bg-purple-100 text-purple-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-blue-400 via-indigo-500 to-purple-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Guest Communication
</h1>
</div>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">
Send messages, emails, and manage communication templates
</p>
</div>
{/* Tabs */}
<div className="mb-6 border-b border-slate-200">
<nav className="-mb-px flex space-x-8">
{[
{ id: 'send' as Tab, label: 'Send Message', icon: Send },
{ id: 'history' as Tab, label: 'History', icon: Clock },
{ id: 'templates' as Tab, label: 'Templates', icon: FileText },
].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 transition-colors
${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}
`}
>
<Icon className="w-5 h-5" />
<span>{tab.label}</span>
</button>
);
})}
</nav>
</div>
{/* Send Tab */}
{activeTab === 'send' && (
<div className="space-y-6">
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-6">
<h2 className="text-xl font-bold text-slate-900 mb-6">Send Communication</h2>
{/* Guest Selection */}
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">Select Guest *</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5" />
<input
type="text"
value={guestSearch}
onChange={(e) => setGuestSearch(e.target.value)}
placeholder="Search by name, email, or phone..."
className="w-full pl-10 pr-4 py-3 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
/>
</div>
{guestSearchResults.length > 0 && (
<div className="mt-2 border-2 border-slate-200 rounded-xl overflow-hidden max-h-60 overflow-y-auto">
{guestSearchResults.map((guest) => (
<div
key={guest.id}
onClick={() => handleSelectGuest(guest)}
className="p-3 hover:bg-blue-50 cursor-pointer border-b border-slate-100 last:border-b-0 transition-colors"
>
<div className="font-medium text-slate-900">{guest.full_name || guest.name}</div>
<div className="text-sm text-slate-600">{guest.email}</div>
{guest.phone && <div className="text-sm text-slate-600">{guest.phone}</div>}
</div>
))}
</div>
)}
{selectedGuest && (
<div className="mt-3 p-4 bg-blue-50 rounded-xl border border-blue-200">
<div className="flex items-center justify-between">
<div>
<div className="font-semibold text-slate-900">{selectedGuest.full_name || selectedGuest.name}</div>
<div className="text-sm text-slate-600">{selectedGuest.email}</div>
{selectedGuest.phone && <div className="text-sm text-slate-600">{selectedGuest.phone}</div>}
{selectedBooking && (
<div className="text-sm text-blue-600 mt-2">
Active Booking: {selectedBooking.booking_number} - Room {selectedBooking.room?.room_number}
</div>
)}
</div>
<button
onClick={() => {
setSelectedGuest(null);
setSelectedBooking(null);
setCommunicationForm({ ...communicationForm, booking_id: null });
}}
className="p-2 text-slate-400 hover:text-slate-600"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
{/* Communication Type */}
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">Communication Type *</label>
<select
value={communicationForm.communication_type}
onChange={(e) => setCommunicationForm({ ...communicationForm, communication_type: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
>
<option value="email">Email</option>
<option value="sms">SMS</option>
<option value="phone">Phone Call</option>
</select>
</div>
{/* Subject (for email) */}
{communicationForm.communication_type === 'email' && (
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">Subject *</label>
<input
type="text"
value={communicationForm.subject}
onChange={(e) => setCommunicationForm({ ...communicationForm, subject: e.target.value })}
placeholder="Email subject..."
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
/>
</div>
)}
{/* Content */}
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">Message *</label>
<textarea
value={communicationForm.content}
onChange={(e) => setCommunicationForm({ ...communicationForm, content: e.target.value })}
rows={8}
placeholder="Enter your message..."
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
/>
{communicationForm.communication_type === 'sms' && (
<p className="text-xs text-slate-500 mt-1">
{communicationForm.content.length} / 160 characters
</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-4">
<button
onClick={() => setShowTemplateModal(true)}
className="px-4 py-2 border-2 border-slate-300 text-slate-700 rounded-xl hover:bg-slate-50 font-medium transition-colors"
>
Use Template
</button>
<button
onClick={handleSendCommunication}
disabled={sending || !selectedGuest}
className="flex-1 px-6 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-xl hover:from-blue-700 hover:to-indigo-700 font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
>
{sending ? (
<>
<RefreshCw className="w-5 h-5 animate-spin" />
Sending...
</>
) : (
<>
<Send className="w-5 h-5" />
Send {communicationForm.communication_type === 'email' ? 'Email' : communicationForm.communication_type === 'sms' ? 'SMS' : 'Message'}
</>
)}
</button>
</div>
</div>
</div>
)}
{/* History Tab */}
{activeTab === 'history' && (
<div className="space-y-6">
{/* Filters */}
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Search</label>
<input
type="text"
value={historyFilters.search}
onChange={(e) => setHistoryFilters({ ...historyFilters, search: e.target.value })}
placeholder="Search communications..."
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Type</label>
<select
value={historyFilters.communication_type}
onChange={(e) => setHistoryFilters({ ...historyFilters, communication_type: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
>
<option value="">All Types</option>
<option value="email">Email</option>
<option value="sms">SMS</option>
<option value="phone">Phone</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Direction</label>
<select
value={historyFilters.direction}
onChange={(e) => setHistoryFilters({ ...historyFilters, direction: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
>
<option value="">All</option>
<option value="outbound">Outbound</option>
<option value="inbound">Inbound</option>
</select>
</div>
</div>
</div>
{/* Communications List */}
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 overflow-hidden">
{loading ? (
<div className="p-12">
<Loading text="Loading communications..." />
</div>
) : communications.length === 0 ? (
<div className="p-12">
<EmptyState title="No communications found" description="No communications match your filters." />
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Guest</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Type</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Subject</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Direction</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{communications.map((comm) => {
const Icon = getCommunicationIcon(comm.communication_type);
return (
<tr key={comm.id} className="hover:bg-slate-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-slate-900">{comm.guest_name || 'N/A'}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 inline-flex text-xs font-semibold rounded-full ${getCommunicationColor(comm.communication_type)}`}>
<Icon className="w-3 h-3 mr-1" />
{comm.communication_type}
</span>
</td>
<td className="px-6 py-4">
<div className="text-sm text-slate-900">{comm.subject || 'No subject'}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 inline-flex text-xs font-semibold rounded-full ${
comm.direction === 'outbound' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'
}`}>
{comm.direction}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500">
{formatRelativeTime(comm.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => {
// Show communication details
alert(`Content: ${comm.content}`);
}}
className="text-blue-600 hover:text-blue-900"
>
<Eye className="w-4 h-4" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{historyTotalPages > 1 && (
<div className="px-6 py-4 border-t border-slate-200">
<Pagination
currentPage={historyPage}
totalPages={historyTotalPages}
onPageChange={setHistoryPage}
/>
</div>
)}
</>
)}
</div>
</div>
)}
{/* Templates Tab */}
{activeTab === 'templates' && (
<div className="space-y-6">
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-slate-900">Communication Templates</h2>
<button
onClick={() => setShowTemplateModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 font-medium flex items-center gap-2"
>
<Plus className="w-4 h-4" />
New Template
</button>
</div>
{loading ? (
<Loading text="Loading templates..." />
) : templates.length === 0 ? (
<EmptyState title="No templates found" description="Create your first communication template." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{templates.map((template) => {
const Icon = getCommunicationIcon(template.channel);
return (
<div
key={template.id}
className="p-4 border-2 border-slate-200 rounded-xl hover:border-blue-300 hover:shadow-lg transition-all cursor-pointer"
onClick={() => handleUseTemplate(template)}
>
<div className="flex items-center justify-between mb-2">
<span className={`px-2 py-1 inline-flex text-xs font-semibold rounded-full ${getCommunicationColor(template.channel)}`}>
<Icon className="w-3 h-3 mr-1" />
{template.channel}
</span>
{template.is_active ? (
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">Active</span>
) : (
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">Inactive</span>
)}
</div>
<h3 className="font-bold text-slate-900 mb-1">{template.name}</h3>
{template.subject && <p className="text-sm text-slate-600 mb-2">{template.subject}</p>}
<p className="text-sm text-slate-500 line-clamp-2">{template.content}</p>
</div>
);
})}
</div>
)}
</div>
</div>
)}
{/* Template Selection Modal */}
{showTemplateModal && (
<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-slate-200">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">Select Template</h2>
<button
onClick={() => setShowTemplateModal(false)}
className="text-slate-400 hover:text-slate-600"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6">
{templates.length === 0 ? (
<EmptyState title="No templates available" />
) : (
<div className="space-y-3">
{templates.map((template) => {
const Icon = getCommunicationIcon(template.channel);
return (
<div
key={template.id}
onClick={() => {
handleUseTemplate(template);
setShowTemplateModal(false);
}}
className="p-4 border-2 border-slate-200 rounded-xl hover:border-blue-300 hover:bg-blue-50 cursor-pointer transition-all"
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-bold text-slate-900">{template.name}</h3>
<span className={`px-2 py-1 inline-flex text-xs font-semibold rounded-full ${getCommunicationColor(template.channel)}`}>
<Icon className="w-3 h-3 mr-1" />
{template.channel}
</span>
</div>
{template.subject && <p className="text-sm text-slate-600 mb-2">{template.subject}</p>}
<p className="text-sm text-slate-500">{template.content.substring(0, 100)}...</p>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default GuestCommunicationPage;

View File

@@ -0,0 +1,759 @@
import React, { useState, useEffect } from 'react';
import {
Clock,
CheckCircle,
AlertCircle,
Bell,
MessageSquare,
Package,
Wrench,
Sparkles,
RefreshCw,
Filter,
MapPin,
Eye,
UserCheck,
X,
Search,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { toast } from 'react-toastify';
import guestRequestService, { GuestRequest } from '../../features/guestRequests/services/guestRequestService';
import userService, { User as StaffUser } from '../../features/auth/services/userService';
import { formatDate, formatRelativeTime } from '../../shared/utils/format';
import { logger } from '../../shared/utils/logger';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import useAuthStore from '../../store/useAuthStore';
import Pagination from '../../shared/components/Pagination';
const GuestRequestManagementPage: React.FC = () => {
const { userInfo } = useAuthStore();
const [loading, setLoading] = useState(true);
const [requests, setRequests] = useState<GuestRequest[]>([]);
const [staffMembers, setStaffMembers] = useState<StaffUser[]>([]);
const [selectedRequest, setSelectedRequest] = useState<GuestRequest | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [filters, setFilters] = useState({
status: '',
request_type: '',
priority: '',
assigned_to: '',
room_id: '',
search: '',
});
const [expandedFilters, setExpandedFilters] = useState(false);
const [updatingRequestId, setUpdatingRequestId] = useState<number | null>(null);
const [staffNotes, setStaffNotes] = useState('');
const requestTypes = [
{ value: 'extra_towels', label: 'Extra Towels', icon: Package, color: 'bg-blue-100 text-blue-800' },
{ value: 'extra_pillows', label: 'Extra Pillows', icon: Package, color: 'bg-purple-100 text-purple-800' },
{ value: 'room_cleaning', label: 'Room Cleaning', icon: Sparkles, color: 'bg-green-100 text-green-800' },
{ value: 'turndown_service', label: 'Turndown Service', icon: Sparkles, color: 'bg-indigo-100 text-indigo-800' },
{ value: 'amenities', label: 'Amenities', icon: Package, color: 'bg-pink-100 text-pink-800' },
{ value: 'maintenance', label: 'Maintenance', icon: Wrench, color: 'bg-orange-100 text-orange-800' },
{ value: 'room_service', label: 'Room Service', icon: Bell, color: 'bg-yellow-100 text-yellow-800' },
{ value: 'other', label: 'Other', icon: MessageSquare, color: 'bg-gray-100 text-gray-800' },
];
useEffect(() => {
fetchRequests();
fetchStaffMembers();
}, [currentPage, filters]);
const fetchRequests = async () => {
try {
setLoading(true);
const params: any = {
page: currentPage,
limit: 20,
};
if (filters.status) params.status = filters.status;
if (filters.request_type) params.request_type = filters.request_type;
if (filters.priority) params.priority = filters.priority;
if (filters.assigned_to) params.assigned_to = parseInt(filters.assigned_to);
if (filters.room_id) params.room_id = parseInt(filters.room_id);
const response = await guestRequestService.getGuestRequests(params);
if (response.status === 'success' && response.data?.requests) {
let filteredRequests = response.data.requests;
// Client-side search filter
if (filters.search) {
const searchLower = filters.search.toLowerCase();
filteredRequests = filteredRequests.filter(
(req: GuestRequest) =>
req.title?.toLowerCase().includes(searchLower) ||
req.guest_name?.toLowerCase().includes(searchLower) ||
req.room_number?.toString().includes(searchLower) ||
req.description?.toLowerCase().includes(searchLower)
);
}
setRequests(filteredRequests);
setTotalPages(response.data.pagination?.total_pages || 1);
setTotalItems(response.data.pagination?.total || 0);
}
} catch (error: any) {
logger.error('Error fetching requests', error);
toast.error('Failed to load guest requests');
} finally {
setLoading(false);
}
};
const fetchStaffMembers = async () => {
try {
const response = await userService.getUsers({ role: 'staff', limit: 100 });
if (response.data?.users) {
setStaffMembers(response.data.users);
}
} catch (error) {
logger.error('Failed to fetch staff members', error);
}
};
const handleAssign = async (requestId: number, staffId?: number) => {
try {
setUpdatingRequestId(requestId);
await guestRequestService.updateGuestRequest(requestId, {
assigned_to: staffId || userInfo?.id,
status: 'in_progress',
});
toast.success('Request assigned successfully');
fetchRequests();
if (selectedRequest?.id === requestId) {
setSelectedRequest(null);
setShowDetailModal(false);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to assign request');
} finally {
setUpdatingRequestId(null);
}
};
const handleStatusChange = async (requestId: number, newStatus: string) => {
try {
setUpdatingRequestId(requestId);
await guestRequestService.updateGuestRequest(requestId, {
status: newStatus,
});
toast.success(`Request marked as ${newStatus.replace('_', ' ')}`);
fetchRequests();
if (selectedRequest?.id === requestId) {
const updated = await guestRequestService.getGuestRequest(requestId);
if (updated.status === 'success') {
setSelectedRequest(updated.data);
}
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to update request');
} finally {
setUpdatingRequestId(null);
}
};
const handleFulfill = async (requestId: number) => {
try {
setUpdatingRequestId(requestId);
await guestRequestService.fulfillRequest(requestId, staffNotes || undefined);
toast.success('Request fulfilled successfully');
setStaffNotes('');
fetchRequests();
setShowDetailModal(false);
setSelectedRequest(null);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to fulfill request');
} finally {
setUpdatingRequestId(null);
}
};
const handleViewDetails = async (request: GuestRequest) => {
try {
const response = await guestRequestService.getGuestRequest(request.id);
if (response.status === 'success' && response.data) {
setSelectedRequest(response.data);
setShowDetailModal(true);
}
} catch (error: any) {
toast.error('Failed to load request details');
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'pending':
return 'bg-yellow-100 text-yellow-800 border-yellow-300';
case 'in_progress':
return 'bg-blue-100 text-blue-800 border-blue-300';
case 'fulfilled':
return 'bg-green-100 text-green-800 border-green-300';
case 'cancelled':
return 'bg-gray-100 text-gray-800 border-gray-300';
default:
return 'bg-gray-100 text-gray-800 border-gray-300';
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent':
return 'bg-red-100 text-red-800 border-red-300';
case 'high':
return 'bg-orange-100 text-orange-800 border-orange-300';
case 'normal':
return 'bg-blue-100 text-blue-800 border-blue-300';
case 'low':
return 'bg-gray-100 text-gray-800 border-gray-300';
default:
return 'bg-gray-100 text-gray-800 border-gray-300';
}
};
const getRequestTypeInfo = (type: string) => {
return requestTypes.find(t => t.value === type) || requestTypes[requestTypes.length - 1];
};
const pendingCount = requests.filter(r => r.status === 'pending').length;
const inProgressCount = requests.filter(r => r.status === 'in_progress').length;
const urgentCount = requests.filter(r => r.priority === 'urgent' && r.status !== 'fulfilled').length;
if (loading && requests.length === 0) {
return <Loading fullScreen text="Loading guest requests..." />;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-blue-400 via-indigo-500 to-purple-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Guest Request Management
</h1>
</div>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">
Manage and fulfill guest service requests
</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-5 md:gap-6 mb-6 sm:mb-8">
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-yellow-500">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Pending</p>
<p className="text-2xl sm:text-3xl font-bold text-yellow-600">{pendingCount}</p>
</div>
<Clock className="w-8 h-8 sm:w-10 sm:h-10 text-yellow-500" />
</div>
</div>
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">In Progress</p>
<p className="text-2xl sm:text-3xl font-bold text-blue-600">{inProgressCount}</p>
</div>
<RefreshCw className="w-8 h-8 sm:w-10 sm:h-10 text-blue-500" />
</div>
</div>
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-red-500">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Urgent</p>
<p className="text-2xl sm:text-3xl font-bold text-red-600">{urgentCount}</p>
</div>
<AlertCircle className="w-8 h-8 sm:w-10 sm:h-10 text-red-500" />
</div>
</div>
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-emerald-500">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Total</p>
<p className="text-2xl sm:text-3xl font-bold text-emerald-600">{totalItems}</p>
</div>
<Bell className="w-8 h-8 sm:w-10 sm:h-10 text-emerald-500" />
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 mb-6 sm:mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg sm:text-xl font-bold text-slate-900 flex items-center gap-2">
<Filter className="w-5 h-5 text-blue-600" />
Filters
</h2>
<button
onClick={() => setExpandedFilters(!expandedFilters)}
className="flex items-center gap-2 px-3 py-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
>
{expandedFilters ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
{expandedFilters ? 'Hide' : 'Show'} Filters
</button>
</div>
{/* Search */}
<div className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5" />
<input
type="text"
placeholder="Search by guest name, room, title, or description..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
/>
</div>
</div>
{expandedFilters && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Status</label>
<select
value={filters.status}
onChange={(e) => {
setFilters({ ...filters, status: e.target.value });
setCurrentPage(1);
}}
className="w-full px-3 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="fulfilled">Fulfilled</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Request Type</label>
<select
value={filters.request_type}
onChange={(e) => {
setFilters({ ...filters, request_type: e.target.value });
setCurrentPage(1);
}}
className="w-full px-3 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
>
<option value="">All Types</option>
{requestTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Priority</label>
<select
value={filters.priority}
onChange={(e) => {
setFilters({ ...filters, priority: e.target.value });
setCurrentPage(1);
}}
className="w-full px-3 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
>
<option value="">All Priorities</option>
<option value="urgent">Urgent</option>
<option value="high">High</option>
<option value="normal">Normal</option>
<option value="low">Low</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Assigned To</label>
<select
value={filters.assigned_to}
onChange={(e) => {
setFilters({ ...filters, assigned_to: e.target.value });
setCurrentPage(1);
}}
className="w-full px-3 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
>
<option value="">All Staff</option>
<option value="unassigned">Unassigned</option>
{staffMembers.map((staff) => (
<option key={staff.id} value={staff.id}>
{staff.full_name}
</option>
))}
</select>
</div>
</div>
)}
<div className="mt-4 flex gap-2">
<button
onClick={() => {
setFilters({
status: '',
request_type: '',
priority: '',
assigned_to: '',
room_id: '',
search: '',
});
setCurrentPage(1);
}}
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 font-medium"
>
Clear Filters
</button>
<button
onClick={fetchRequests}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium flex items-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
</div>
</div>
{/* Requests List */}
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 overflow-hidden">
{requests.length === 0 ? (
<div className="p-8 sm:p-12">
<EmptyState
title="No guest requests found"
description="There are no guest requests matching your filters."
/>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Request
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Guest / Room
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Status
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Priority
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Assigned To
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Time
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{requests.map((request) => {
const typeInfo = getRequestTypeInfo(request.request_type);
const TypeIcon = typeInfo.icon;
return (
<tr
key={request.id}
className={`hover:bg-slate-50 transition-colors ${
request.priority === 'urgent' && request.status !== 'fulfilled'
? 'bg-red-50/50'
: ''
}`}
>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${typeInfo.color}`}>
<TypeIcon className="w-4 h-4" />
</div>
<div>
<div className="text-sm font-medium text-slate-900">{request.title}</div>
<div className="text-xs text-slate-500 capitalize">{typeInfo.label}</div>
</div>
</div>
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
<div className="text-sm text-slate-900">{request.guest_name || 'N/A'}</div>
<div className="text-xs text-slate-500 flex items-center gap-1">
<MapPin className="w-3 h-3" />
Room {request.room_number || request.room_id}
</div>
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border ${getStatusColor(
request.status
)}`}
>
{request.status.replace('_', ' ')}
</span>
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border ${getPriorityColor(
request.priority
)}`}
>
{request.priority}
</span>
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm text-slate-500">
{request.assigned_staff_name || (
<span className="text-yellow-600 font-medium">Unassigned</span>
)}
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm text-slate-500">
<div>{formatRelativeTime(request.requested_at)}</div>
{request.response_time_minutes && (
<div className="text-xs text-slate-400">
Response: {request.response_time_minutes}m
</div>
)}
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
<button
onClick={() => handleViewDetails(request)}
className="text-blue-600 hover:text-blue-900 p-1.5 hover:bg-blue-50 rounded-lg transition-colors"
title="View details"
>
<Eye className="w-4 h-4" />
</button>
{request.status === 'pending' && !request.assigned_to && (
<button
onClick={() => handleAssign(request.id)}
disabled={updatingRequestId === request.id}
className="text-green-600 hover:text-green-900 p-1.5 hover:bg-green-50 rounded-lg transition-colors disabled:opacity-50"
title="Assign to me"
>
<UserCheck className="w-4 h-4" />
</button>
)}
{request.status === 'in_progress' && request.assigned_to === userInfo?.id && (
<button
onClick={() => handleFulfill(request.id)}
disabled={updatingRequestId === request.id}
className="text-emerald-600 hover:text-emerald-900 p-1.5 hover:bg-emerald-50 rounded-lg transition-colors disabled:opacity-50"
title="Mark as fulfilled"
>
<CheckCircle className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="px-4 sm:px-6 py-4 border-t border-slate-200">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</>
)}
</div>
{/* Detail Modal */}
{showDetailModal && selectedRequest && (
<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-slate-200">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">Request Details</h2>
<button
onClick={() => {
setShowDetailModal(false);
setSelectedRequest(null);
setStaffNotes('');
}}
className="text-slate-400 hover:text-slate-600"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-4">
{/* Request Info */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Status</label>
<span
className={`px-3 py-1 inline-flex text-sm font-semibold rounded-full border ${getStatusColor(
selectedRequest.status
)}`}
>
{selectedRequest.status.replace('_', ' ')}
</span>
</div>
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Priority</label>
<span
className={`px-3 py-1 inline-flex text-sm font-semibold rounded-full border ${getPriorityColor(
selectedRequest.priority
)}`}
>
{selectedRequest.priority}
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Title</label>
<p className="text-slate-900 font-medium">{selectedRequest.title}</p>
</div>
{selectedRequest.description && (
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Description</label>
<p className="text-slate-900 whitespace-pre-wrap">{selectedRequest.description}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Guest</label>
<p className="text-slate-900">{selectedRequest.guest_name || 'N/A'}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Room</label>
<p className="text-slate-900">Room {selectedRequest.room_number || selectedRequest.room_id}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Request Type</label>
<p className="text-slate-900 capitalize">{selectedRequest.request_type.replace('_', ' ')}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Requested At</label>
<p className="text-slate-900">{formatDate(selectedRequest.requested_at)}</p>
</div>
</div>
{selectedRequest.assigned_staff_name && (
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Assigned To</label>
<p className="text-slate-900">{selectedRequest.assigned_staff_name}</p>
</div>
)}
{selectedRequest.guest_notes && (
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Guest Notes</label>
<p className="text-slate-900 whitespace-pre-wrap">{selectedRequest.guest_notes}</p>
</div>
)}
{selectedRequest.staff_notes && (
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Staff Notes</label>
<p className="text-slate-900 whitespace-pre-wrap">{selectedRequest.staff_notes}</p>
</div>
)}
{selectedRequest.response_time_minutes && (
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Response Time</label>
<p className="text-slate-900">{selectedRequest.response_time_minutes} minutes</p>
</div>
)}
{selectedRequest.fulfillment_time_minutes && (
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Fulfillment Time</label>
<p className="text-slate-900">{selectedRequest.fulfillment_time_minutes} minutes</p>
</div>
)}
{/* Actions */}
{selectedRequest.status !== 'fulfilled' && selectedRequest.status !== 'cancelled' && (
<div className="pt-4 border-t border-slate-200 space-y-4">
{!selectedRequest.assigned_to && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Assign To</label>
<select
onChange={(e) => {
if (e.target.value) {
handleAssign(selectedRequest.id, parseInt(e.target.value));
}
}}
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
>
<option value="">Select staff member...</option>
<option value={userInfo?.id}>Assign to me</option>
{staffMembers.map((staff) => (
<option key={staff.id} value={staff.id}>
{staff.full_name}
</option>
))}
</select>
</div>
)}
{selectedRequest.status === 'in_progress' && selectedRequest.assigned_to === userInfo?.id && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Staff Notes (Optional)</label>
<textarea
value={staffNotes}
onChange={(e) => setStaffNotes(e.target.value)}
rows={3}
placeholder="Add notes about fulfillment..."
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
/>
<button
onClick={() => handleFulfill(selectedRequest.id)}
disabled={updatingRequestId === selectedRequest.id}
className="mt-2 w-full px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium disabled:opacity-50"
>
{updatingRequestId === selectedRequest.id ? 'Fulfilling...' : 'Mark as Fulfilled'}
</button>
</div>
)}
{selectedRequest.status === 'pending' && selectedRequest.assigned_to && (
<button
onClick={() => handleStatusChange(selectedRequest.id, 'in_progress')}
disabled={updatingRequestId === selectedRequest.id}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium disabled:opacity-50"
>
Start Working
</button>
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default GuestRequestManagementPage;

View File

@@ -0,0 +1,800 @@
import React, { useState, useEffect } from 'react';
import {
AlertTriangle,
CheckCircle,
Clock,
XCircle,
Filter,
Search,
Eye,
Edit,
MessageSquare,
User,
Calendar,
MapPin,
RefreshCw,
X,
ArrowUp,
ArrowDown,
} from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate, formatRelativeTime } from '../../shared/utils/format';
import { logger } from '../../shared/utils/logger';
import complaintService, { Complaint, ComplaintUpdate } from '../../features/complaints/services/complaintService';
import userService from '../../features/auth/services/userService';
import Pagination from '../../shared/components/Pagination';
const IncidentComplaintManagementPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [complaints, setComplaints] = useState<Complaint[]>([]);
const [selectedComplaint, setSelectedComplaint] = useState<Complaint | null>(null);
const [complaintUpdates, setComplaintUpdates] = useState<ComplaintUpdate[]>([]);
const [showDetailModal, setShowDetailModal] = useState(false);
const [staffMembers, setStaffMembers] = useState<any[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [filters, setFilters] = useState({
status: '',
priority: '',
category: '',
assigned_to: '',
search: '',
});
const [expandedFilters, setExpandedFilters] = useState(false);
const [updatingComplaintId, setUpdatingComplaintId] = useState<number | null>(null);
const [updateForm, setUpdateForm] = useState({
description: '',
update_type: 'note',
});
const [resolveForm, setResolveForm] = useState({
resolution_notes: '',
compensation_amount: '',
});
useEffect(() => {
fetchComplaints();
fetchStaffMembers();
}, [currentPage, filters.status, filters.priority, filters.category, filters.assigned_to]);
const fetchComplaints = async () => {
try {
setLoading(true);
const params: any = {
page: currentPage,
limit: 20,
};
if (filters.status) params.status = filters.status;
if (filters.priority) params.priority = filters.priority;
if (filters.category) params.category = filters.category;
if (filters.assigned_to) params.assigned_to = parseInt(filters.assigned_to);
const response = await complaintService.getComplaints(params);
if (response.status === 'success' && response.data?.complaints) {
let filtered = response.data.complaints;
// Client-side search filter
if (filters.search) {
const searchLower = filters.search.toLowerCase();
filtered = filtered.filter(
(complaint: Complaint) =>
complaint.title?.toLowerCase().includes(searchLower) ||
complaint.guest_name?.toLowerCase().includes(searchLower) ||
complaint.description?.toLowerCase().includes(searchLower) ||
complaint.room_number?.toString().includes(searchLower)
);
}
setComplaints(filtered);
setTotalPages(response.data.pagination?.total_pages || 1);
}
} catch (error: any) {
logger.error('Error fetching complaints', error);
toast.error('Failed to load complaints');
} finally {
setLoading(false);
}
};
const fetchStaffMembers = async () => {
try {
const response = await userService.getUsers({ role: 'staff', limit: 100 });
if (response.data?.users) {
setStaffMembers(response.data.users);
}
} catch (error) {
logger.error('Failed to fetch staff members', error);
}
};
const handleViewDetails = async (complaint: Complaint) => {
try {
const response = await complaintService.getComplaint(complaint.id);
if (response.status === 'success' && response.data) {
setSelectedComplaint(response.data.complaint);
setComplaintUpdates(response.data.updates || []);
setShowDetailModal(true);
}
} catch (error: any) {
toast.error('Failed to load complaint details');
}
};
const handleUpdateStatus = async (complaintId: number, newStatus: string) => {
try {
setUpdatingComplaintId(complaintId);
await complaintService.updateComplaint(complaintId, { status: newStatus });
toast.success(`Complaint marked as ${newStatus}`);
fetchComplaints();
if (selectedComplaint?.id === complaintId) {
const updated = await complaintService.getComplaint(complaintId);
if (updated.status === 'success') {
setSelectedComplaint(updated.data.complaint);
}
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to update complaint');
} finally {
setUpdatingComplaintId(null);
}
};
const handleAssign = async (complaintId: number, staffId?: number) => {
try {
setUpdatingComplaintId(complaintId);
await complaintService.updateComplaint(complaintId, { assigned_to: staffId });
toast.success('Complaint assigned successfully');
fetchComplaints();
if (selectedComplaint?.id === complaintId) {
const updated = await complaintService.getComplaint(complaintId);
if (updated.status === 'success') {
setSelectedComplaint(updated.data.complaint);
}
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to assign complaint');
} finally {
setUpdatingComplaintId(null);
}
};
const handleAddUpdate = async () => {
if (!selectedComplaint || !updateForm.description.trim()) {
toast.error('Please enter update description');
return;
}
try {
await complaintService.addComplaintUpdate(selectedComplaint.id, {
description: updateForm.description,
update_type: updateForm.update_type,
});
toast.success('Update added successfully');
setUpdateForm({ description: '', update_type: 'note' });
// Refresh complaint details
const updated = await complaintService.getComplaint(selectedComplaint.id);
if (updated.status === 'success') {
setSelectedComplaint(updated.data.complaint);
setComplaintUpdates(updated.data.updates || []);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to add update');
}
};
const handleResolve = async () => {
if (!selectedComplaint || !resolveForm.resolution_notes.trim()) {
toast.error('Please enter resolution notes');
return;
}
try {
setUpdatingComplaintId(selectedComplaint.id);
await complaintService.resolveComplaint(selectedComplaint.id, {
resolution_notes: resolveForm.resolution_notes,
compensation_amount: resolveForm.compensation_amount ? parseFloat(resolveForm.compensation_amount) : undefined,
});
toast.success('Complaint resolved successfully');
setResolveForm({ resolution_notes: '', compensation_amount: '' });
fetchComplaints();
setShowDetailModal(false);
setSelectedComplaint(null);
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to resolve complaint');
} finally {
setUpdatingComplaintId(null);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'open':
return 'bg-red-100 text-red-800 border-red-300';
case 'in_progress':
return 'bg-yellow-100 text-yellow-800 border-yellow-300';
case 'resolved':
return 'bg-green-100 text-green-800 border-green-300';
case 'closed':
return 'bg-gray-100 text-gray-800 border-gray-300';
default:
return 'bg-gray-100 text-gray-800 border-gray-300';
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent':
return 'bg-red-100 text-red-800 border-red-300';
case 'high':
return 'bg-orange-100 text-orange-800 border-orange-300';
case 'medium':
return 'bg-yellow-100 text-yellow-800 border-yellow-300';
case 'low':
return 'bg-blue-100 text-blue-800 border-blue-300';
default:
return 'bg-gray-100 text-gray-800 border-gray-300';
}
};
const getCategoryIcon = (category: string) => {
switch (category) {
case 'room_quality':
return '🏨';
case 'service':
return '👥';
case 'billing':
return '💰';
case 'noise':
return '🔊';
case 'cleanliness':
return '🧹';
case 'maintenance':
return '🔧';
default:
return '📋';
}
};
const openCount = complaints.filter(c => c.status === 'open').length;
const inProgressCount = complaints.filter(c => c.status === 'in_progress').length;
const urgentCount = complaints.filter(c => c.priority === 'urgent' && c.status !== 'resolved' && c.status !== 'closed').length;
if (loading && complaints.length === 0) {
return <Loading fullScreen text="Loading complaints..." />;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-red-400 via-orange-500 to-yellow-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Incident & Complaint Management
</h1>
</div>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">
Track, assign, and resolve guest complaints and incidents
</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-5 md:gap-6 mb-6 sm:mb-8">
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-red-500">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Open</p>
<p className="text-2xl sm:text-3xl font-bold text-red-600">{openCount}</p>
</div>
<AlertTriangle className="w-8 h-8 sm:w-10 sm:h-10 text-red-500" />
</div>
</div>
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-yellow-500">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">In Progress</p>
<p className="text-2xl sm:text-3xl font-bold text-yellow-600">{inProgressCount}</p>
</div>
<Clock className="w-8 h-8 sm:w-10 sm:h-10 text-yellow-500" />
</div>
</div>
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-orange-500">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Urgent</p>
<p className="text-2xl sm:text-3xl font-bold text-orange-600">{urgentCount}</p>
</div>
<ArrowUp className="w-8 h-8 sm:w-10 sm:h-10 text-orange-500" />
</div>
</div>
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Total</p>
<p className="text-2xl sm:text-3xl font-bold text-blue-600">{complaints.length}</p>
</div>
<AlertTriangle className="w-8 h-8 sm:w-10 sm:h-10 text-blue-500" />
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 mb-6 sm:mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg sm:text-xl font-bold text-slate-900 flex items-center gap-2">
<Filter className="w-5 h-5 text-blue-600" />
Filters
</h2>
<button
onClick={() => setExpandedFilters(!expandedFilters)}
className="flex items-center gap-2 px-3 py-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
>
{expandedFilters ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
{expandedFilters ? 'Hide' : 'Show'} Filters
</button>
</div>
{/* Search */}
<div className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5" />
<input
type="text"
placeholder="Search by title, guest name, room, or description..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
/>
</div>
</div>
{expandedFilters && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Status</label>
<select
value={filters.status}
onChange={(e) => {
setFilters({ ...filters, status: e.target.value });
setCurrentPage(1);
}}
className="w-full px-3 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
>
<option value="">All Statuses</option>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Priority</label>
<select
value={filters.priority}
onChange={(e) => {
setFilters({ ...filters, priority: e.target.value });
setCurrentPage(1);
}}
className="w-full px-3 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
>
<option value="">All Priorities</option>
<option value="urgent">Urgent</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Category</label>
<select
value={filters.category}
onChange={(e) => {
setFilters({ ...filters, category: e.target.value });
setCurrentPage(1);
}}
className="w-full px-3 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
>
<option value="">All Categories</option>
<option value="room_quality">Room Quality</option>
<option value="service">Service</option>
<option value="billing">Billing</option>
<option value="noise">Noise</option>
<option value="cleanliness">Cleanliness</option>
<option value="maintenance">Maintenance</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Assigned To</label>
<select
value={filters.assigned_to}
onChange={(e) => {
setFilters({ ...filters, assigned_to: e.target.value });
setCurrentPage(1);
}}
className="w-full px-3 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
>
<option value="">All Staff</option>
<option value="unassigned">Unassigned</option>
{staffMembers.map((staff) => (
<option key={staff.id} value={staff.id}>
{staff.full_name}
</option>
))}
</select>
</div>
</div>
)}
<div className="mt-4 flex gap-2">
<button
onClick={() => {
setFilters({
status: '',
priority: '',
category: '',
assigned_to: '',
search: '',
});
setCurrentPage(1);
}}
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 font-medium"
>
Clear Filters
</button>
<button
onClick={fetchComplaints}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium flex items-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
</div>
</div>
{/* Complaints List */}
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 overflow-hidden">
{complaints.length === 0 ? (
<div className="p-8 sm:p-12">
<EmptyState
title="No complaints found"
description="There are no complaints matching your filters."
/>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Complaint
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Guest / Room
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Status
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Priority
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Assigned To
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Date
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{complaints.map((complaint) => (
<tr
key={complaint.id}
className={`hover:bg-slate-50 transition-colors cursor-pointer ${
complaint.priority === 'urgent' && complaint.status !== 'resolved' && complaint.status !== 'closed'
? 'bg-red-50/50'
: ''
}`}
onClick={() => handleViewDetails(complaint)}
>
<td className="px-4 sm:px-6 py-4">
<div className="flex items-center gap-3">
<span className="text-2xl">{getCategoryIcon(complaint.category)}</span>
<div>
<div className="text-sm font-medium text-slate-900">{complaint.title}</div>
<div className="text-xs text-slate-500 capitalize">{complaint.category.replace('_', ' ')}</div>
</div>
</div>
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
<div className="text-sm text-slate-900">{complaint.guest_name || 'N/A'}</div>
{complaint.room_number && (
<div className="text-xs text-slate-500 flex items-center gap-1">
<MapPin className="w-3 h-3" />
Room {complaint.room_number}
</div>
)}
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border ${getStatusColor(
complaint.status
)}`}
>
{complaint.status.replace('_', ' ')}
</span>
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border ${getPriorityColor(
complaint.priority
)}`}
>
{complaint.priority}
</span>
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm text-slate-500">
{complaint.assigned_staff_name || (
<span className="text-yellow-600 font-medium">Unassigned</span>
)}
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm text-slate-500">
{formatRelativeTime(complaint.created_at)}
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={(e) => {
e.stopPropagation();
handleViewDetails(complaint);
}}
className="text-blue-600 hover:text-blue-900 p-1.5 hover:bg-blue-50 rounded-lg transition-colors"
title="View details"
>
<Eye className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="px-4 sm:px-6 py-4 border-t border-slate-200">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</>
)}
</div>
{/* Detail Modal */}
{showDetailModal && selectedComplaint && (
<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-slate-200">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">Complaint Details</h2>
<button
onClick={() => {
setShowDetailModal(false);
setSelectedComplaint(null);
setComplaintUpdates([]);
setUpdateForm({ description: '', update_type: 'note' });
setResolveForm({ resolution_notes: '', compensation_amount: '' });
}}
className="text-slate-400 hover:text-slate-600"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-6">
{/* Complaint Info */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Status</label>
<span
className={`px-3 py-1 inline-flex text-sm font-semibold rounded-full border ${getStatusColor(
selectedComplaint.status
)}`}
>
{selectedComplaint.status.replace('_', ' ')}
</span>
</div>
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Priority</label>
<span
className={`px-3 py-1 inline-flex text-sm font-semibold rounded-full border ${getPriorityColor(
selectedComplaint.priority
)}`}
>
{selectedComplaint.priority}
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Title</label>
<p className="text-slate-900 font-medium">{selectedComplaint.title}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Description</label>
<p className="text-slate-900 whitespace-pre-wrap">{selectedComplaint.description}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Guest</label>
<p className="text-slate-900">{selectedComplaint.guest_name || 'N/A'}</p>
</div>
{selectedComplaint.room_number && (
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Room</label>
<p className="text-slate-900">Room {selectedComplaint.room_number}</p>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Category</label>
<p className="text-slate-900 capitalize">{selectedComplaint.category.replace('_', ' ')}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Created At</label>
<p className="text-slate-900">{formatDate(selectedComplaint.created_at)}</p>
</div>
</div>
{selectedComplaint.assigned_staff_name && (
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Assigned To</label>
<p className="text-slate-900">{selectedComplaint.assigned_staff_name}</p>
</div>
)}
{/* Updates Timeline */}
{complaintUpdates.length > 0 && (
<div>
<label className="block text-sm font-medium text-slate-500 mb-3">Updates Timeline</label>
<div className="space-y-3">
{complaintUpdates.map((update) => (
<div key={update.id} className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold text-slate-900">{update.updated_by_name || 'System'}</span>
<span className="text-xs text-slate-500">{formatRelativeTime(update.created_at)}</span>
</div>
<p className="text-sm text-slate-700">{update.description}</p>
</div>
))}
</div>
</div>
)}
{/* Actions */}
{selectedComplaint.status !== 'resolved' && selectedComplaint.status !== 'closed' && (
<div className="pt-4 border-t border-slate-200 space-y-4">
{!selectedComplaint.assigned_to && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Assign To</label>
<select
onChange={(e) => {
if (e.target.value) {
handleAssign(selectedComplaint.id, parseInt(e.target.value));
}
}}
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
>
<option value="">Select staff member...</option>
{staffMembers.map((staff) => (
<option key={staff.id} value={staff.id}>
{staff.full_name}
</option>
))}
</select>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Add Update</label>
<textarea
value={updateForm.description}
onChange={(e) => setUpdateForm({ ...updateForm, description: e.target.value })}
rows={3}
placeholder="Add an update or note..."
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
/>
<button
onClick={handleAddUpdate}
className="mt-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
>
Add Update
</button>
</div>
{selectedComplaint.status !== 'resolved' && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Resolution Notes *</label>
<textarea
value={resolveForm.resolution_notes}
onChange={(e) => setResolveForm({ ...resolveForm, resolution_notes: e.target.value })}
rows={4}
placeholder="Enter resolution details..."
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
/>
<div className="mt-2">
<label className="block text-sm font-medium text-slate-700 mb-2">Compensation Amount (Optional)</label>
<input
type="number"
value={resolveForm.compensation_amount}
onChange={(e) => setResolveForm({ ...resolveForm, compensation_amount: e.target.value })}
placeholder="0.00"
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
/>
</div>
<button
onClick={handleResolve}
disabled={updatingComplaintId === selectedComplaint.id}
className="mt-2 w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium disabled:opacity-50"
>
{updatingComplaintId === selectedComplaint.id ? 'Resolving...' : 'Resolve Complaint'}
</button>
</div>
)}
<div className="flex gap-2">
<button
onClick={() => handleUpdateStatus(selectedComplaint.id, 'in_progress')}
disabled={updatingComplaintId === selectedComplaint.id || selectedComplaint.status === 'in_progress'}
className="flex-1 px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 font-medium disabled:opacity-50"
>
Mark In Progress
</button>
<button
onClick={() => handleUpdateStatus(selectedComplaint.id, 'closed')}
disabled={updatingComplaintId === selectedComplaint.id}
className="flex-1 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 font-medium disabled:opacity-50"
>
Close
</button>
</div>
</div>
)}
{selectedComplaint.status === 'resolved' && selectedComplaint.resolution_notes && (
<div className="pt-4 border-t border-slate-200">
<label className="block text-sm font-medium text-slate-500 mb-1">Resolution Notes</label>
<p className="text-slate-900 whitespace-pre-wrap">{selectedComplaint.resolution_notes}</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default IncidentComplaintManagementPage;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
LogIn,
LogOut,
@@ -27,6 +27,7 @@ import {
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
import roomService, { Room } from '../../features/rooms/services/roomService';
import serviceService, { Service } from '../../features/hotel_services/services/serviceService';
import userService from '../../features/auth/services/userService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import CurrencyIcon from '../../shared/components/CurrencyIcon';
@@ -37,7 +38,7 @@ import { parseDateLocal } from '../../shared/utils/format';
import CreateBookingModal from '../../features/hotel_services/components/CreateBookingModal';
import { logger } from '../../shared/utils/logger';
type ReceptionTab = 'overview' | 'check-in' | 'check-out' | 'bookings' | 'rooms' | 'services';
type ReceptionTab = 'overview' | 'check-in' | 'check-out' | 'walk-in' | 'bookings' | 'rooms' | 'services';
interface GuestInfo {
name: string;
@@ -77,6 +78,27 @@ const ReceptionDashboardPage: React.FC = () => {
const [discount, setDiscount] = useState(0);
const [showInvoice, setShowInvoice] = useState(false);
// Walk-in booking state
const [walkInLoading, setWalkInLoading] = useState(false);
const [walkInSearchingRooms, setWalkInSearchingRooms] = useState(false);
const [walkInAvailableRooms, setWalkInAvailableRooms] = useState<Room[]>([]);
const [walkInForm, setWalkInForm] = useState({
guestName: '',
guestEmail: '',
guestPhone: '',
guestIdNumber: '',
selectedRoomId: '',
checkInDate: new Date().toISOString().split('T')[0],
checkOutDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0],
numGuests: 1,
numChildren: 0,
specialRequests: '',
paymentMethod: 'cash' as 'cash' | 'stripe',
paymentStatus: 'unpaid' as 'unpaid' | 'deposit' | 'full',
});
const [walkInTotalPrice, setWalkInTotalPrice] = useState(0);
const [walkInSelectedRoom, setWalkInSelectedRoom] = useState<Room | null>(null);
const [bookings, setBookings] = useState<Booking[]>([]);
const [bookingsLoading, setBookingsLoading] = useState(true);
@@ -342,6 +364,139 @@ const ReceptionDashboardPage: React.FC = () => {
setShowInvoice(false);
};
// Walk-in booking handlers
const handleWalkInSearchRooms = async () => {
if (!walkInForm.checkInDate || !walkInForm.checkOutDate) {
toast.error('Please select check-in and check-out dates');
return;
}
try {
setWalkInSearchingRooms(true);
const response = await roomService.searchAvailableRooms({
from: walkInForm.checkInDate,
to: walkInForm.checkOutDate,
limit: 50,
});
setWalkInAvailableRooms(response.data.rooms || []);
if (response.data.rooms && response.data.rooms.length === 0) {
toast.warning('No available rooms for selected dates');
}
} catch (error: any) {
toast.error('Failed to search available rooms');
logger.error('Error searching rooms', error);
} finally {
setWalkInSearchingRooms(false);
}
};
useEffect(() => {
if (walkInForm.checkInDate && walkInForm.checkOutDate && walkInForm.selectedRoomId) {
const selectedRoom = walkInAvailableRooms.find(r => r.id.toString() === walkInForm.selectedRoomId);
if (selectedRoom) {
setWalkInSelectedRoom(selectedRoom);
const checkIn = new Date(walkInForm.checkInDate);
const checkOut = new Date(walkInForm.checkOutDate);
const nights = Math.ceil((checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24));
const roomPrice = selectedRoom.room_type?.base_price || selectedRoom.price || 0;
setWalkInTotalPrice(roomPrice * nights);
}
}
}, [walkInForm.checkInDate, walkInForm.checkOutDate, walkInForm.selectedRoomId, walkInAvailableRooms]);
const handleWalkInBooking = async () => {
if (!walkInForm.guestName || !walkInForm.guestPhone || !walkInForm.selectedRoomId) {
toast.error('Please fill in all required fields');
return;
}
if (!walkInSelectedRoom) {
toast.error('Please select a room');
return;
}
try {
setWalkInLoading(true);
// First, create or find user
let userId: number;
try {
const userResponse = await userService.getUsers({
search: walkInForm.guestEmail || walkInForm.guestPhone,
role: 'customer',
limit: 1,
});
if (userResponse.data?.users && userResponse.data.users.length > 0) {
userId = userResponse.data.users[0].id;
} else {
// Create new user for walk-in
const createUserResponse = await userService.createUser({
full_name: walkInForm.guestName,
email: walkInForm.guestEmail || `${walkInForm.guestPhone}@walkin.local`,
phone_number: walkInForm.guestPhone,
password: 'temp123', // Temporary password
role: 'customer',
});
userId = createUserResponse.data.user.id;
}
} catch (error: any) {
toast.error('Failed to create/find guest profile');
logger.error('Error creating user', error);
return;
}
// Create booking
const bookingData = {
user_id: userId,
room_id: parseInt(walkInForm.selectedRoomId),
check_in_date: walkInForm.checkInDate,
check_out_date: walkInForm.checkOutDate,
guest_count: walkInForm.numGuests,
total_price: walkInTotalPrice,
status: 'confirmed',
payment_method: walkInForm.paymentMethod,
guest_info: {
full_name: walkInForm.guestName,
email: walkInForm.guestEmail || `${walkInForm.guestPhone}@walkin.local`,
phone: walkInForm.guestPhone,
},
notes: walkInForm.specialRequests || undefined,
};
await bookingService.adminCreateBooking(bookingData);
toast.success('Walk-in booking created successfully!');
// Reset form
setWalkInForm({
guestName: '',
guestEmail: '',
guestPhone: '',
guestIdNumber: '',
selectedRoomId: '',
checkInDate: new Date().toISOString().split('T')[0],
checkOutDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0],
numGuests: 1,
numChildren: 0,
specialRequests: '',
paymentMethod: 'cash',
paymentStatus: 'unpaid',
});
setWalkInAvailableRooms([]);
setWalkInSelectedRoom(null);
setWalkInTotalPrice(0);
// Optionally switch to check-in tab
setActiveTab('check-in');
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to create walk-in booking');
logger.error('Error creating walk-in booking', error);
} finally {
setWalkInLoading(false);
}
};
const fetchBookings = useCallback(async () => {
try {
@@ -986,6 +1141,7 @@ const ReceptionDashboardPage: React.FC = () => {
{ id: 'overview' as ReceptionTab, label: 'Overview', icon: LogIn },
{ id: 'check-in' as ReceptionTab, label: 'Check-in', icon: LogIn },
{ id: 'check-out' as ReceptionTab, label: 'Check-out', icon: LogOut },
{ id: 'walk-in' as ReceptionTab, label: 'Walk-in Booking', icon: Plus },
{ id: 'bookings' as ReceptionTab, label: 'Bookings', icon: Calendar },
{ id: 'rooms' as ReceptionTab, label: 'Rooms', icon: Hotel },
{ id: 'services' as ReceptionTab, label: 'Services', icon: Wrench },
@@ -1947,6 +2103,271 @@ const ReceptionDashboardPage: React.FC = () => {
</div>
)}
{}
{activeTab === 'walk-in' && (
<div className="space-y-8">
{walkInLoading && (
<Loading fullScreen text="Creating walk-in booking..." />
)}
{}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-gradient-to-br from-purple-500/10 to-pink-500/10 border border-purple-200/40">
<Plus className="w-6 h-6 text-purple-600" />
</div>
<h2 className="text-3xl font-extrabold text-gray-900">Walk-in Booking</h2>
</div>
<p className="text-gray-600 text-base max-w-2xl leading-relaxed">
Quick booking creation for walk-in guests
</p>
</div>
</div>
{}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{}
<div className="space-y-6">
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
<span className="text-purple-600 font-bold">1</span>
</div>
Guest Information
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Full Name *</label>
<input
type="text"
value={walkInForm.guestName}
onChange={(e) => setWalkInForm({ ...walkInForm, guestName: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
placeholder="Guest full name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Phone Number *</label>
<input
type="tel"
value={walkInForm.guestPhone}
onChange={(e) => setWalkInForm({ ...walkInForm, guestPhone: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
placeholder="+1234567890"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Email</label>
<input
type="email"
value={walkInForm.guestEmail}
onChange={(e) => setWalkInForm({ ...walkInForm, guestEmail: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
placeholder="guest@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">ID Number</label>
<input
type="text"
value={walkInForm.guestIdNumber}
onChange={(e) => setWalkInForm({ ...walkInForm, guestIdNumber: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
placeholder="ID/Passport number"
/>
</div>
</div>
</div>
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
<span className="text-purple-600 font-bold">2</span>
</div>
Booking Details
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Check-in Date *</label>
<input
type="date"
value={walkInForm.checkInDate}
onChange={(e) => setWalkInForm({ ...walkInForm, checkInDate: e.target.value })}
min={new Date().toISOString().split('T')[0]}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Check-out Date *</label>
<input
type="date"
value={walkInForm.checkOutDate}
onChange={(e) => setWalkInForm({ ...walkInForm, checkOutDate: e.target.value })}
min={walkInForm.checkInDate || new Date().toISOString().split('T')[0]}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Number of Guests *</label>
<input
type="number"
min="1"
value={walkInForm.numGuests}
onChange={(e) => setWalkInForm({ ...walkInForm, numGuests: parseInt(e.target.value) || 1 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Children</label>
<input
type="number"
min="0"
value={walkInForm.numChildren}
onChange={(e) => setWalkInForm({ ...walkInForm, numChildren: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Special Requests</label>
<textarea
value={walkInForm.specialRequests}
onChange={(e) => setWalkInForm({ ...walkInForm, specialRequests: e.target.value })}
rows={3}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
placeholder="Any special requests or notes..."
/>
</div>
<button
onClick={handleWalkInSearchRooms}
disabled={walkInSearchingRooms || !walkInForm.checkInDate || !walkInForm.checkOutDate}
className="w-full px-6 py-3.5 bg-gradient-to-r from-purple-500 to-pink-600 text-white font-semibold rounded-xl shadow-xl hover:shadow-2xl transition-all duration-300 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
>
{walkInSearchingRooms ? 'Searching...' : 'Search Available Rooms'}
</button>
</div>
</div>
</div>
{}
<div className="space-y-6">
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
<span className="text-purple-600 font-bold">3</span>
</div>
Select Room
</h3>
{walkInAvailableRooms.length === 0 ? (
<div className="text-center py-12">
<Hotel className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-gray-500">Search for available rooms to see options</p>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{walkInAvailableRooms.map((room) => (
<div
key={room.id}
onClick={() => setWalkInForm({ ...walkInForm, selectedRoomId: room.id.toString() })}
className={`p-4 rounded-xl border-2 cursor-pointer transition-all duration-200 ${
walkInForm.selectedRoomId === room.id.toString()
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-300 hover:bg-purple-50/50'
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-bold text-gray-900">Room {room.room_number}</div>
<div className="text-sm text-gray-600">{room.room_type?.name}</div>
<div className="text-sm font-semibold text-purple-600 mt-1">
{formatCurrency(room.room_type?.base_price || room.price || 0)}/night
</div>
</div>
{walkInForm.selectedRoomId === room.id.toString() && (
<CheckCircle className="w-6 h-6 text-purple-600" />
)}
</div>
</div>
))}
</div>
)}
</div>
{walkInSelectedRoom && (
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
<span className="text-purple-600 font-bold">4</span>
</div>
Payment & Summary
</h3>
<div className="space-y-4">
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Room:</span>
<span className="font-semibold">Room {walkInSelectedRoom.room_number}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Nights:</span>
<span className="font-semibold">
{Math.ceil(
(new Date(walkInForm.checkOutDate).getTime() -
new Date(walkInForm.checkInDate).getTime()) /
(1000 * 60 * 60 * 24)
)}
</span>
</div>
<div className="flex justify-between text-lg font-bold pt-2 border-t border-gray-200">
<span>Total:</span>
<span className="text-purple-600">{formatCurrency(walkInTotalPrice)}</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Payment Method</label>
<select
value={walkInForm.paymentMethod}
onChange={(e) =>
setWalkInForm({ ...walkInForm, paymentMethod: e.target.value as 'cash' | 'stripe' })
}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
>
<option value="cash">Cash</option>
<option value="stripe">Card/Stripe</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Payment Status</label>
<select
value={walkInForm.paymentStatus}
onChange={(e) =>
setWalkInForm({ ...walkInForm, paymentStatus: e.target.value as 'unpaid' | 'deposit' | 'full' })
}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
>
<option value="unpaid">Unpaid</option>
<option value="deposit">Deposit</option>
<option value="full">Full Payment</option>
</select>
</div>
<button
onClick={handleWalkInBooking}
disabled={walkInLoading}
className="w-full px-6 py-4 bg-gradient-to-r from-purple-500 to-pink-600 text-white font-bold rounded-xl shadow-xl hover:shadow-2xl transition-all duration-300 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
>
{walkInLoading ? 'Creating Booking...' : 'Create Walk-in Booking'}
</button>
</div>
</div>
)}
</div>
</div>
</div>
)}
{}
{activeTab === 'bookings' && (
<div className="space-y-8">

View File

@@ -0,0 +1,721 @@
import React, { useState, useEffect } from 'react';
import {
TrendingUp,
Hotel,
Sparkles,
Search,
ArrowUp,
CheckCircle,
XCircle,
Eye,
Plus,
DollarSign,
Calendar,
User,
RefreshCw,
Filter,
Package,
} from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate, formatCurrency } from '../../shared/utils/format';
import { logger } from '../../shared/utils/logger';
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
import roomService, { Room, RoomType } from '../../features/rooms/services/roomService';
import serviceService, { Service } from '../../features/hotel_services/services/serviceService';
import Pagination from '../../shared/components/Pagination';
import apiClient from '../../shared/services/apiClient';
interface UpsellOpportunity {
booking: Booking;
upgradeRooms: Room[];
availableServices: Service[];
}
type Tab = 'opportunities' | 'room-upgrades' | 'service-upsells';
const UpsellManagementPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<Tab>('opportunities');
const [loading, setLoading] = useState(false);
const [bookings, setBookings] = useState<Booking[]>([]);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showServiceModal, setShowServiceModal] = useState(false);
const [availableRooms, setAvailableRooms] = useState<Room[]>([]);
const [availableServices, setAvailableServices] = useState<Service[]>([]);
const [roomTypes, setRoomTypes] = useState<RoomType[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [filters, setFilters] = useState({
search: '',
status: 'checked_in',
});
const [selectedUpgradeRoom, setSelectedUpgradeRoom] = useState<Room | null>(null);
const [selectedServices, setSelectedServices] = useState<Array<{ service: Service; quantity: number }>>([]);
const [upgradePrice, setUpgradePrice] = useState(0);
const [processing, setProcessing] = useState(false);
useEffect(() => {
fetchBookings();
fetchServices();
fetchRoomTypes();
}, [currentPage, filters.status]);
const fetchBookings = async () => {
try {
setLoading(true);
const response = await bookingService.getAllBookings({
status: filters.status,
page: currentPage,
limit: 20,
search: filters.search || undefined,
});
if (response.success && response.data?.bookings) {
// Filter to only show checked-in bookings for upsells
const checkedInBookings = response.data.bookings.filter(
(b: Booking) => b.status === 'checked_in'
);
setBookings(checkedInBookings);
setTotalPages(response.data.pagination?.total_pages || 1);
}
} catch (error: any) {
logger.error('Error fetching bookings', error);
toast.error('Failed to load bookings');
} finally {
setLoading(false);
}
};
const fetchServices = async () => {
try {
const response = await serviceService.getServices({ is_active: true });
if (response.data?.services) {
setAvailableServices(response.data.services);
}
} catch (error) {
logger.error('Error fetching services', error);
}
};
const fetchRoomTypes = async () => {
try {
const response = await roomService.getRoomTypes();
if (response.data?.room_types) {
setRoomTypes(response.data.room_types);
}
} catch (error) {
logger.error('Error fetching room types', error);
}
};
const handleViewUpgradeOptions = async (booking: Booking) => {
try {
setSelectedBooking(booking);
setLoading(true);
// Find available rooms of better room types
const currentRoomType = booking.room?.room_type;
if (!currentRoomType) {
toast.error('Current room type not found');
return;
}
// Get room types with higher base price
const betterRoomTypes = roomTypes.filter(
(rt) => rt.base_price > (currentRoomType.base_price || 0)
);
if (betterRoomTypes.length === 0) {
toast.info('No upgrade options available');
return;
}
// Search for available rooms in better room types
const checkIn = new Date(booking.check_in_date);
const checkOut = new Date(booking.check_out_date);
const allAvailableRooms: Room[] = [];
for (const roomType of betterRoomTypes) {
try {
const response = await roomService.searchAvailableRooms({
from: checkIn.toISOString().split('T')[0],
to: checkOut.toISOString().split('T')[0],
room_type_id: roomType.id,
capacity: booking.guest_count || 1,
});
if (response.data?.rooms) {
allAvailableRooms.push(...response.data.rooms);
}
} catch (error) {
logger.error(`Error fetching rooms for type ${roomType.id}`, error);
}
}
setAvailableRooms(allAvailableRooms);
setShowUpgradeModal(true);
} catch (error: any) {
toast.error('Failed to load upgrade options');
logger.error('Error loading upgrade options', error);
} finally {
setLoading(false);
}
};
const handleViewServiceOptions = (booking: Booking) => {
setSelectedBooking(booking);
setSelectedServices([]);
setShowServiceModal(true);
};
const calculateUpgradePrice = (booking: Booking, newRoom: Room) => {
if (!booking.room?.room_type || !newRoom.room_type) return 0;
const currentPrice = booking.room.room_type.base_price || 0;
const newPrice = newRoom.room_type.base_price || 0;
const priceDiff = newPrice - currentPrice;
// Calculate nights
const checkIn = new Date(booking.check_in_date);
const checkOut = new Date(booking.check_out_date);
const nights = Math.ceil((checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24));
return priceDiff * nights;
};
const handleSelectUpgradeRoom = (room: Room) => {
if (!selectedBooking) return;
setSelectedUpgradeRoom(room);
setUpgradePrice(calculateUpgradePrice(selectedBooking, room));
};
const handleApplyUpgrade = async () => {
if (!selectedBooking || !selectedUpgradeRoom) {
toast.error('Please select a room for upgrade');
return;
}
try {
setProcessing(true);
await bookingService.updateBooking(selectedBooking.id, {
room_id: selectedUpgradeRoom.id,
});
toast.success(
`Room upgraded successfully! Guest moved to Room ${selectedUpgradeRoom.room_number}. Additional charge: ${formatCurrency(upgradePrice)}`
);
setShowUpgradeModal(false);
setSelectedBooking(null);
setSelectedUpgradeRoom(null);
setUpgradePrice(0);
fetchBookings();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to apply upgrade');
logger.error('Error applying upgrade', error);
} finally {
setProcessing(false);
}
};
const handleAddService = (service: Service) => {
const existing = selectedServices.find((s) => s.service.id === service.id);
if (existing) {
setSelectedServices(
selectedServices.map((s) =>
s.service.id === service.id ? { ...s, quantity: s.quantity + 1 } : s
)
);
} else {
setSelectedServices([...selectedServices, { service, quantity: 1 }]);
}
};
const handleRemoveService = (serviceId: number) => {
setSelectedServices(selectedServices.filter((s) => s.service.id !== serviceId));
};
const handleUpdateServiceQuantity = (serviceId: number, quantity: number) => {
if (quantity <= 0) {
handleRemoveService(serviceId);
return;
}
setSelectedServices(
selectedServices.map((s) => (s.service.id === serviceId ? { ...s, quantity } : s))
);
};
const calculateServiceTotal = () => {
return selectedServices.reduce(
(total, item) => total + (item.service.price || 0) * item.quantity,
0
);
};
const handleApplyServiceUpsell = async () => {
if (!selectedBooking || selectedServices.length === 0) {
toast.error('Please select at least one service');
return;
}
try {
setProcessing(true);
// Add services to booking
for (const item of selectedServices) {
await apiClient.post('/bookings/service-usage', {
booking_id: selectedBooking.id,
service_id: item.service.id,
quantity: item.quantity,
unit_price: item.service.price,
total_price: item.service.price * item.quantity,
});
}
// Update booking total price
const serviceTotal = calculateServiceTotal();
const newTotal = (selectedBooking.total_price || 0) + serviceTotal;
await bookingService.updateBooking(selectedBooking.id, {
total_price: newTotal,
});
toast.success(`Services added successfully! Total: ${formatCurrency(serviceTotal)}`);
setShowServiceModal(false);
setSelectedBooking(null);
setSelectedServices([]);
fetchBookings();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to add services');
logger.error('Error adding services', error);
} finally {
setProcessing(false);
}
};
if (loading && bookings.length === 0) {
return <Loading fullScreen text="Loading upsell opportunities..." />;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* Header */}
<div className="mb-6 sm:mb-8">
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-green-400 via-emerald-500 to-teal-600 rounded-full"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Upsell Management
</h1>
</div>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">
Offer room upgrades and service upsells to increase revenue
</p>
</div>
{/* Tabs */}
<div className="mb-6 border-b border-slate-200">
<nav className="-mb-px flex space-x-8">
{[
{ id: 'opportunities' as Tab, label: 'Upsell Opportunities', icon: TrendingUp },
{ id: 'room-upgrades' as Tab, label: 'Room Upgrades', icon: Hotel },
{ id: 'service-upsells' as Tab, label: 'Service Upsells', icon: Sparkles },
].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 transition-colors
${
activeTab === tab.id
? 'border-green-500 text-green-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}
`}
>
<Icon className="w-5 h-5" />
<span>{tab.label}</span>
</button>
);
})}
</nav>
</div>
{/* Opportunities Tab */}
{activeTab === 'opportunities' && (
<div className="space-y-6">
{/* Filters */}
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5" />
<input
type="text"
placeholder="Search by booking number, guest name..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
onKeyPress={(e) => e.key === 'Enter' && fetchBookings()}
className="w-full pl-10 pr-4 py-2.5 border-2 border-slate-200 rounded-xl focus:border-green-400 focus:ring-4 focus:ring-green-100 transition-all duration-200 text-slate-700"
/>
</div>
<button
onClick={fetchBookings}
className="px-6 py-2.5 bg-green-600 text-white rounded-xl hover:bg-green-700 font-medium flex items-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
</div>
</div>
{/* Bookings List */}
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 overflow-hidden">
{bookings.length === 0 ? (
<div className="p-8 sm:p-12">
<EmptyState
title="No upsell opportunities"
description="No checked-in bookings available for upsells."
/>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Booking
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Guest
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Room
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Stay Duration
</th>
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{bookings.map((booking) => {
const checkIn = new Date(booking.check_in_date);
const checkOut = new Date(booking.check_out_date);
const nights = Math.ceil(
(checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24)
);
return (
<tr key={booking.id} className="hover:bg-slate-50">
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-slate-900">
{booking.booking_number}
</div>
<div className="text-xs text-slate-500">
{formatDate(booking.check_in_date)}
</div>
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
<div className="text-sm text-slate-900">
{booking.guest_info?.full_name || booking.user?.full_name || 'N/A'}
</div>
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
<div className="text-sm text-slate-900">
Room {booking.room?.room_number || 'N/A'}
</div>
<div className="text-xs text-slate-500">
{booking.room?.room_type?.name || 'N/A'}
</div>
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm text-slate-500">
{nights} night{nights !== 1 ? 's' : ''}
</td>
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
<button
onClick={() => handleViewUpgradeOptions(booking)}
className="text-green-600 hover:text-green-900 p-1.5 hover:bg-green-50 rounded-lg transition-colors"
title="View upgrade options"
>
<ArrowUp className="w-4 h-4" />
</button>
<button
onClick={() => handleViewServiceOptions(booking)}
className="text-blue-600 hover:text-blue-900 p-1.5 hover:bg-blue-50 rounded-lg transition-colors"
title="Add services"
>
<Plus className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="px-4 sm:px-6 py-4 border-t border-slate-200">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</>
)}
</div>
</div>
)}
{/* Room Upgrade Modal */}
{showUpgradeModal && 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 shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">Room Upgrade Options</h2>
<button
onClick={() => {
setShowUpgradeModal(false);
setSelectedUpgradeRoom(null);
setUpgradePrice(0);
}}
className="text-slate-400 hover:text-slate-600"
>
<XCircle className="w-6 h-6" />
</button>
</div>
<p className="text-sm text-slate-600 mt-2">
Booking: {selectedBooking.booking_number} | Current Room:{' '}
{selectedBooking.room?.room_number} ({selectedBooking.room?.room_type?.name})
</p>
</div>
<div className="p-6">
{availableRooms.length === 0 ? (
<EmptyState
title="No upgrade options available"
description="No better rooms available for the selected dates."
/>
) : (
<div className="space-y-4">
{availableRooms.map((room) => {
const priceDiff = calculateUpgradePrice(selectedBooking, room);
const isSelected = selectedUpgradeRoom?.id === room.id;
return (
<div
key={room.id}
onClick={() => handleSelectUpgradeRoom(room)}
className={`p-4 border-2 rounded-xl cursor-pointer transition-all ${
isSelected
? 'border-green-500 bg-green-50'
: 'border-slate-200 hover:border-green-300 hover:bg-slate-50'
}`}
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-bold text-lg text-slate-900">
Room {room.room_number}
</h3>
<p className="text-sm text-slate-600">{room.room_type?.name}</p>
<p className="text-xs text-slate-500 mt-1">
Floor: {room.floor} | Base Price: {formatCurrency(room.room_type?.base_price || 0)}/night
</p>
</div>
<div className="text-right">
<div className="text-lg font-bold text-green-600">
+{formatCurrency(priceDiff)}
</div>
<div className="text-xs text-slate-500">Additional charge</div>
</div>
</div>
</div>
);
})}
</div>
)}
{selectedUpgradeRoom && (
<div className="mt-6 pt-6 border-t border-slate-200">
<div className="bg-green-50 p-4 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-slate-900">Upgrade Summary</span>
<span className="text-2xl font-bold text-green-600">
+{formatCurrency(upgradePrice)}
</span>
</div>
<p className="text-sm text-slate-600">
Guest will be moved to Room {selectedUpgradeRoom.room_number} (
{selectedUpgradeRoom.room_type?.name})
</p>
</div>
<button
onClick={handleApplyUpgrade}
disabled={processing}
className="mt-4 w-full px-6 py-3 bg-green-600 text-white rounded-xl hover:bg-green-700 font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing ? (
<>
<RefreshCw className="w-5 h-5 animate-spin" />
Processing...
</>
) : (
<>
<CheckCircle className="w-5 h-5" />
Apply Upgrade
</>
)}
</button>
</div>
)}
</div>
</div>
</div>
)}
{/* Service Upsell Modal */}
{showServiceModal && 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 shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">Add Services</h2>
<button
onClick={() => {
setShowServiceModal(false);
setSelectedServices([]);
}}
className="text-slate-400 hover:text-slate-600"
>
<XCircle className="w-6 h-6" />
</button>
</div>
<p className="text-sm text-slate-600 mt-2">
Booking: {selectedBooking.booking_number} | Guest:{' '}
{selectedBooking.guest_info?.full_name || selectedBooking.user?.full_name}
</p>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{availableServices.map((service) => (
<div
key={service.id}
className="p-4 border-2 border-slate-200 rounded-xl hover:border-blue-300 transition-all"
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-bold text-slate-900">{service.name}</h3>
<span className="text-lg font-bold text-blue-600">
{formatCurrency(service.price || 0)}
</span>
</div>
{service.description && (
<p className="text-sm text-slate-600 mb-3">{service.description}</p>
)}
<button
onClick={() => handleAddService(service)}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
>
Add Service
</button>
</div>
))}
</div>
{selectedServices.length > 0 && (
<div className="mt-6 pt-6 border-t border-slate-200">
<h3 className="font-bold text-lg text-slate-900 mb-4">Selected Services</h3>
<div className="space-y-3">
{selectedServices.map((item) => (
<div
key={item.service.id}
className="flex items-center justify-between p-4 bg-slate-50 rounded-lg"
>
<div className="flex-1">
<div className="font-semibold text-slate-900">{item.service.name}</div>
<div className="text-sm text-slate-600">
{formatCurrency(item.service.price || 0)} each
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<button
onClick={() =>
handleUpdateServiceQuantity(item.service.id, item.quantity - 1)
}
className="px-2 py-1 bg-slate-200 rounded hover:bg-slate-300"
>
-
</button>
<span className="w-8 text-center font-semibold">{item.quantity}</span>
<button
onClick={() =>
handleUpdateServiceQuantity(item.service.id, item.quantity + 1)
}
className="px-2 py-1 bg-slate-200 rounded hover:bg-slate-300"
>
+
</button>
</div>
<div className="text-right w-24">
<div className="font-bold text-slate-900">
{formatCurrency((item.service.price || 0) * item.quantity)}
</div>
</div>
<button
onClick={() => handleRemoveService(item.service.id)}
className="text-red-600 hover:text-red-800 p-1"
>
<XCircle className="w-5 h-5" />
</button>
</div>
</div>
))}
</div>
<div className="mt-6 pt-4 border-t border-slate-200">
<div className="flex items-center justify-between mb-4">
<span className="text-lg font-semibold text-slate-900">Total</span>
<span className="text-2xl font-bold text-green-600">
{formatCurrency(calculateServiceTotal())}
</span>
</div>
<button
onClick={handleApplyServiceUpsell}
disabled={processing}
className="w-full px-6 py-3 bg-green-600 text-white rounded-xl hover:bg-green-700 font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing ? (
<>
<RefreshCw className="w-5 h-5 animate-spin" />
Processing...
</>
) : (
<>
<CheckCircle className="w-5 h-5" />
Add Services to Booking
</>
)}
</button>
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default UpsellManagementPage;

View File

@@ -16,6 +16,10 @@ const AnalyticsDashboardPage = lazy(() => import('../pages/staff/AnalyticsDashbo
const LoyaltyManagementPage = lazy(() => import('../pages/staff/LoyaltyManagementPage'));
const GuestProfilePage = lazy(() => import('../pages/staff/GuestProfilePage'));
const AdvancedRoomManagementPage = lazy(() => import('../pages/staff/AdvancedRoomManagementPage'));
const GuestRequestManagementPage = lazy(() => import('../pages/staff/GuestRequestManagementPage'));
const GuestCommunicationPage = lazy(() => import('../pages/staff/GuestCommunicationPage'));
const IncidentComplaintManagementPage = lazy(() => import('../pages/staff/IncidentComplaintManagementPage'));
const UpsellManagementPage = lazy(() => import('../pages/staff/UpsellManagementPage'));
const staffRoutes: RouteObject[] = [
{
@@ -31,6 +35,10 @@ const staffRoutes: RouteObject[] = [
{ path: 'loyalty', element: <LoyaltyManagementPage /> },
{ path: 'guest-profiles', element: <GuestProfilePage /> },
{ path: 'advanced-rooms', element: <AdvancedRoomManagementPage /> },
{ path: 'guest-requests', element: <GuestRequestManagementPage /> },
{ path: 'guest-communication', element: <GuestCommunicationPage /> },
{ path: 'incidents-complaints', element: <IncidentComplaintManagementPage /> },
{ path: 'upsells', element: <UpsellManagementPage /> },
],
},
];

View File

@@ -14,7 +14,11 @@ import {
MessageCircle,
Award,
Users,
Wrench
Wrench,
Bell,
Mail,
AlertTriangle,
TrendingUp
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useChatNotifications } from '../../features/notifications/contexts/ChatNotificationContext';
@@ -112,6 +116,26 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
icon: Users,
label: 'Guest Profiles'
},
{
path: '/staff/guest-requests',
icon: Bell,
label: 'Guest Requests'
},
{
path: '/staff/guest-communication',
icon: Mail,
label: 'Communication'
},
{
path: '/staff/incidents-complaints',
icon: AlertTriangle,
label: 'Incidents & Complaints'
},
{
path: '/staff/upsells',
icon: TrendingUp,
label: 'Upsell Management'
},
{
path: '/staff/advanced-rooms',
icon: Wrench,

View File

@@ -250,14 +250,32 @@ apiClient.interceptors.response.use(
if (status === 403) {
// SECURITY: Sanitize error message - don't expose internal details
const rawMessage = (error.response?.data as any)?.message || '';
// Only show generic message to users, log details in dev mode
const errorMessage = rawMessage.includes('CSRF token')
? 'Security validation failed. Please refresh the page and try again.'
: 'You do not have permission to access this resource.';
const rawMessage = (error.response?.data as any)?.message || (error.response?.data as any)?.detail || '';
const errorData = error.response?.data as any;
// Determine error type for better UX
let errorMessage = 'You do not have permission to access this resource.';
let shouldRetry = false;
if (rawMessage.includes('CSRF token') || rawMessage.toLowerCase().includes('csrf')) {
errorMessage = 'Security validation failed. Please refresh the page and try again.';
shouldRetry = true;
} else if (rawMessage.toLowerCase().includes('forbidden') || rawMessage.toLowerCase().includes('permission')) {
// Keep generic message for permission errors
errorMessage = 'You do not have permission to access this resource.';
}
// Log 403 errors in development for debugging (not in production to avoid information disclosure)
if (import.meta.env.DEV) {
logDebug('403 Forbidden error', {
url: originalRequest?.url,
method: originalRequest?.method,
rawMessage: rawMessage.substring(0, 100), // Limit length
});
}
// Handle CSRF token missing/invalid errors - retry after getting token from error response
if (errorMessage.includes('CSRF token') && originalRequest && !originalRequest._retry) {
if (shouldRetry && originalRequest && !originalRequest._retry) {
// The backend sets the CSRF cookie in the error response, so wait a moment for browser to process it
await new Promise(resolve => setTimeout(resolve, 200));