updates
This commit is contained in:
@@ -20,24 +20,28 @@ import {
|
||||
X,
|
||||
Image as ImageIcon,
|
||||
Check,
|
||||
Bed,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import {
|
||||
RoomStatusBoardItem,
|
||||
} from '../../features/rooms/services/advancedRoomService';
|
||||
import roomService, { Room } from '../../features/rooms/services/roomService';
|
||||
import roomService, { Room, RoomType, CreateRoomTypeData } from '../../features/rooms/services/roomService';
|
||||
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 apiClient from '../../shared/services/apiClient';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import { useRoomContext } from '../../features/rooms/contexts/RoomContext';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
|
||||
type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections';
|
||||
type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections' | 'room-types';
|
||||
|
||||
const AdvancedRoomManagementPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const {
|
||||
statusBoardRooms,
|
||||
statusBoardLoading,
|
||||
@@ -79,6 +83,21 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
const [customAmenityInput, setCustomAmenityInput] = useState('');
|
||||
const [editingAmenity, setEditingAmenity] = useState<{ name: string; newName: string } | null>(null);
|
||||
|
||||
// Room Types management state
|
||||
const [roomTypesList, setRoomTypesList] = useState<RoomType[]>([]);
|
||||
const [roomTypesLoading, setRoomTypesLoading] = useState(false);
|
||||
const [showRoomTypeModal, setShowRoomTypeModal] = useState(false);
|
||||
const [editingRoomType, setEditingRoomType] = useState<RoomType | null>(null);
|
||||
const [roomTypeFormData, setRoomTypeFormData] = useState<CreateRoomTypeData>({
|
||||
name: '',
|
||||
description: '',
|
||||
base_price: 0,
|
||||
capacity: 1,
|
||||
amenities: [],
|
||||
});
|
||||
const [roomTypeSearchFilter, setRoomTypeSearchFilter] = useState('');
|
||||
const [customRoomTypeAmenityInput, setCustomRoomTypeAmenityInput] = useState('');
|
||||
|
||||
// Define fetchFloors before using it in useEffect
|
||||
const fetchFloors = useCallback(async () => {
|
||||
try {
|
||||
@@ -288,6 +307,134 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
}
|
||||
}, [editingRoom]);
|
||||
|
||||
// Helper function to ensure amenities is always an array
|
||||
const getAmenitiesArray = (amenities: any): string[] => {
|
||||
if (!amenities) return [];
|
||||
if (Array.isArray(amenities)) return amenities;
|
||||
if (typeof amenities === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(amenities);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Fetch room types for management tab
|
||||
const fetchRoomTypesList = useCallback(async () => {
|
||||
try {
|
||||
setRoomTypesLoading(true);
|
||||
const response = await roomService.getRoomTypes();
|
||||
if (response.data?.room_types) {
|
||||
setRoomTypesList(response.data.room_types);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to fetch room types', error);
|
||||
toast.error(error.response?.data?.detail || 'Unable to load room types');
|
||||
} finally {
|
||||
setRoomTypesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Room Type management functions
|
||||
const handleRoomTypeSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingRoomType) {
|
||||
await roomService.updateRoomType(editingRoomType.id, roomTypeFormData);
|
||||
toast.success('Room type updated successfully');
|
||||
} else {
|
||||
await roomService.createRoomType(roomTypeFormData);
|
||||
toast.success('Room type created successfully');
|
||||
}
|
||||
setShowRoomTypeModal(false);
|
||||
resetRoomTypeForm();
|
||||
fetchRoomTypesList();
|
||||
fetchRoomTypes(); // Refresh dropdown list
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoomTypeEdit = (roomType: RoomType) => {
|
||||
setEditingRoomType(roomType);
|
||||
setRoomTypeFormData({
|
||||
name: roomType.name,
|
||||
description: roomType.description || '',
|
||||
base_price: roomType.base_price,
|
||||
capacity: roomType.capacity,
|
||||
amenities: getAmenitiesArray(roomType.amenities),
|
||||
});
|
||||
setShowRoomTypeModal(true);
|
||||
};
|
||||
|
||||
const handleRoomTypeDelete = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this room type? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await roomService.deleteRoomType(id);
|
||||
toast.success('Room type deleted successfully');
|
||||
fetchRoomTypesList();
|
||||
fetchRoomTypes(); // Refresh dropdown list
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Unable to delete room type');
|
||||
}
|
||||
};
|
||||
|
||||
const resetRoomTypeForm = () => {
|
||||
setEditingRoomType(null);
|
||||
setRoomTypeFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
base_price: 0,
|
||||
capacity: 1,
|
||||
amenities: [],
|
||||
});
|
||||
setCustomRoomTypeAmenityInput('');
|
||||
};
|
||||
|
||||
const toggleRoomTypeAmenity = (amenity: string) => {
|
||||
const currentAmenities = roomTypeFormData.amenities || [];
|
||||
if (currentAmenities.includes(amenity)) {
|
||||
setRoomTypeFormData({
|
||||
...roomTypeFormData,
|
||||
amenities: currentAmenities.filter(a => a !== amenity),
|
||||
});
|
||||
} else {
|
||||
setRoomTypeFormData({
|
||||
...roomTypeFormData,
|
||||
amenities: [...currentAmenities, amenity],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addCustomRoomTypeAmenity = () => {
|
||||
if (customRoomTypeAmenityInput.trim() && !roomTypeFormData.amenities?.includes(customRoomTypeAmenityInput.trim())) {
|
||||
setRoomTypeFormData({
|
||||
...roomTypeFormData,
|
||||
amenities: [...(roomTypeFormData.amenities || []), customRoomTypeAmenityInput.trim()],
|
||||
});
|
||||
setCustomRoomTypeAmenityInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeRoomTypeAmenity = (amenity: string) => {
|
||||
setRoomTypeFormData({
|
||||
...roomTypeFormData,
|
||||
amenities: (roomTypeFormData.amenities || []).filter(a => a !== amenity),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'room-types') {
|
||||
fetchRoomTypesList();
|
||||
}
|
||||
}, [activeTab, fetchRoomTypesList]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const abortController = new AbortController();
|
||||
@@ -352,7 +499,8 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
capacity: roomFormData.capacity ? parseInt(roomFormData.capacity) : undefined,
|
||||
room_size: roomFormData.room_size || undefined,
|
||||
view: roomFormData.view || undefined,
|
||||
amenities: Array.isArray(roomFormData.amenities) ? roomFormData.amenities : [],
|
||||
// Don't send amenities - backend will inherit from room type
|
||||
amenities: [],
|
||||
};
|
||||
await contextUpdateRoom(editingRoom.id, updateData);
|
||||
|
||||
@@ -372,56 +520,74 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
capacity: roomFormData.capacity ? parseInt(roomFormData.capacity) : undefined,
|
||||
room_size: roomFormData.room_size || undefined,
|
||||
view: roomFormData.view || undefined,
|
||||
amenities: Array.isArray(roomFormData.amenities) ? roomFormData.amenities : [],
|
||||
// Don't send amenities - backend will inherit from room type
|
||||
amenities: [],
|
||||
};
|
||||
const response = await roomService.createRoom(createData);
|
||||
|
||||
if (response.data?.room) {
|
||||
if (selectedFiles.length > 0) {
|
||||
try {
|
||||
setUploadingImages(true);
|
||||
const uploadFormData = new FormData();
|
||||
selectedFiles.forEach(file => {
|
||||
uploadFormData.append('images', file);
|
||||
});
|
||||
|
||||
await apiClient.post(`/rooms/${response.data.room.id}/images`, uploadFormData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('Images uploaded successfully');
|
||||
// Validate files before upload
|
||||
const MAX_FILES = 5;
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB per file
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
|
||||
|
||||
if (selectedFiles.length > MAX_FILES) {
|
||||
toast.error(`Maximum ${MAX_FILES} images allowed.`);
|
||||
setSelectedFiles([]);
|
||||
const updatedRoom = await roomService.getRoomByNumber(response.data.room.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
} catch (uploadError: any) {
|
||||
toast.error(uploadError.response?.data?.message || 'Room created but failed to upload images');
|
||||
const updatedRoom = await roomService.getRoomByNumber(response.data.room.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
} finally {
|
||||
setUploadingImages(false);
|
||||
} else {
|
||||
const invalidFiles = selectedFiles.filter(file =>
|
||||
!ALLOWED_TYPES.includes(file.type) || file.size > MAX_FILE_SIZE
|
||||
);
|
||||
|
||||
if (invalidFiles.length > 0) {
|
||||
toast.error('Some files are invalid. Please select valid image files (max 5MB each).');
|
||||
setSelectedFiles([]);
|
||||
} else {
|
||||
try {
|
||||
setUploadingImages(true);
|
||||
const uploadFormData = new FormData();
|
||||
selectedFiles.forEach(file => {
|
||||
uploadFormData.append('images', file);
|
||||
});
|
||||
|
||||
await apiClient.post(`/rooms/${response.data.room.id}/images`, uploadFormData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('Images uploaded successfully');
|
||||
setSelectedFiles([]);
|
||||
const updatedRoom = await roomService.getRoomByNumber(response.data.room.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
} catch (uploadError: any) {
|
||||
toast.error(uploadError.response?.data?.message || 'Room created but failed to upload images');
|
||||
const updatedRoom = await roomService.getRoomByNumber(response.data.room.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
} finally {
|
||||
setUploadingImages(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const updatedRoom = await roomService.getRoomByNumber(response.data.room.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
}
|
||||
|
||||
setRoomFormData({
|
||||
room_number: response.data.room.room_number,
|
||||
floor: response.data.room.floor,
|
||||
room_type_id: response.data.room.room_type_id,
|
||||
status: response.data.room.status,
|
||||
featured: response.data.room.featured,
|
||||
price: response.data.room.price?.toString() || '',
|
||||
description: response.data.room.description || '',
|
||||
capacity: response.data.room.capacity?.toString() || '',
|
||||
room_size: response.data.room.room_size || '',
|
||||
view: response.data.room.view || '',
|
||||
amenities: response.data.room.amenities || [],
|
||||
});
|
||||
// Refresh context to sync the new room - don't call contextCreateRoom as it would duplicate
|
||||
await refreshRooms();
|
||||
await refreshStatusBoard();
|
||||
|
||||
await contextCreateRoom(createData);
|
||||
setShowRoomModal(false);
|
||||
resetRoomForm();
|
||||
toast.success('Room created successfully');
|
||||
return;
|
||||
|
||||
// Refresh context to sync the new room - don't call contextCreateRoom as it would duplicate
|
||||
await refreshRooms();
|
||||
await refreshStatusBoard();
|
||||
|
||||
setShowRoomModal(false);
|
||||
resetRoomForm();
|
||||
toast.success('Room created successfully');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -486,10 +652,14 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
|
||||
const resetRoomForm = () => {
|
||||
setEditingRoom(null);
|
||||
// Get default amenities from first room type if available
|
||||
const defaultRoomType = roomTypesList.length > 0 ? roomTypesList[0] : null;
|
||||
const defaultAmenities = defaultRoomType ? getAmenitiesArray(defaultRoomType.amenities) : [];
|
||||
|
||||
setRoomFormData({
|
||||
room_number: '',
|
||||
floor: 1,
|
||||
room_type_id: 1,
|
||||
room_type_id: roomTypes.length > 0 ? roomTypes[0].id : 1,
|
||||
status: 'available',
|
||||
featured: false,
|
||||
price: '',
|
||||
@@ -497,7 +667,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
capacity: '',
|
||||
room_size: '',
|
||||
view: '',
|
||||
amenities: [],
|
||||
amenities: defaultAmenities, // Inherit from default room type
|
||||
});
|
||||
setSelectedFiles([]);
|
||||
setUploadingImages(false);
|
||||
@@ -505,14 +675,8 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
setEditingAmenity(null);
|
||||
};
|
||||
|
||||
const toggleAmenity = (amenity: string) => {
|
||||
setRoomFormData(prev => ({
|
||||
...prev,
|
||||
amenities: prev.amenities.includes(amenity)
|
||||
? prev.amenities.filter(a => a !== amenity)
|
||||
: [...prev.amenities, amenity]
|
||||
}));
|
||||
};
|
||||
// Amenities are now managed only at the room type level
|
||||
// Rooms automatically inherit amenities from their room type
|
||||
|
||||
const handleAddCustomAmenity = () => {
|
||||
const trimmed = customAmenityInput.trim();
|
||||
@@ -595,15 +759,84 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
setSelectedFiles(files);
|
||||
if (!e.target.files || e.target.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(e.target.files);
|
||||
const MAX_FILES = 5;
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB per file
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
|
||||
|
||||
// Check file count
|
||||
if (files.length > MAX_FILES) {
|
||||
toast.error(`Maximum ${MAX_FILES} images allowed. Please select fewer files.`);
|
||||
e.target.value = ''; // Reset input
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate each file
|
||||
const validFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
// Check file type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
errors.push(`${file.name}: Invalid file type. Only JPEG, PNG, WebP, and GIF images are allowed.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
errors.push(`${file.name}: File size exceeds ${MAX_FILE_SIZE / (1024 * 1024)}MB limit.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
// Show errors if any
|
||||
if (errors.length > 0) {
|
||||
errors.forEach(error => toast.error(error));
|
||||
}
|
||||
|
||||
// Set valid files
|
||||
if (validFiles.length > 0) {
|
||||
setSelectedFiles(validFiles);
|
||||
if (validFiles.length < files.length) {
|
||||
toast.warning(`${validFiles.length} of ${files.length} files selected. Some files were rejected.`);
|
||||
}
|
||||
} else {
|
||||
setSelectedFiles([]);
|
||||
e.target.value = ''; // Reset input if no valid files
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadImages = async () => {
|
||||
if (!editingRoom || selectedFiles.length === 0) return;
|
||||
|
||||
// Validate before upload (double-check)
|
||||
const MAX_FILES = 5;
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB per file
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
|
||||
|
||||
if (selectedFiles.length > MAX_FILES) {
|
||||
toast.error(`Maximum ${MAX_FILES} images allowed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate each file one more time before upload
|
||||
const invalidFiles = selectedFiles.filter(file =>
|
||||
!ALLOWED_TYPES.includes(file.type) || file.size > MAX_FILE_SIZE
|
||||
);
|
||||
|
||||
if (invalidFiles.length > 0) {
|
||||
toast.error('Some files are invalid. Please select valid image files (max 5MB each).');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploadingImages(true);
|
||||
const formData = new FormData();
|
||||
@@ -712,6 +945,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{[
|
||||
{ id: 'status-board' as Tab, label: 'Rooms & Status Board', icon: Hotel },
|
||||
{ id: 'room-types' as Tab, label: 'Room Types', icon: Bed },
|
||||
{ id: 'maintenance' as Tab, label: 'Maintenance', icon: Wrench },
|
||||
{ id: 'housekeeping' as Tab, label: 'Housekeeping', icon: Sparkles },
|
||||
{ id: 'inspections' as Tab, label: 'Inspections', icon: ClipboardCheck },
|
||||
@@ -955,6 +1189,148 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
{/* Inspections Tab */}
|
||||
{activeTab === 'inspections' && <InspectionManagement />}
|
||||
|
||||
{/* Room Types Tab */}
|
||||
{activeTab === 'room-types' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Room Type Management</h2>
|
||||
<p className="text-gray-600 mt-1">Manage room types, pricing, and amenities</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetRoomTypeForm();
|
||||
setShowRoomTypeModal(true);
|
||||
}}
|
||||
className="flex items-center space-x-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-lg hover:from-blue-600 hover:to-blue-700 transition-all duration-200 shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add Room Type</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Filter */}
|
||||
<div className="bg-white rounded-xl p-4 border border-gray-200 shadow-sm">
|
||||
<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"
|
||||
placeholder="Search room types..."
|
||||
value={roomTypeSearchFilter}
|
||||
onChange={(e) => setRoomTypeSearchFilter(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 border-2 border-gray-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room Types Table */}
|
||||
{roomTypesLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gradient-to-r from-gray-800 to-gray-900">
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-white uppercase tracking-wider">Name</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-white uppercase tracking-wider">Description</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-white uppercase tracking-wider">Base Price</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-white uppercase tracking-wider">Capacity</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-white uppercase tracking-wider">Amenities</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-bold text-white uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{roomTypesList
|
||||
.filter(rt =>
|
||||
rt.name.toLowerCase().includes(roomTypeSearchFilter.toLowerCase()) ||
|
||||
(rt.description && rt.description.toLowerCase().includes(roomTypeSearchFilter.toLowerCase()))
|
||||
)
|
||||
.map((roomType) => {
|
||||
const amenities = getAmenitiesArray(roomType.amenities);
|
||||
return (
|
||||
<tr key={roomType.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Bed className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900">{roomType.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-600 max-w-md">
|
||||
{roomType.description || <span className="text-gray-400 italic">No description</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-bold text-emerald-600">
|
||||
{formatCurrency(roomType.base_price)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-gray-700">
|
||||
{roomType.capacity} {roomType.capacity === 1 ? 'guest' : 'guests'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-wrap gap-1 max-w-md">
|
||||
{amenities.slice(0, 3).map((amenity, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-md"
|
||||
>
|
||||
{amenity}
|
||||
</span>
|
||||
))}
|
||||
{amenities.length > 3 && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded-md">
|
||||
+{amenities.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
{amenities.length === 0 && (
|
||||
<span className="text-xs text-gray-400 italic">No amenities</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleRoomTypeEdit(roomType)}
|
||||
className="p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRoomTypeDelete(roomType.id)}
|
||||
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{roomTypesList.filter(rt =>
|
||||
rt.name.toLowerCase().includes(roomTypeSearchFilter.toLowerCase()) ||
|
||||
(rt.description && rt.description.toLowerCase().includes(roomTypeSearchFilter.toLowerCase()))
|
||||
).length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Bed className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No room types found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Room Modal */}
|
||||
{showRoomModal && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-[100] p-4">
|
||||
@@ -1018,7 +1394,17 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
</label>
|
||||
<select
|
||||
value={roomFormData.room_type_id}
|
||||
onChange={(e) => setRoomFormData({ ...roomFormData, room_type_id: parseInt(e.target.value) })}
|
||||
onChange={(e) => {
|
||||
const selectedRoomTypeId = parseInt(e.target.value);
|
||||
// Find the selected room type and inherit its amenities
|
||||
const selectedRoomType = roomTypesList.find(rt => rt.id === selectedRoomTypeId);
|
||||
const inheritedAmenities = selectedRoomType ? getAmenitiesArray(selectedRoomType.amenities) : [];
|
||||
setRoomFormData({
|
||||
...roomFormData,
|
||||
room_type_id: selectedRoomTypeId,
|
||||
amenities: inheritedAmenities // Inherit amenities from room type
|
||||
});
|
||||
}}
|
||||
className="w-full px-4 py-3 bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg text-white focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] transition-all duration-300"
|
||||
required
|
||||
>
|
||||
@@ -1032,6 +1418,9 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
<option value="" className="bg-[#1a1a1a]">Loading...</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1 italic">
|
||||
Amenities are inherited from the selected room type. Manage amenities in the Room Types tab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1140,53 +1529,47 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
Amenities & Features
|
||||
</h3>
|
||||
|
||||
<div className="border border-[#d4af37]/20 rounded-lg p-4 max-h-80 overflow-y-auto bg-[#0a0a0a]/50 backdrop-blur-sm">
|
||||
{availableAmenities.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 text-center py-4">Loading amenities...</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{availableAmenities.map((amenity) => {
|
||||
const isSelected = roomFormData.amenities.includes(amenity);
|
||||
return (
|
||||
<label
|
||||
key={amenity}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all duration-300 ${
|
||||
isSelected
|
||||
? 'bg-gradient-to-r from-[#d4af37]/20 to-[#c9a227]/20 border-2 border-[#d4af37] shadow-lg shadow-[#d4af37]/20'
|
||||
: 'bg-[#1a1a1a]/50 border-2 border-[#333] hover:border-[#d4af37]/30 hover:bg-[#1a1a1a]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleAmenity(amenity)}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all duration-300 ${
|
||||
isSelected
|
||||
? 'bg-[#d4af37] border-[#d4af37] shadow-lg shadow-[#d4af37]/30'
|
||||
: 'border-gray-600 bg-transparent'
|
||||
}`}>
|
||||
{isSelected && <Check className="w-3 h-3 text-[#0f0f0f] font-bold" />}
|
||||
</div>
|
||||
<span className={`text-sm flex-1 transition-colors ${
|
||||
isSelected
|
||||
? 'font-semibold text-[#d4af37]'
|
||||
: 'text-gray-400 hover:text-gray-300'
|
||||
}`}>
|
||||
<div className="border border-[#d4af37]/20 rounded-lg p-4 bg-[#0a0a0a]/50 backdrop-blur-sm">
|
||||
{(() => {
|
||||
const selectedRoomType = roomTypesList.find(rt => rt.id === roomFormData.room_type_id);
|
||||
const inheritedAmenities = selectedRoomType ? getAmenitiesArray(selectedRoomType.amenities) : [];
|
||||
|
||||
if (inheritedAmenities.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-sm text-gray-400 mb-2">No amenities defined for this room type.</p>
|
||||
<p className="text-xs text-gray-500 italic">
|
||||
Go to the <strong>Room Types</strong> tab to add amenities to this room type.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-gray-400 italic flex items-center gap-2">
|
||||
<Check className="w-3 h-3 text-[#d4af37]" />
|
||||
Inherited from room type: <strong className="text-[#d4af37]">{selectedRoomType?.name}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{inheritedAmenities.map((amenity) => (
|
||||
<span
|
||||
key={amenity}
|
||||
className="px-3 py-1.5 bg-gradient-to-r from-[#d4af37]/20 to-[#c9a227]/20 border border-[#d4af37]/30 rounded-lg text-sm text-[#d4af37] font-medium"
|
||||
>
|
||||
{amenity}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3 italic">
|
||||
To modify amenities, edit the room type in the <strong>Room Types</strong> tab.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{roomFormData.amenities.length > 0 && (
|
||||
<p className="text-xs text-gray-400 font-light italic">
|
||||
{roomFormData.amenities.length} amenit{roomFormData.amenities.length === 1 ? 'y' : 'ies'} selected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-4 border-t border-[#d4af37]/20">
|
||||
@@ -1322,13 +1705,13 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Add New Images (max 5 images):
|
||||
Add New Images (max 5 images, 5MB each):
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp,image/gif"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="w-full text-sm text-gray-400
|
||||
@@ -1340,17 +1723,25 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
hover:file:border-[#d4af37] file:cursor-pointer
|
||||
transition-all duration-300 bg-[#0a0a0a] rounded-lg"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1 italic">
|
||||
Accepted formats: JPEG, PNG, WebP, GIF. Maximum 5MB per file.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="text-sm text-gray-400 font-light italic">
|
||||
{selectedFiles.length} file(s) selected
|
||||
{selectedFiles.length > 0 && (
|
||||
<span className="ml-2 text-xs">
|
||||
(Total: {(selectedFiles.reduce((sum, f) => sum + f.size, 0) / (1024 * 1024)).toFixed(2)}MB)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUploadImages}
|
||||
disabled={uploadingImages}
|
||||
disabled={uploadingImages || selectedFiles.length === 0}
|
||||
className="px-6 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-semibold shadow-lg shadow-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/40 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{uploadingImages ? 'Uploading...' : 'Upload Images'}
|
||||
@@ -1376,6 +1767,173 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Room Type Modal */}
|
||||
{showRoomTypeModal && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-gradient-to-r from-blue-500 to-blue-600 px-8 py-6 flex justify-between items-center rounded-t-2xl">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{editingRoomType ? 'Edit Room Type' : 'Create Room Type'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRoomTypeModal(false);
|
||||
resetRoomTypeForm();
|
||||
}}
|
||||
className="p-2 rounded-lg text-white hover:bg-white/20 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleRoomTypeSubmit} className="p-8 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={roomTypeFormData.name}
|
||||
onChange={(e) => setRoomTypeFormData({ ...roomTypeFormData, name: e.target.value })}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Base Price *</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={roomTypeFormData.base_price}
|
||||
onChange={(e) => setRoomTypeFormData({ ...roomTypeFormData, base_price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Capacity *</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="20"
|
||||
value={roomTypeFormData.capacity}
|
||||
onChange={(e) => setRoomTypeFormData({ ...roomTypeFormData, capacity: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Description</label>
|
||||
<textarea
|
||||
value={roomTypeFormData.description}
|
||||
onChange={(e) => setRoomTypeFormData({ ...roomTypeFormData, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Amenities</h3>
|
||||
|
||||
{/* Available Amenities */}
|
||||
{availableAmenities.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Select Amenities</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableAmenities.map((amenity) => (
|
||||
<button
|
||||
key={amenity}
|
||||
type="button"
|
||||
onClick={() => toggleRoomTypeAmenity(amenity)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
roomTypeFormData.amenities?.includes(amenity)
|
||||
? 'bg-blue-500 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{amenity}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Amenity Input */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Add Custom Amenity</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customRoomTypeAmenityInput}
|
||||
onChange={(e) => setCustomRoomTypeAmenityInput(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addCustomRoomTypeAmenity();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter amenity name..."
|
||||
className="flex-1 px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustomRoomTypeAmenity}
|
||||
className="px-6 py-3 bg-blue-500 text-white rounded-xl font-semibold hover:bg-blue-600 transition-all"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Amenities */}
|
||||
{(roomTypeFormData.amenities || []).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Selected Amenities</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(roomTypeFormData.amenities || []).map((amenity, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-800 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{amenity}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRoomTypeAmenity(amenity)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowRoomTypeModal(false);
|
||||
resetRoomTypeForm();
|
||||
}}
|
||||
className="px-6 py-3 border-2 border-gray-300 text-gray-700 rounded-xl font-semibold hover:bg-gray-50 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl font-semibold hover:from-blue-600 hover:to-blue-700 transition-all shadow-lg hover:shadow-xl"
|
||||
>
|
||||
{editingRoomType ? 'Update Room Type' : 'Create Room Type'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user