updates
This commit is contained in:
@@ -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 />}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
140
Frontend/src/features/inventory/services/inventoryService.ts
Normal file
140
Frontend/src/features/inventory/services/inventoryService.ts
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
150
Frontend/src/features/staffShifts/services/staffShiftService.ts
Normal file
150
Frontend/src/features/staffShifts/services/staffShiftService.ts
Normal 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -680,8 +680,7 @@ const BannerManagementPage: React.FC = () => {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -513,8 +513,7 @@ const PromotionManagementPage: React.FC = () => {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
457
Frontend/src/pages/customer/GuestRequestsPage.tsx
Normal file
457
Frontend/src/pages/customer/GuestRequestsPage.tsx
Normal 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
@@ -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 />}
|
||||
|
||||
|
||||
708
Frontend/src/pages/staff/GuestCommunicationPage.tsx
Normal file
708
Frontend/src/pages/staff/GuestCommunicationPage.tsx
Normal 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;
|
||||
|
||||
759
Frontend/src/pages/staff/GuestRequestManagementPage.tsx
Normal file
759
Frontend/src/pages/staff/GuestRequestManagementPage.tsx
Normal 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;
|
||||
|
||||
800
Frontend/src/pages/staff/IncidentComplaintManagementPage.tsx
Normal file
800
Frontend/src/pages/staff/IncidentComplaintManagementPage.tsx
Normal 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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
721
Frontend/src/pages/staff/UpsellManagementPage.tsx
Normal file
721
Frontend/src/pages/staff/UpsellManagementPage.tsx
Normal 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;
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user