diff --git a/Backend/src/content/routes/__pycache__/blog_routes.cpython-312.pyc b/Backend/src/content/routes/__pycache__/blog_routes.cpython-312.pyc index d936e0c4..fa65499d 100644 Binary files a/Backend/src/content/routes/__pycache__/blog_routes.cpython-312.pyc and b/Backend/src/content/routes/__pycache__/blog_routes.cpython-312.pyc differ diff --git a/Backend/src/content/routes/blog_routes.py b/Backend/src/content/routes/blog_routes.py index c61fdfcf..e65f5786 100644 --- a/Backend/src/content/routes/blog_routes.py +++ b/Backend/src/content/routes/blog_routes.py @@ -562,3 +562,122 @@ async def upload_blog_image( except Exception as e: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error uploading image: {str(e)}') +@router.get('/admin/tags', response_model=dict) +async def get_all_tags( + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Get all unique tags from all blog posts (admin only)""" + try: + all_posts = db.query(BlogPost).all() + all_unique_tags = set() + tag_usage = {} # Track how many posts use each tag + + for post in all_posts: + if post.tags: + try: + tags_list = json.loads(post.tags) + for tag in tags_list: + all_unique_tags.add(tag) + tag_usage[tag] = tag_usage.get(tag, 0) + 1 + except: + pass + + tags_with_usage = [ + {'name': tag, 'usage_count': tag_usage.get(tag, 0)} + for tag in sorted(all_unique_tags) + ] + + return success_response({ + 'tags': tags_with_usage, + 'total': len(tags_with_usage) + }) + except Exception as e: + logger.error(f"Error in get_all_tags: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + +@router.put('/admin/tags/rename', response_model=dict) +async def rename_tag( + old_tag: str = Query(..., description='Old tag name'), + new_tag: str = Query(..., description='New tag name'), + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Rename a tag across all blog posts (admin only)""" + try: + if not old_tag or not new_tag: + raise HTTPException(status_code=400, detail='Both old_tag and new_tag are required') + + if old_tag == new_tag: + raise HTTPException(status_code=400, detail='Old and new tag names must be different') + + # Get all posts that contain the old tag + all_posts = db.query(BlogPost).all() + updated_count = 0 + + for post in all_posts: + if post.tags: + try: + tags_list = json.loads(post.tags) + if old_tag in tags_list: + # Replace old tag with new tag + tags_list = [new_tag if tag == old_tag else tag for tag in tags_list] + post.tags = json.dumps(tags_list) + updated_count += 1 + except: + pass + + db.commit() + + return success_response({ + 'old_tag': old_tag, + 'new_tag': new_tag, + 'updated_posts': updated_count + }, message=f'Tag renamed successfully. Updated {updated_count} post(s).') + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"Error in rename_tag: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete('/admin/tags', response_model=dict) +async def delete_tag( + tag: str = Query(..., description='Tag name to delete'), + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Delete a tag from all blog posts (admin only)""" + try: + if not tag: + raise HTTPException(status_code=400, detail='Tag name is required') + + # Get all posts that contain the tag + all_posts = db.query(BlogPost).all() + updated_count = 0 + + for post in all_posts: + if post.tags: + try: + tags_list = json.loads(post.tags) + if tag in tags_list: + # Remove the tag + tags_list = [t for t in tags_list if t != tag] + post.tags = json.dumps(tags_list) if tags_list else None + updated_count += 1 + except: + pass + + db.commit() + + return success_response({ + 'deleted_tag': tag, + 'updated_posts': updated_count + }, message=f'Tag deleted successfully. Updated {updated_count} post(s).') + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"Error in delete_tag: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/Backend/src/rooms/routes/__pycache__/room_routes.cpython-312.pyc b/Backend/src/rooms/routes/__pycache__/room_routes.cpython-312.pyc index 3ae29ecf..9d26b61e 100644 Binary files a/Backend/src/rooms/routes/__pycache__/room_routes.cpython-312.pyc and b/Backend/src/rooms/routes/__pycache__/room_routes.cpython-312.pyc differ diff --git a/Backend/src/rooms/routes/room_routes.py b/Backend/src/rooms/routes/room_routes.py index 0eae485f..4aa90463 100644 --- a/Backend/src/rooms/routes/room_routes.py +++ b/Backend/src/rooms/routes/room_routes.py @@ -10,7 +10,7 @@ from ...security.middleware.auth import get_current_user, authorize_roles from ...auth.models.user import User from ..models.room import Room, RoomStatus from ..models.room_type import RoomType -from ..schemas.room import CreateRoomRequest, UpdateRoomRequest, BulkDeleteRoomsRequest +from ..schemas.room import CreateRoomRequest, UpdateRoomRequest, BulkDeleteRoomsRequest, UpdateAmenityRequest from ...shared.utils.response_helpers import success_response from ...reviews.models.review import Review, ReviewStatus from ...bookings.models.booking import Booking, BookingStatus @@ -72,6 +72,112 @@ async def get_amenities(db: Session=Depends(get_db)): logger.error(f'Error fetching amenities: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) +@router.put('/amenities/{old_name}', dependencies=[Depends(authorize_roles('admin'))]) +async def update_amenity(old_name: str, request: UpdateAmenityRequest, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): + """Update/rename an amenity across all rooms and room types.""" + try: + import json + updated_count = 0 + + # Update in room types + room_types = db.query(RoomType).all() + for rt in room_types: + if rt.amenities: + amenities_list = [] + if isinstance(rt.amenities, list): + amenities_list = rt.amenities + elif isinstance(rt.amenities, str): + try: + amenities_list = json.loads(rt.amenities) + except: + amenities_list = [s.strip() for s in rt.amenities.split(',') if s.strip()] + + if old_name in amenities_list: + amenities_list = [request.new_name if a == old_name else a for a in amenities_list] + rt.amenities = amenities_list + updated_count += 1 + + # Update in rooms + rooms = db.query(Room).all() + for room in rooms: + if room.amenities: + amenities_list = [] + if isinstance(room.amenities, list): + amenities_list = room.amenities + elif isinstance(room.amenities, str): + try: + amenities_list = json.loads(room.amenities) + except: + amenities_list = [s.strip() for s in room.amenities.split(',') if s.strip()] + + if old_name in amenities_list: + amenities_list = [request.new_name if a == old_name else a for a in amenities_list] + room.amenities = amenities_list + updated_count += 1 + + db.commit() + return success_response( + data={'updated_count': updated_count, 'old_name': old_name, 'new_name': request.new_name}, + message=f'Amenity "{old_name}" updated to "{request.new_name}" in {updated_count} location(s)' + ) + except Exception as e: + db.rollback() + logger.error(f'Error updating amenity: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete('/amenities/{amenity_name}', dependencies=[Depends(authorize_roles('admin'))]) +async def delete_amenity(amenity_name: str, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): + """Remove an amenity from all rooms and room types.""" + try: + import json + updated_count = 0 + + # Remove from room types + room_types = db.query(RoomType).all() + for rt in room_types: + if rt.amenities: + amenities_list = [] + if isinstance(rt.amenities, list): + amenities_list = rt.amenities + elif isinstance(rt.amenities, str): + try: + amenities_list = json.loads(rt.amenities) + except: + amenities_list = [s.strip() for s in rt.amenities.split(',') if s.strip()] + + if amenity_name in amenities_list: + amenities_list = [a for a in amenities_list if a != amenity_name] + rt.amenities = amenities_list if amenities_list else [] + updated_count += 1 + + # Remove from rooms + rooms = db.query(Room).all() + for room in rooms: + if room.amenities: + amenities_list = [] + if isinstance(room.amenities, list): + amenities_list = room.amenities + elif isinstance(room.amenities, str): + try: + amenities_list = json.loads(room.amenities) + except: + amenities_list = [s.strip() for s in room.amenities.split(',') if s.strip()] + + if amenity_name in amenities_list: + amenities_list = [a for a in amenities_list if a != amenity_name] + room.amenities = amenities_list if amenities_list else [] + updated_count += 1 + + db.commit() + return success_response( + data={'updated_count': updated_count, 'amenity_name': amenity_name}, + message=f'Amenity "{amenity_name}" removed from {updated_count} location(s)' + ) + except Exception as e: + db.rollback() + logger.error(f'Error deleting amenity: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + @router.get('/room-types') async def get_room_types(db: Session=Depends(get_db)): """Get all room types for dropdowns and forms.""" @@ -457,7 +563,21 @@ async def upload_room_images(id: int, images: List[UploadFile]=File(...), curren continue await f.write(content) image_urls.append(f'/uploads/rooms/{filename}') + + # Handle existing_images - it might be a list, a JSON string, or None existing_images = room.images or [] + if isinstance(existing_images, str): + # If it's a string, try to parse it as JSON + import json + try: + existing_images = json.loads(existing_images) + except (json.JSONDecodeError, TypeError): + # If parsing fails, treat as empty list + existing_images = [] + # Ensure it's a list + if not isinstance(existing_images, list): + existing_images = [] + updated_images = existing_images + image_urls room.images = updated_images db.commit() @@ -483,7 +603,21 @@ async def delete_room_images(id: int, image_url: str=Query(..., description='Ima if not normalized_url.startswith('/'): normalized_url = f'/{normalized_url}' filename = Path(normalized_url).name + + # Handle existing_images - it might be a list, a JSON string, or None existing_images = room.images or [] + if isinstance(existing_images, str): + # If it's a string, try to parse it as JSON + import json + try: + existing_images = json.loads(existing_images) + except (json.JSONDecodeError, TypeError): + # If parsing fails, treat as empty list + existing_images = [] + # Ensure it's a list + if not isinstance(existing_images, list): + existing_images = [] + updated_images = [] for img in existing_images: stored_path = img if img.startswith('/') else f'/{img}' diff --git a/Backend/src/rooms/schemas/__pycache__/room.cpython-312.pyc b/Backend/src/rooms/schemas/__pycache__/room.cpython-312.pyc index b11e373f..c1e78f7c 100644 Binary files a/Backend/src/rooms/schemas/__pycache__/room.cpython-312.pyc and b/Backend/src/rooms/schemas/__pycache__/room.cpython-312.pyc differ diff --git a/Backend/src/rooms/schemas/room.py b/Backend/src/rooms/schemas/room.py index 90990c70..de0c54d1 100644 --- a/Backend/src/rooms/schemas/room.py +++ b/Backend/src/rooms/schemas/room.py @@ -105,3 +105,8 @@ class BulkDeleteRoomsRequest(BaseModel): raise ValueError('All room IDs must be positive integers') return v + +class UpdateAmenityRequest(BaseModel): + """Schema for updating/renaming an amenity.""" + new_name: str = Field(..., min_length=1, max_length=100, description="New name for the amenity") + diff --git a/Backend/src/rooms/services/__pycache__/room_service.cpython-312.pyc b/Backend/src/rooms/services/__pycache__/room_service.cpython-312.pyc index 394229da..325aba5f 100644 Binary files a/Backend/src/rooms/services/__pycache__/room_service.cpython-312.pyc and b/Backend/src/rooms/services/__pycache__/room_service.cpython-312.pyc differ diff --git a/Backend/src/rooms/services/room_service.py b/Backend/src/rooms/services/room_service.py index 640ae016..0d9dd0f8 100644 --- a/Backend/src/rooms/services/room_service.py +++ b/Backend/src/rooms/services/room_service.py @@ -54,11 +54,14 @@ async def get_rooms_with_ratings(db: Session, rooms: List[Room], base_url: str) result.append(room_dict) return result -def get_predefined_amenities() -> List[str]: - return ['Free WiFi', 'WiFi', 'High-Speed Internet', 'WiFi in Room', 'Flat-Screen TV', 'TV', 'Cable TV', 'Satellite TV', 'Smart TV', 'Netflix', 'Streaming Services', 'DVD Player', 'Stereo System', 'Radio', 'iPod Dock', 'Air Conditioning', 'AC', 'Heating', 'Climate Control', 'Ceiling Fan', 'Air Purifier', 'Private Bathroom', 'Ensuite Bathroom', 'Bathtub', 'Jacuzzi Bathtub', 'Hot Tub', 'Shower', 'Rain Shower', 'Walk-in Shower', 'Bidet', 'Hair Dryer', 'Hairdryer', 'Bathrobes', 'Slippers', 'Toiletries', 'Premium Toiletries', 'Towels', 'Mini Bar', 'Minibar', 'Refrigerator', 'Fridge', 'Microwave', 'Coffee Maker', 'Electric Kettle', 'Tea Making Facilities', 'Coffee Machine', 'Nespresso Machine', 'Kitchenette', 'Dining Table', 'Room Service', 'Breakfast Included', 'Breakfast', 'Complimentary Water', 'Bottled Water', 'Desk', 'Writing Desk', 'Office Desk', 'Work Desk', 'Sofa', 'Sitting Area', 'Lounge Area', 'Dining Area', 'Separate Living Area', 'Wardrobe', 'Closet', 'Dresser', 'Mirror', 'Full-Length Mirror', 'Seating Area', 'King Size Bed', 'Queen Size Bed', 'Double Bed', 'Twin Beds', 'Single Bed', 'Extra Bedding', 'Pillow Menu', 'Premium Bedding', 'Blackout Curtains', 'Soundproofing', 'Safe', 'In-Room Safe', 'Safety Deposit Box', 'Smoke Detector', 'Fire Extinguisher', 'Security System', 'Key Card Access', 'Door Lock', 'Pepper Spray', 'USB Charging Ports', 'USB Ports', 'USB Outlets', 'Power Outlets', 'Charging Station', 'Laptop Safe', 'HDMI Port', 'Phone', 'Desk Phone', 'Wake-Up Service', 'Alarm Clock', 'Digital Clock', 'Balcony', 'Private Balcony', 'Terrace', 'Patio', 'City View', 'Ocean View', 'Sea View', 'Mountain View', 'Garden View', 'Pool View', 'Park View', 'Window', 'Large Windows', 'Floor-to-Ceiling Windows', '24-Hour Front Desk', '24 Hour Front Desk', '24/7 Front Desk', 'Concierge Service', 'Butler Service', 'Housekeeping', 'Daily Housekeeping', 'Turndown Service', 'Laundry Service', 'Dry Cleaning', 'Ironing Service', 'Luggage Storage', 'Bell Service', 'Valet Parking', 'Parking', 'Free Parking', 'Airport Shuttle', 'Shuttle Service', 'Car Rental', 'Taxi Service', 'Gym Access', 'Fitness Center', 'Fitness Room', 'Spa Access', 'Spa', 'Sauna', 'Steam Room', 'Hot Tub', 'Massage Service', 'Beauty Services', 'Swimming Pool', 'Pool', 'Indoor Pool', 'Outdoor Pool', 'Infinity Pool', 'Pool Access', 'Golf Course', 'Tennis Court', 'Beach Access', 'Water Sports', 'Business Center', 'Meeting Room', 'Conference Room', 'Fax Service', 'Photocopying', 'Printing Service', 'Secretarial Services', 'Wheelchair Accessible', 'Accessible Room', 'Elevator Access', 'Ramp Access', 'Accessible Bathroom', 'Lowered Sink', 'Grab Bars', 'Hearing Accessible', 'Visual Alarm', 'Family Room', 'Kids Welcome', 'Baby Crib', 'Extra Bed', 'Crib', 'Childcare Services', 'Pets Allowed', 'Pet Friendly', 'Smoking Room', 'Non-Smoking Room', 'No Smoking', 'Interconnecting Rooms', 'Adjoining Rooms', 'Suite', 'Separate Bedroom', 'Kitchen', 'Full Kitchen', 'Dishwasher', 'Oven', 'Stove', 'Washing Machine', 'Dryer', 'Iron', 'Ironing Board', 'Clothes Rack', 'Umbrella', 'Shoe Shine Service', 'Fireplace', 'Jacuzzi', 'Steam Shower', 'Spa Bath', 'Bidet Toilet', 'Smart Home System', 'Lighting Control', 'Curtain Control', 'Automated Systems', 'Personalized Service', 'VIP Treatment', 'Butler', 'Private Entrance', 'Private Elevator', 'Panic Button', 'Blu-ray Player', 'Gaming Console', 'PlayStation', 'Xbox', 'Sound System', 'Surround Sound', 'Music System', 'Library', 'Reading Room', 'Study Room', 'Private Pool', 'Private Garden', 'Yard', 'Courtyard', 'Outdoor Furniture', 'BBQ Facilities', 'Picnic Area'] - async def get_amenities_list(db: Session) -> List[str]: - all_amenities = set(get_predefined_amenities()) + """ + Get all unique amenities from the database only. + Aggregates amenities from room_types and rooms tables. + """ + all_amenities = set() + + # Get amenities from room types room_types = db.query(RoomType.amenities).all() for rt in room_types: if rt.amenities: @@ -74,6 +77,8 @@ async def get_amenities_list(db: Session) -> List[str]: all_amenities.update([s.strip() for s in rt.amenities.split(',') if s.strip()]) except: all_amenities.update([s.strip() for s in rt.amenities.split(',') if s.strip()]) + + # Get amenities from rooms rooms = db.query(Room.amenities).all() for r in rooms: if r.amenities: @@ -89,4 +94,5 @@ async def get_amenities_list(db: Session) -> List[str]: all_amenities.update([s.strip() for s in r.amenities.split(',') if s.strip()]) except: all_amenities.update([s.strip() for s in r.amenities.split(',') if s.strip()]) + return sorted(list(all_amenities)) \ No newline at end of file diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index b2759a29..251e9381 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -93,6 +93,7 @@ const NotificationManagementPage = lazy(() => import('./pages/admin/Notification const ReceptionDashboardPage = lazy(() => import('./pages/admin/ReceptionDashboardPage')); const LoyaltyManagementPage = lazy(() => import('./pages/admin/LoyaltyManagementPage')); const AdvancedRoomManagementPage = lazy(() => import('./pages/admin/AdvancedRoomManagementPage')); +const EditRoomPage = lazy(() => import('./pages/admin/EditRoomPage')); const RatePlanManagementPage = lazy(() => import('./pages/admin/RatePlanManagementPage')); const PackageManagementPage = lazy(() => import('./pages/admin/PackageManagementPage')); const SecurityManagementPage = lazy(() => import('./pages/admin/SecurityManagementPage')); @@ -543,6 +544,10 @@ function App() { path="advanced-rooms" element={} /> + } + /> } diff --git a/Frontend/src/features/content/services/blogService.ts b/Frontend/src/features/content/services/blogService.ts index d6d7df85..5ee8c469 100644 --- a/Frontend/src/features/content/services/blogService.ts +++ b/Frontend/src/features/content/services/blogService.ts @@ -143,6 +143,26 @@ class BlogService { }); return response.data; } + + // Tag management endpoints + async getAllTags(): Promise<{ status: string; data: { tags: Array<{ name: string; usage_count: number }>; total: number } }> { + const response = await apiClient.get('/blog/admin/tags'); + return response.data; + } + + async renameTag(oldTag: string, newTag: string): Promise<{ status: string; data: { old_tag: string; new_tag: string; updated_posts: number }; message?: string }> { + const response = await apiClient.put('/blog/admin/tags/rename', null, { + params: { old_tag: oldTag, new_tag: newTag }, + }); + return response.data; + } + + async deleteTag(tag: string): Promise<{ status: string; data: { deleted_tag: string; updated_posts: number }; message?: string }> { + const response = await apiClient.delete('/blog/admin/tags', { + params: { tag }, + }); + return response.data; + } } export const blogService = new BlogService(); diff --git a/Frontend/src/features/rooms/services/roomService.ts b/Frontend/src/features/rooms/services/roomService.ts index ded95049..7efae9a9 100644 --- a/Frontend/src/features/rooms/services/roomService.ts +++ b/Frontend/src/features/rooms/services/roomService.ts @@ -169,6 +169,33 @@ export const getAmenities = async (): Promise<{ }; }; +export const updateAmenity = async ( + oldName: string, + newName: string +): Promise<{ success: boolean; message: string; data: { updated_count: number; old_name: string; new_name: string } }> => { + const response = await apiClient.put(`/rooms/amenities/${encodeURIComponent(oldName)}`, { + new_name: newName, + }); + const responseData = response.data; + return { + success: responseData.status === 'success' || responseData.success === true, + message: responseData.message || '', + data: responseData.data || { updated_count: 0, old_name: oldName, new_name: newName }, + }; +}; + +export const deleteAmenity = async ( + amenityName: string +): Promise<{ success: boolean; message: string; data: { updated_count: number; amenity_name: string } }> => { + const response = await apiClient.delete(`/rooms/amenities/${encodeURIComponent(amenityName)}`); + const data = response.data; + return { + success: data.status === 'success' || data.success === true, + message: data.message || '', + data: data.data || { updated_count: 0, amenity_name: amenityName }, + }; +}; + export interface RoomType { id: number; name: string; @@ -264,6 +291,8 @@ export default { getRoomByNumber, searchAvailableRooms, getAmenities, + updateAmenity, + deleteAmenity, getRoomTypes, createRoom, updateRoom, diff --git a/Frontend/src/pages/admin/AdvancedRoomManagementPage.tsx b/Frontend/src/pages/admin/AdvancedRoomManagementPage.tsx index ce6d4a2c..128ffc6f 100644 --- a/Frontend/src/pages/admin/AdvancedRoomManagementPage.tsx +++ b/Frontend/src/pages/admin/AdvancedRoomManagementPage.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Hotel, Wrench, @@ -15,6 +16,7 @@ import { MapPin, Plus, Edit, + Trash2, X, Image as ImageIcon, Check, @@ -35,6 +37,7 @@ import { useRoomContext } from '../../features/rooms/contexts/RoomContext'; type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections'; const AdvancedRoomManagementPage: React.FC = () => { + const navigate = useNavigate(); const { statusBoardRooms, statusBoardLoading, @@ -73,6 +76,8 @@ const AdvancedRoomManagementPage: React.FC = () => { const [roomTypes, setRoomTypes] = useState>([]); const [uploadingImages, setUploadingImages] = useState(false); const [selectedFiles, setSelectedFiles] = useState([]); + const [customAmenityInput, setCustomAmenityInput] = useState(''); + const [editingAmenity, setEditingAmenity] = useState<{ name: string; newName: string } | null>(null); // Define fetchFloors before using it in useEffect const fetchFloors = useCallback(async () => { @@ -451,87 +456,31 @@ const AdvancedRoomManagementPage: React.FC = () => { } }; - const handleEditRoom = async (room: Room) => { - setEditingRoom(room); - - let amenitiesArray: string[] = []; - if (room.amenities) { - if (Array.isArray(room.amenities)) { - amenitiesArray = room.amenities; - } else if (typeof room.amenities === 'string') { - try { - const parsed = JSON.parse(room.amenities); - amenitiesArray = Array.isArray(parsed) ? parsed : []; - } catch { - const amenitiesStr: string = room.amenities; - amenitiesArray = amenitiesStr.split(',').map((a: string) => a.trim()).filter(Boolean); - } - } - } - - setRoomFormData({ - room_number: room.room_number, - floor: room.floor, - room_type_id: room.room_type_id, - status: room.status, - featured: room.featured, - price: room.price?.toString() || '', - description: room.description || '', - capacity: room.capacity?.toString() || '', - room_size: room.room_size || '', - view: room.view || '', - amenities: amenitiesArray, - }); - - setShowRoomModal(true); - - try { - const fullRoom = await roomService.getRoomByNumber(room.room_number); - const roomData = fullRoom.data.room; - - let updatedAmenitiesArray: string[] = []; - if (roomData.amenities) { - if (Array.isArray(roomData.amenities)) { - updatedAmenitiesArray = roomData.amenities; - } else if (typeof roomData.amenities === 'string') { - try { - const parsed = JSON.parse(roomData.amenities); - updatedAmenitiesArray = Array.isArray(parsed) ? parsed : []; - } catch { - const amenitiesStr: string = roomData.amenities; - updatedAmenitiesArray = amenitiesStr.split(',').map((a: string) => a.trim()).filter(Boolean); - } - } - } - - setRoomFormData({ - room_number: roomData.room_number, - floor: roomData.floor, - room_type_id: roomData.room_type_id, - status: roomData.status, - featured: roomData.featured, - price: roomData.price?.toString() || '', - description: roomData.description || '', - capacity: roomData.capacity?.toString() || '', - room_size: roomData.room_size || '', - view: roomData.view || '', - amenities: updatedAmenitiesArray, - }); - - setEditingRoom(roomData); - } catch (error) { - logger.error('Failed to fetch full room details', error); - toast.error('Failed to load complete room details'); - } + const handleEditRoom = (room: Room) => { + navigate(`/admin/rooms/${room.id}/edit`); }; const handleDeleteRoom = async (id: number) => { - if (!window.confirm('Are you sure you want to delete this room?')) return; + const room = contextRooms.find(r => r.id === id) || statusBoardRooms.find(r => r.id === id); + const roomNumber = room?.room_number || 'this room'; + + if (!window.confirm(`Are you sure you want to delete room ${roomNumber}? This action cannot be undone.`)) { + return; + } try { await contextDeleteRoom(id); + toast.success(`Room ${roomNumber} deleted successfully`); + await refreshStatusBoard(); + await refreshRooms(); + // Remove from expanded rooms if it was expanded + setExpandedRooms(prev => { + const newSet = new Set(prev); + newSet.delete(id); + return newSet; + }); } catch (error: any) { - // Error already handled in context + toast.error(error.response?.data?.message || error.response?.data?.detail || 'Failed to delete room'); } }; @@ -552,6 +501,8 @@ const AdvancedRoomManagementPage: React.FC = () => { }); setSelectedFiles([]); setUploadingImages(false); + setCustomAmenityInput(''); + setEditingAmenity(null); }; const toggleAmenity = (amenity: string) => { @@ -563,6 +514,86 @@ const AdvancedRoomManagementPage: React.FC = () => { })); }; + const handleAddCustomAmenity = () => { + const trimmed = customAmenityInput.trim(); + if (trimmed && !roomFormData.amenities.includes(trimmed)) { + setRoomFormData(prev => ({ + ...prev, + amenities: [...prev.amenities, trimmed] + })); + setCustomAmenityInput(''); + } + }; + + const handleRemoveAmenity = (amenity: string) => { + setRoomFormData(prev => ({ + ...prev, + amenities: prev.amenities.filter(a => a !== amenity) + })); + }; + + const handleEditAmenity = (amenity: string) => { + setEditingAmenity({ name: amenity, newName: amenity }); + }; + + const handleSaveAmenityEdit = async () => { + if (!editingAmenity || editingAmenity.name === editingAmenity.newName.trim()) { + setEditingAmenity(null); + return; + } + + const newName = editingAmenity.newName.trim(); + if (!newName) { + toast.error('Amenity name cannot be empty'); + return; + } + + try { + await roomService.updateAmenity(editingAmenity.name, newName); + toast.success(`Amenity "${editingAmenity.name}" updated to "${newName}"`); + + setAvailableAmenities(prev => { + const updated = prev.map(a => a === editingAmenity.name ? newName : a); + return updated.sort(); + }); + + setRoomFormData(prev => ({ + ...prev, + amenities: prev.amenities.map(a => a === editingAmenity.name ? newName : a) + })); + + setEditingAmenity(null); + await fetchAvailableAmenities(); + await refreshRooms(); + await refreshStatusBoard(); + } catch (error: any) { + toast.error(error.response?.data?.message || error.response?.data?.detail || 'Failed to update amenity'); + } + }; + + const handleDeleteAmenity = async (amenity: string) => { + if (!window.confirm(`Are you sure you want to delete "${amenity}"? This will remove it from all rooms and room types.`)) { + return; + } + + try { + await roomService.deleteAmenity(amenity); + toast.success(`Amenity "${amenity}" deleted successfully`); + + setAvailableAmenities(prev => prev.filter(a => a !== amenity)); + setRoomFormData(prev => ({ + ...prev, + amenities: prev.amenities.filter(a => a !== amenity) + })); + + await fetchAvailableAmenities(); + await refreshRooms(); + await refreshStatusBoard(); + } catch (error: any) { + toast.error(error.response?.data?.message || error.response?.data?.detail || 'Failed to delete amenity'); + } + }; + const handleFileSelect = (e: React.ChangeEvent) => { if (e.target.files) { const files = Array.from(e.target.files); @@ -806,17 +837,29 @@ const AdvancedRoomManagementPage: React.FC = () => { {getStatusLabel(effectiveStatus)} - {/* Edit Button */} - + {/* Action Buttons */} +
+ + +
{/* Room Content */}
toggleRoomExpansion(room.id)}> diff --git a/Frontend/src/pages/admin/AuditLogsPage.tsx b/Frontend/src/pages/admin/AuditLogsPage.tsx index 17848e8b..ac861881 100644 --- a/Frontend/src/pages/admin/AuditLogsPage.tsx +++ b/Frontend/src/pages/admin/AuditLogsPage.tsx @@ -353,8 +353,9 @@ const AuditLogsPage: React.FC = () => { {} {showDetails && selectedLog && ( -
-
+
+
+

Audit Log Details

@@ -460,6 +461,7 @@ const AuditLogsPage: React.FC = () => {
+
)}
diff --git a/Frontend/src/pages/admin/BannerManagementPage.tsx b/Frontend/src/pages/admin/BannerManagementPage.tsx index 6c0b8c8c..23b47275 100644 --- a/Frontend/src/pages/admin/BannerManagementPage.tsx +++ b/Frontend/src/pages/admin/BannerManagementPage.tsx @@ -417,8 +417,9 @@ const BannerManagementPage: React.FC = () => { {} {showModal && ( -
-
+
+
+
@@ -440,7 +441,7 @@ const BannerManagementPage: React.FC = () => {
-
+
+
+
)} diff --git a/Frontend/src/pages/admin/BlogManagementPage.tsx b/Frontend/src/pages/admin/BlogManagementPage.tsx index ddc927c5..dc88ad14 100644 --- a/Frontend/src/pages/admin/BlogManagementPage.tsx +++ b/Frontend/src/pages/admin/BlogManagementPage.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useRef } from 'react'; -import { Plus, Search, Edit, Trash2, X, Eye, EyeOff, Loader2, Calendar, User, Tag, GripVertical, Image as ImageIcon, Type, Quote, List, Video, ArrowRight, MoveUp, MoveDown, Sparkles, Upload } from 'lucide-react'; +import { Plus, Search, Edit, Trash2, X, Eye, EyeOff, Loader2, Tag, GripVertical, Image as ImageIcon, Type, Quote, List, Video, ArrowRight, MoveUp, MoveDown, Sparkles, Upload } from 'lucide-react'; import { toast } from 'react-toastify'; import Loading from '../../shared/components/Loading'; import Pagination from '../../shared/components/Pagination'; @@ -42,6 +42,16 @@ const BlogManagementPage: React.FC = () => { const [saving, setSaving] = useState(false); const [showSectionBuilder, setShowSectionBuilder] = useState(false); const [uploadingImages, setUploadingImages] = useState<{ [key: string]: boolean }>({}); + + // Tag management state + const [showTagController, setShowTagController] = useState(false); + const [tags, setTags] = useState>([]); + const [tagsLoading, setTagsLoading] = useState(false); + const [editingTag, setEditingTag] = useState<{ old: string; new: string } | null>(null); + const [deleteTagConfirm, setDeleteTagConfirm] = useState<{ show: boolean; tag: string | null }>({ + show: false, + tag: null, + }); useEffect(() => { setCurrentPage(1); @@ -51,6 +61,12 @@ const BlogManagementPage: React.FC = () => { fetchPosts(); }, [filters, currentPage]); + useEffect(() => { + if (showTagController) { + fetchTags(); + } + }, [showTagController]); + const fetchPosts = async () => { try { setLoading(true); @@ -275,6 +291,50 @@ const BlogManagementPage: React.FC = () => { } }; + // Tag management functions + const fetchTags = async () => { + try { + setTagsLoading(true); + const response = await blogService.getAllTags(); + if (response.status === 'success' && response.data) { + setTags(response.data.tags); + } + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to load tags'); + } finally { + setTagsLoading(false); + } + }; + + const handleRenameTag = async () => { + if (!editingTag || !editingTag.new.trim() || editingTag.old === editingTag.new.trim()) { + setEditingTag(null); + return; + } + try { + const response = await blogService.renameTag(editingTag.old, editingTag.new.trim()); + toast.success(response.message || 'Tag renamed successfully'); + setEditingTag(null); + fetchTags(); + fetchPosts(); // Refresh posts to show updated tags + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to rename tag'); + } + }; + + const handleDeleteTag = async () => { + if (!deleteTagConfirm.tag) return; + try { + const response = await blogService.deleteTag(deleteTagConfirm.tag); + toast.success(response.message || 'Tag deleted successfully'); + setDeleteTagConfirm({ show: false, tag: null }); + fetchTags(); + fetchPosts(); // Refresh posts to show updated tags + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to delete tag'); + } + }; + const formatDate = (dateString?: string) => { if (!dateString) return 'Not published'; return new Date(dateString).toLocaleDateString('en-US', { @@ -398,13 +458,22 @@ const BlogManagementPage: React.FC = () => {

Blog Management

Manage your blog posts and content

- +
+ + +
@@ -530,8 +599,9 @@ const BlogManagementPage: React.FC = () => { {/* Create/Edit Modal */} {showModal && ( -
-
+
+
+
@@ -551,7 +621,7 @@ const BlogManagementPage: React.FC = () => {
-
+
@@ -1177,6 +1247,7 @@ const BlogManagementPage: React.FC = () => {
+
)} {/* Delete Confirmation */} @@ -1187,6 +1258,131 @@ const BlogManagementPage: React.FC = () => { title="Delete Blog Post" message="Are you sure you want to delete this blog post? This action cannot be undone." /> + + {/* Tag Controller Modal */} + {showTagController && ( +
+
+
+
+
+
+

+ Tag Controller +

+

+ Manage all blog tags - rename or delete tags across all posts +

+
+ +
+
+ +
+
+ {tagsLoading ? ( +
+ +
+ ) : tags.length === 0 ? ( +
+ +

No tags found

+

Tags will appear here when you add them to blog posts

+
+ ) : ( +
+ {tags.map((tag) => ( +
+ {editingTag && editingTag.old === tag.name ? ( +
+ setEditingTag({ ...editingTag, new: e.target.value })} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleRenameTag(); + } else if (e.key === 'Escape') { + setEditingTag(null); + } + }} + autoFocus + className="flex-1 px-4 py-2 border-2 border-blue-400 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="Enter new tag name" + /> + + +
+ ) : ( +
+
+
+ + {tag.name} +
+ + Used in {tag.usage_count} {tag.usage_count === 1 ? 'post' : 'posts'} + +
+
+ + +
+
+ )} +
+ ))} +
+ )} +
+
+
+
+
+ )} + + {/* Delete Tag Confirmation */} + setDeleteTagConfirm({ show: false, tag: null })} + onConfirm={handleDeleteTag} + title="Delete Tag" + message={`Are you sure you want to delete the tag "${deleteTagConfirm.tag}"? This will remove it from all blog posts. This action cannot be undone.`} + />
); diff --git a/Frontend/src/pages/admin/BookingManagementPage.tsx b/Frontend/src/pages/admin/BookingManagementPage.tsx index deb20c00..fbd6b4fe 100644 --- a/Frontend/src/pages/admin/BookingManagementPage.tsx +++ b/Frontend/src/pages/admin/BookingManagementPage.tsx @@ -115,7 +115,7 @@ const BookingManagementPage: React.FC = () => { const response = await invoiceService.getInvoicesByBooking(booking.id); // Check response structure - handle both possible formats - const invoices = response.data?.invoices || response.data?.data?.invoices || []; + const invoices = response.data?.invoices || (response.data as any)?.data?.invoices || []; const hasInvoice = Array.isArray(invoices) && invoices.length > 0; return { @@ -197,7 +197,7 @@ const BookingManagementPage: React.FC = () => { let invoice = null; if (response.status === 'success' && response.data) { // Try different possible response structures - invoice = response.data.invoice || response.data.data?.invoice || response.data; + invoice = response.data.invoice || (response.data as any).data?.invoice || response.data; logger.debug('Extracted invoice', { invoice }); } @@ -637,8 +637,9 @@ const BookingManagementPage: React.FC = () => { {} {showDetailModal && selectedBooking && ( -
-
+
+
+
{}
@@ -655,7 +656,7 @@ const BookingManagementPage: React.FC = () => {
-
+
{}
@@ -985,6 +986,7 @@ const BookingManagementPage: React.FC = () => {
+
)} diff --git a/Frontend/src/pages/admin/BusinessDashboardPage.tsx b/Frontend/src/pages/admin/BusinessDashboardPage.tsx index bd2598dd..7e1e2414 100644 --- a/Frontend/src/pages/admin/BusinessDashboardPage.tsx +++ b/Frontend/src/pages/admin/BusinessDashboardPage.tsx @@ -1044,8 +1044,9 @@ const BusinessDashboardPage: React.FC = () => { {} {showPromotionModal && ( -
-
+
+
+
{}
@@ -1067,7 +1068,7 @@ const BusinessDashboardPage: React.FC = () => {
{} -
+
@@ -1241,7 +1242,8 @@ const BusinessDashboardPage: React.FC = () => {
-
+
+
)}
)} diff --git a/Frontend/src/pages/admin/EditRoomPage.tsx b/Frontend/src/pages/admin/EditRoomPage.tsx new file mode 100644 index 00000000..ff1060bb --- /dev/null +++ b/Frontend/src/pages/admin/EditRoomPage.tsx @@ -0,0 +1,1044 @@ +import React, { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { ArrowLeft, Upload, Image as ImageIcon, Check, X, Plus, Edit as EditIcon, Trash2, Sparkles, Crown } from 'lucide-react'; +import roomService, { Room } from '../../features/rooms/services/roomService'; +import { toast } from 'react-toastify'; +import Loading from '../../shared/components/Loading'; +import apiClient from '../../shared/services/apiClient'; +import { logger } from '../../shared/utils/logger'; +import { useRoomContext } from '../../features/rooms/contexts/RoomContext'; + +const EditRoomPage: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { updateRoom: contextUpdateRoom, refreshRooms } = useRoomContext(); + + const [loading, setLoading] = useState(true); + const [editingRoom, setEditingRoom] = useState(null); + const [uploadingImages, setUploadingImages] = useState(false); + const [selectedFiles, setSelectedFiles] = useState([]); + const [customAmenityInput, setCustomAmenityInput] = useState(''); + const [editingAmenity, setEditingAmenity] = useState<{ name: string; newName: string } | null>(null); + const [availableAmenities, setAvailableAmenities] = useState([]); + const [roomTypes, setRoomTypes] = useState>([]); + const [deletingImageUrl, setDeletingImageUrl] = useState(null); + const [failedImageUrls, setFailedImageUrls] = useState>(new Set()); + + const [formData, setFormData] = useState({ + room_number: '', + floor: 1, + room_type_id: 1, + status: 'available' as 'available' | 'occupied' | 'maintenance', + featured: false, + price: '', + description: '', + capacity: '', + room_size: '', + view: '', + amenities: [] as string[], + }); + + useEffect(() => { + if (id) { + fetchRoomData(); + } + fetchAvailableAmenities(); + fetchRoomTypes(); + }, [id]); + + const fetchRoomTypes = async () => { + try { + const response = await roomService.getRooms({ limit: 100, page: 1 }); + const allUniqueRoomTypes = new Map(); + response.data.rooms.forEach((room: Room) => { + if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) { + allUniqueRoomTypes.set(room.room_type.id, { + id: room.room_type.id, + name: room.room_type.name, + }); + } + }); + + if (response.data.pagination && response.data.pagination.totalPages > 1) { + const totalPages = response.data.pagination.totalPages; + for (let page = 2; page <= totalPages; page++) { + try { + const pageResponse = await roomService.getRooms({ limit: 100, page }); + pageResponse.data.rooms.forEach((room: Room) => { + if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) { + allUniqueRoomTypes.set(room.room_type.id, { + id: room.room_type.id, + name: room.room_type.name, + }); + } + }); + } catch (err) { + logger.error(`Failed to fetch page ${page}`, err); + } + } + } + + if (allUniqueRoomTypes.size > 0) { + setRoomTypes(Array.from(allUniqueRoomTypes.values())); + } + } catch (err) { + logger.error('Failed to fetch room types', err); + } + }; + + const fetchAvailableAmenities = async () => { + try { + const response = await roomService.getAmenities(); + if (response.data?.amenities) { + setAvailableAmenities(response.data.amenities); + } + } catch (error) { + logger.error('Failed to fetch amenities', error); + } + }; + + const fetchRoomData = async () => { + if (!id) return; + + try { + setLoading(true); + const roomId = parseInt(id); + const response = await roomService.getRoomById(roomId); + const room = response.data.room; + + setEditingRoom(room); + + let amenitiesArray: string[] = []; + const roomAmenities = room.amenities as string[] | string | undefined; + if (roomAmenities) { + if (Array.isArray(roomAmenities)) { + amenitiesArray = roomAmenities; + } else if (typeof roomAmenities === 'string') { + try { + const parsed = JSON.parse(roomAmenities); + amenitiesArray = Array.isArray(parsed) ? parsed : []; + } catch { + amenitiesArray = roomAmenities.split(',').map((a: string) => a.trim()).filter(Boolean); + } + } + } + + setFormData({ + room_number: room.room_number, + floor: room.floor, + room_type_id: room.room_type_id, + status: room.status, + featured: room.featured, + price: room.price?.toString() || '', + description: room.description || '', + capacity: room.capacity?.toString() || '', + room_size: room.room_size || '', + view: room.view || '', + amenities: amenitiesArray, + }); + } catch (error: any) { + logger.error('Failed to fetch room data', error); + toast.error(error.response?.data?.message || 'Failed to load room data'); + navigate('/admin/advanced-rooms'); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editingRoom) return; + + try { + const updateData = { + ...formData, + price: formData.price ? parseFloat(formData.price) : undefined, + description: formData.description || undefined, + capacity: formData.capacity ? parseInt(formData.capacity) : undefined, + room_size: formData.room_size || undefined, + view: formData.view || undefined, + amenities: Array.isArray(formData.amenities) ? formData.amenities : [], + }; + + await contextUpdateRoom(editingRoom.id, updateData); + toast.success('Room updated successfully'); + await refreshRooms(); + navigate('/admin/advanced-rooms'); + } catch (error: any) { + toast.error(error.response?.data?.message || 'An error occurred'); + } + }; + + const toggleAmenity = (amenity: string) => { + setFormData(prev => ({ + ...prev, + amenities: prev.amenities.includes(amenity) + ? prev.amenities.filter(a => a !== amenity) + : [...prev.amenities, amenity] + })); + }; + + const handleAddCustomAmenity = () => { + const trimmed = customAmenityInput.trim(); + if (trimmed && !formData.amenities.includes(trimmed)) { + setFormData(prev => ({ + ...prev, + amenities: [...prev.amenities, trimmed] + })); + setCustomAmenityInput(''); + } + }; + + const handleRemoveAmenity = (amenity: string) => { + setFormData(prev => ({ + ...prev, + amenities: prev.amenities.filter(a => a !== amenity) + })); + }; + + const handleEditAmenity = (amenity: string) => { + setEditingAmenity({ name: amenity, newName: amenity }); + }; + + const handleSaveAmenityEdit = async () => { + if (!editingAmenity || editingAmenity.name === editingAmenity.newName.trim()) { + setEditingAmenity(null); + return; + } + + const newName = editingAmenity.newName.trim(); + if (!newName) { + toast.error('Amenity name cannot be empty'); + return; + } + + try { + await roomService.updateAmenity(editingAmenity.name, newName); + toast.success(`Amenity "${editingAmenity.name}" updated to "${newName}"`); + + setAvailableAmenities(prev => { + const updated = prev.map(a => a === editingAmenity.name ? newName : a); + return updated.sort(); + }); + + setFormData(prev => ({ + ...prev, + amenities: prev.amenities.map(a => a === editingAmenity.name ? newName : a) + })); + + setEditingAmenity(null); + await fetchAvailableAmenities(); + await refreshRooms(); + } catch (error: any) { + toast.error(error.response?.data?.message || error.response?.data?.detail || 'Failed to update amenity'); + } + }; + + const handleDeleteAmenity = async (amenity: string) => { + if (!window.confirm(`Are you sure you want to delete "${amenity}"? This will remove it from all rooms and room types.`)) { + return; + } + + try { + await roomService.deleteAmenity(amenity); + toast.success(`Amenity "${amenity}" deleted successfully`); + + setAvailableAmenities(prev => prev.filter(a => a !== amenity)); + setFormData(prev => ({ + ...prev, + amenities: prev.amenities.filter(a => a !== amenity) + })); + + await fetchAvailableAmenities(); + await refreshRooms(); + } catch (error: any) { + toast.error(error.response?.data?.message || error.response?.data?.detail || 'Failed to delete amenity'); + } + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + if (e.target.files) { + const files = Array.from(e.target.files); + setSelectedFiles(files); + } + }; + + const handleUploadImages = async () => { + if (!editingRoom || selectedFiles.length === 0) return; + + try { + setUploadingImages(true); + const formData = new FormData(); + selectedFiles.forEach(file => { + formData.append('images', file); + }); + + await apiClient.post(`/rooms/${editingRoom.id}/images`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + toast.success('Images uploaded successfully'); + setSelectedFiles([]); + await refreshRooms(); + await fetchRoomData(); + } catch (error: any) { + toast.error(error.response?.data?.message || 'Unable to upload images'); + } finally { + setUploadingImages(false); + } + }; + + const handleDeleteImage = async (imageUrl: string) => { + if (!editingRoom) return; + if (!window.confirm('Are you sure you want to delete this image?')) return; + + try { + setDeletingImageUrl(imageUrl); + // Immediately mark as failed to prevent error handler from firing + setFailedImageUrls(prev => new Set([...prev, imageUrl])); + + let imagePath = imageUrl; + if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) { + try { + const url = new URL(imageUrl); + imagePath = url.pathname; + } catch (e) { + const match = imageUrl.match(/(\/uploads\/.*)/); + imagePath = match ? match[1] : imageUrl; + } + } + + await apiClient.delete(`/rooms/${editingRoom.id}/images`, { + params: { image_url: imagePath }, + }); + + toast.success('Image deleted successfully'); + await refreshRooms(); + await fetchRoomData(); + } catch (error: any) { + logger.error('Error deleting image', error); + toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to delete image'); + // Remove from failed list if deletion failed so user can try again + setFailedImageUrls(prev => { + const newSet = new Set(prev); + newSet.delete(imageUrl); + return newSet; + }); + } finally { + setDeletingImageUrl(null); + } + }; + + if (loading) { + return ; + } + + if (!editingRoom) { + return ( +
+
+
+ +
+

Room Not Found

+

The room you're looking for doesn't exist or has been removed.

+ +
+
+ ); + } + + const apiBaseUrl = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'; + + const normalizeImageUrl = (img: string): string => { + if (!img) return ''; + + // If it's already a full URL, return as is + if (img.startsWith('http://') || img.startsWith('https://')) { + // Only reject if it contains obviously malformed characters that could cause parsing issues + if (img.includes('[') || img.includes(']') || img.includes('"')) { + return ''; + } + return img; + } + + // If it's a data URI, return as is (but validate to prevent parsing issues) + if (img.startsWith('data:')) { + // Basic validation - if it looks like a complete data URI, allow it + if (img.includes(';base64,') && img.length > 50) { + return img; + } + // Reject malformed data URIs + return ''; + } + + // Otherwise, construct the full URL + const cleanPath = img.startsWith('/') ? img : `/${img}`; + return `${apiBaseUrl}${cleanPath}`; + }; + + const normalizeForComparison = (img: string): string => { + if (!img) return ''; + if (img.startsWith('http://') || img.startsWith('https://')) { + try { + const url = new URL(img); + return url.pathname; + } catch { + const match = img.match(/(\/uploads\/.*)/); + return match ? match[1] : img; + } + } + return img.startsWith('/') ? img : `/${img}`; + }; + + const roomImages = editingRoom.images || []; + const roomTypeImages = editingRoom.room_type?.images || []; + const normalizedRoomImages = roomImages.map((ri: any) => normalizeForComparison(String(ri || ''))); + const allImages = [ + ...roomImages.filter((img: any) => img != null && String(img).trim() !== ''), + ...roomTypeImages.filter((img: any) => { + if (!img || String(img).trim() === '') return false; + const normalized = normalizeForComparison(String(img)); + return !normalizedRoomImages.includes(normalized); + }) + ]; + + // Debug logging (can be removed later) + if (allImages.length > 0) { + console.log('EditRoomPage - Images found:', { + roomImages: roomImages.length, + roomTypeImages: roomTypeImages.length, + allImages: allImages.length, + sampleImage: allImages[0], + normalizedUrl: allImages[0] ? normalizeImageUrl(String(allImages[0])) : 'none' + }); + } + + return ( +
+ {/* Luxury Header with Glassmorphism */} +
+
+ {/* Decorative Background Elements */} +
+
+ +
+ + +
+
+
+

+ Edit Room +

+ {editingRoom.featured && ( +
+ + Featured +
+ )} +
+
+

+ {editingRoom.room_number} +

+

+ {editingRoom.room_type?.name || 'Room Type'} +

+ +

Floor {editingRoom.floor}

+
+
+
+
+
+ + {/* Main Form Container */} +
+ {/* Decorative Elements */} +
+
+ +
+ {/* Basic Information Section */} +
+
+
+
+

+ Basic Information +

+

Essential room details and specifications

+
+
+ +
+
+ + setFormData({ ...formData, room_number: e.target.value })} + className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg placeholder:text-slate-400" + placeholder="e.g., 1001" + required + /> +
+ +
+ + setFormData({ ...formData, floor: parseInt(e.target.value) })} + className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg" + required + min="1" + /> +
+
+ +
+
+ + +
+ +
+ + +
+
+ + {/* Featured Checkbox - Luxury Style */} +
+
+ setFormData({ ...formData, featured: e.target.checked })} + className="w-6 h-6 sm:w-7 sm:h-7 text-amber-600 bg-white border-2 border-amber-300 rounded-xl focus:ring-4 focus:ring-amber-200 cursor-pointer transition-all duration-300 checked:bg-gradient-to-br checked:from-amber-400 checked:to-amber-600 checked:border-amber-500 shadow-md hover:shadow-lg" + /> + {formData.featured && ( + + )} +
+ +
+ +
+
+ + setFormData({ ...formData, price: e.target.value })} + className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg placeholder:text-slate-400" + placeholder="e.g., 150.00" + /> +

+ Leave empty to use room type base price +

+
+ +
+ + setFormData({ ...formData, capacity: e.target.value })} + className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg placeholder:text-slate-400" + placeholder="e.g., 4" + /> +

+ Room-specific capacity +

+
+
+ +
+
+ + setFormData({ ...formData, room_size: e.target.value })} + className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg placeholder:text-slate-400" + placeholder="e.g., 1 Room, 50 sqm" + /> +
+ +
+ + setFormData({ ...formData, view: e.target.value })} + className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg placeholder:text-slate-400" + placeholder="e.g., City View, Ocean View" + /> +
+
+ +
+ +