From 2d770dd27b20e8dd47ea4c54bd34d6ea3f22fb6d Mon Sep 17 00:00:00 2001 From: Iliyan Angelov Date: Tue, 2 Dec 2025 10:42:35 +0200 Subject: [PATCH] updates --- .../__pycache__/blog_routes.cpython-312.pyc | Bin 28788 -> 33872 bytes Backend/src/content/routes/blog_routes.py | 119 ++ .../__pycache__/room_routes.cpython-312.pyc | Bin 46505 -> 53176 bytes Backend/src/rooms/routes/room_routes.py | 136 +- .../schemas/__pycache__/room.cpython-312.pyc | Bin 5857 -> 6208 bytes Backend/src/rooms/schemas/room.py | 5 + .../__pycache__/room_service.cpython-312.pyc | Bin 12987 -> 9783 bytes Backend/src/rooms/services/room_service.py | 14 +- Frontend/src/App.tsx | 5 + .../features/content/services/blogService.ts | 20 + .../features/rooms/services/roomService.ts | 29 + .../admin/AdvancedRoomManagementPage.tsx | 213 +-- Frontend/src/pages/admin/AuditLogsPage.tsx | 6 +- .../src/pages/admin/BannerManagementPage.tsx | 9 +- .../src/pages/admin/BlogManagementPage.tsx | 218 ++- .../src/pages/admin/BookingManagementPage.tsx | 12 +- .../src/pages/admin/BusinessDashboardPage.tsx | 10 +- Frontend/src/pages/admin/EditRoomPage.tsx | 1044 ++++++++++++++ .../src/pages/admin/LoyaltyManagementPage.tsx | 16 +- .../pages/admin/PromotionManagementPage.tsx | 9 +- .../src/pages/admin/RoomManagementPage.tsx | 1262 ----------------- .../pages/admin/SecurityManagementPage.tsx | 27 +- .../src/pages/admin/ServiceManagementPage.tsx | 6 +- .../src/pages/admin/UserManagementPage.tsx | 8 +- 24 files changed, 1766 insertions(+), 1402 deletions(-) create mode 100644 Frontend/src/pages/admin/EditRoomPage.tsx delete mode 100644 Frontend/src/pages/admin/RoomManagementPage.tsx 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 d936e0c41860e7619a692a73ccec3be5e806efe9..fa65499d0186876f4ca27aaaf0e6c509062769cd 100644 GIT binary patch delta 6468 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 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 3ae29ecf8c1027d1dd099f2e7705d9baba9abcd3..9d26b61e43931a87ae18fd912e2dd665481f4b94 100644 GIT binary patch delta 12393 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 delta 74 zcmX?L@KBfUG%qg~0}ya*)X97!IFV0+F>j)JK1(H+Cilh-^F*2bG`T0Yi#tp{BQD96 a1ysif#KqQ=--}yveP!Tf6fANC3IG80SrV=Q 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 394229dad91d23331b1b016313aa4f9570b0d676..325aba5ff23a2bb89a1041aa20d76143745611b9 100644 GIT binary patch delta 1300 zcmb_cOH30%7@p~4OSfGrTb8!8l$E|{!BR9R7DB?ocu;7X7>#eMU67`}Ri1^?gJbB}0;sp!`H6HwjQsj{+2PfHYX1@8qZ~lCl?6CCW zaNz{Uu?WWLyTJ~pd7#jWKMnX^+e{LU8>IrN*al~9nmB)5bHduC^_z!yJS>oFE}>$y z0HH$&qkBlf)PasCxe4{+9+Xg%s|99*W|cw-hAdQ;suFsFJ*A}FU4$7@>$Y%)d_j*I zN(Bj+V%)5hqdhobB0K$_b$3Gd8+r&bsbec;xrUcGGq#a4DhJUA@=cdj z>sHz?Bc}ou&0096`TI78ll5&4N53hYXPjA0x$VcZ>aeYN^6vjRpuFwBBC;CF^2sP1 z=?gTky@tOl@2icZwYr#eR|P?_z1wn{iat$umK9+floT|>+lc!b*nIEtJt``FQPJH>DX zFmIeqt&D2S;cRJS#~SQ_Z%abJj59(jX2%^o#h{KdbTZnST*)R_{XXJpHvoTQPw~@WI&pkA-K_u71BzpmV r7BqIIeE)Q^1+EQ<)H$48#J29Gb(<%;k(o!%y` delta 4644 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 joc;XdCm}umXvhfP_T1P 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" + /> +
+
+ +
+ +