From f7d6f24e49f1f6696e724a0a3faa6a398bb43c51 Mon Sep 17 00:00:00 2001 From: Iliyan Angelov Date: Mon, 1 Dec 2025 15:34:45 +0200 Subject: [PATCH] updates --- .../add_accountant_role.cpython-312.pyc | Bin 0 -> 2772 bytes Backend/seeds_data/add_housekeeping_role.py | 59 + Backend/seeds_data/add_housekeeping_user.py | 81 ++ .../seeds_data/assign_housekeeping_tasks.py | 149 ++ Backend/seeds_data/seed_initial_data.py | 3 +- .../booking_routes.cpython-312.pyc | Bin 107321 -> 108992 bytes Backend/src/bookings/routes/booking_routes.py | 40 + .../advanced_room_routes.cpython-312.pyc | Bin 43640 -> 45285 bytes .../__pycache__/room_routes.cpython-312.pyc | Bin 45835 -> 46505 bytes .../src/rooms/routes/advanced_room_routes.py | 57 +- Backend/src/rooms/routes/room_routes.py | 71 +- Frontend/src/App.tsx | 29 +- .../features/auth/components/AdminRoute.tsx | 2 + .../auth/components/HousekeepingRoute.tsx | 52 + .../features/auth/components/LoginModal.tsx | 2 + .../features/auth/components/StaffRoute.tsx | 2 + .../src/features/auth/components/index.ts | 1 + .../components/HousekeepingManagement.tsx | 13 +- .../features/rooms/contexts/RoomContext.tsx | 316 +++++ .../features/rooms/services/roomService.ts | 23 + Frontend/src/pages/HousekeepingLayout.tsx | 123 ++ .../admin/AdvancedRoomManagementPage.tsx | 1209 ++++++++--------- .../src/pages/admin/RoomManagementPage.tsx | 173 +-- .../src/pages/admin/UserManagementPage.tsx | 17 +- .../src/pages/housekeeping/DashboardPage.tsx | 505 +++++++ Frontend/src/pages/housekeeping/TasksPage.tsx | 19 + Frontend/src/pages/housekeeping/index.ts | 3 + Frontend/src/shared/components/Header.tsx | 4 +- 28 files changed, 2121 insertions(+), 832 deletions(-) create mode 100644 Backend/seeds_data/__pycache__/add_accountant_role.cpython-312.pyc create mode 100644 Backend/seeds_data/add_housekeeping_role.py create mode 100644 Backend/seeds_data/add_housekeeping_user.py create mode 100644 Backend/seeds_data/assign_housekeeping_tasks.py create mode 100644 Frontend/src/features/auth/components/HousekeepingRoute.tsx create mode 100644 Frontend/src/features/rooms/contexts/RoomContext.tsx create mode 100644 Frontend/src/pages/HousekeepingLayout.tsx create mode 100644 Frontend/src/pages/housekeeping/DashboardPage.tsx create mode 100644 Frontend/src/pages/housekeeping/TasksPage.tsx create mode 100644 Frontend/src/pages/housekeeping/index.ts diff --git a/Backend/seeds_data/__pycache__/add_accountant_role.cpython-312.pyc b/Backend/seeds_data/__pycache__/add_accountant_role.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..788378bcd44dde0592bdf7663a9715212ce9ab60 GIT binary patch literal 2772 zcmb7GO>7fK6rT02?e(sI@&m;U2^k1gaGThW1PUQgktPAu5-6O=#oBl`WRvx-ySr{; zH=!U=k%&~J>H!L^%ApqoQaScq2`;_Z#gfAcr2f!K+zjDRK@WXnXR}V~7Ih@gd-MM0 z&Fp*M_{TtiLolvg>yZEOAoLqq*aOz(vHTa1D@a5mNTfuULXA*BX~mVKM`#MUkVH$& zEvjNXQI1#P?Rmr_Q4>LX<JYs%R*(H=}S#euOXcZpEJrj0Ax9 zTtFj1DI|I)cOazi0RK9Xwf)Qa)rr0}>)@@G?P_Z4M9#6!xH7H?pFPW+O6YRR#HNM? zQN-rBgxiHgLQAWrpqlMi*A$5;i9!@iVN@`rD0ecgf}%w;*1miLHLB3gjY1i>Np7+{paMO&y>>;; zy5RO2ZJk4Bs`3GgjgH3Voamt6WJ?`d9ii$5s%pAQO3%1z_14;RR@pOP|H^x*viE^~ z*vZZ2*2af(yy6zyklAGWZb$loSsHv=Y>7jiyQ#{7o1xe4XSUiN6WHb)D)LSRokpU2 zmOe`_oUG6(Y6?x$r_mJEMymfmv$icG{%qf&m8M_cuIpMJ*J@vjbG*{skms*oeSr_H zf=8wA1qCoc%v3twz%l`jDk#bF7?U2yM+bUwHn6f77Lrmg9?lM~k2WQn;{;j&B?u~w z$*Q0xWI-uoQwL57nWUteMh6yD5%&5>lfg9NvKSR2TD&M#01FT%9r>4pD;OqXBVFER zEUhS+t=SO91CDbqPH!)}U%`XAuIae!B&%oE!(K0bLI5gKuM_OC+#jYTJ)@J(vpi$6 zVoJJBiq>+E$+}@$?vyU8rsa}F%bid(L$YW4cOr z;Vc#lFs+OViAjq#GKR$fyqXcla*3l>pbWK;TAV<+6>>gVML^xla0mRHktZ zF6A70?Av&0%enrNtHs^;bGYdn_DlBL4cCLkaA!W;d0YHWzA1m-myaI4A3jnH59hAL#hyMCp!{LJe2r!H9-3S!%&OF|kYu%M==qmWTiS*cw&f>P7{I;Il*4?>= zJq7>UOa6w^o5;ssX6M<@xaV)6&8(Qs{>*{S83wQobQCeV6MVa?y7W zxQ94&&*cWmJugjou3@N|w%B+)DadL(o{azy)TrNoJpIvVwyX1hkF0w@Q>CquddnM+ zi&`Qc*I$E#EpG~*eMKJC$!krrfu4}!am$kubm(J?Cp8TP7Z+vSVu-QjkyS&|O(=9z zx0(z+5jDoaSd1n#bxb}Jwg0448ZJZ(lA0(fMwHZlx%J9*NOmKj0I86_a5vF1%t&vB z=>^*dE@?$NsCU4UG_a9@sYFv0^^~D0W|>12{|JSjdQhlwaqlIy7;MW2+s^Szh`z|4 zXD^1%hZd>NyB9yc-`G|_+kZ#hj}U&wV5+e+Kq1CIm;H%;{Q=|4ak~r59$@BnTsZ!K Y*;HVNw6gHcwSPKX;t|uNzXiMh4Vztm00000 literal 0 HcmV?d00001 diff --git a/Backend/seeds_data/add_housekeeping_role.py b/Backend/seeds_data/add_housekeeping_role.py new file mode 100644 index 00000000..145aa247 --- /dev/null +++ b/Backend/seeds_data/add_housekeeping_role.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +Script to add the 'housekeeping' role to the database. +Run this script once to create the housekeeping role if it doesn't exist. +""" + +import sys +import os +from pathlib import Path + +# Add the Backend directory to the path +backend_dir = Path(__file__).parent.parent +sys.path.insert(0, str(backend_dir)) + +from src.shared.config.database import SessionLocal +from src.models import Role # Use the models __init__ which handles all imports + +def add_housekeeping_role(): + """Add the housekeeping role to the database if it doesn't exist.""" + db = SessionLocal() + try: + # Check if housekeeping role already exists + existing_role = db.query(Role).filter(Role.name == 'housekeeping').first() + + if existing_role: + print("✓ Housekeeping role already exists in the database.") + print(f" Role ID: {existing_role.id}") + print(f" Role Name: {existing_role.name}") + return + + # Create the housekeeping role + housekeeping_role = Role( + name='housekeeping', + description='Housekeeping staff role with access to room cleaning tasks and status updates' + ) + db.add(housekeeping_role) + db.commit() + db.refresh(housekeeping_role) + + print("✓ Housekeeping role created successfully!") + print(f" Role ID: {housekeeping_role.id}") + print(f" Role Name: {housekeeping_role.name}") + print(f" Description: {housekeeping_role.description}") + + except Exception as e: + db.rollback() + print(f"✗ Error creating housekeeping role: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + finally: + db.close() + +if __name__ == '__main__': + print("Adding housekeeping role to the database...") + print("=" * 60) + add_housekeeping_role() + print("=" * 60) + print("Done!") diff --git a/Backend/seeds_data/add_housekeeping_user.py b/Backend/seeds_data/add_housekeeping_user.py new file mode 100644 index 00000000..e43da954 --- /dev/null +++ b/Backend/seeds_data/add_housekeeping_user.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +Script to add a housekeeping user to the database. +""" + +import sys +import os +from pathlib import Path + +# Add the Backend directory to the path +backend_dir = Path(__file__).parent.parent +sys.path.insert(0, str(backend_dir)) + +from src.shared.config.database import SessionLocal +from src.models import Role, User +import bcrypt + +def add_housekeeping_user(): + """Add the housekeeping user to the database if it doesn't exist.""" + db = SessionLocal() + try: + # Get housekeeping role + housekeeping_role = db.query(Role).filter(Role.name == 'housekeeping').first() + + if not housekeeping_role: + print("✗ Housekeeping role not found! Please run add_housekeeping_role.py first.") + sys.exit(1) + + # Check if user already exists + existing_user = db.query(User).filter(User.email == 'housekeeping@gnxsoft.com').first() + + if existing_user: + print("✓ Housekeeping user already exists in the database.") + print(f" User ID: {existing_user.id}") + print(f" Email: {existing_user.email}") + print(f" Full Name: {existing_user.full_name}") + print(f" Role: {existing_user.role.name if existing_user.role else 'N/A'}") + return + + # Hash password + password = 'P4eli240453.' + password_bytes = password.encode('utf-8') + salt = bcrypt.gensalt() + hashed_password = bcrypt.hashpw(password_bytes, salt).decode('utf-8') + + # Create the housekeeping user + housekeeping_user = User( + email='housekeeping@gnxsoft.com', + password=hashed_password, + full_name='Housekeeping Staff', + role_id=housekeeping_role.id, + is_active=True, + currency='EUR' + ) + db.add(housekeeping_user) + db.commit() + db.refresh(housekeeping_user) + + print("✓ Housekeeping user created successfully!") + print(f" User ID: {housekeeping_user.id}") + print(f" Email: {housekeeping_user.email}") + print(f" Full Name: {housekeeping_user.full_name}") + print(f" Role: {housekeeping_role.name}") + print(f" Password: P4eli240453.") + + except Exception as e: + db.rollback() + print(f"✗ Error creating housekeeping user: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + finally: + db.close() + +if __name__ == '__main__': + print("Adding housekeeping user to the database...") + print("=" * 60) + add_housekeeping_user() + print("=" * 60) + print("Done!") + diff --git a/Backend/seeds_data/assign_housekeeping_tasks.py b/Backend/seeds_data/assign_housekeeping_tasks.py new file mode 100644 index 00000000..f0069047 --- /dev/null +++ b/Backend/seeds_data/assign_housekeeping_tasks.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +Script to assign test housekeeping tasks to the housekeeping user. +This creates sample tasks for testing the housekeeping dashboard. +""" + +import sys +import os +from pathlib import Path +from datetime import datetime, timedelta + +# Add the Backend directory to the path +backend_dir = Path(__file__).parent.parent +sys.path.insert(0, str(backend_dir)) + +from src.shared.config.database import SessionLocal +from src.models import Role, User, Room +from src.hotel_services.models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType + +def assign_housekeeping_tasks(): + """Assign test housekeeping tasks to the housekeeping user.""" + db = SessionLocal() + try: + # Get housekeeping user + housekeeping_user = db.query(User).join(Role).filter( + Role.name == 'housekeeping', + User.email == 'housekeeping@gnxsoft.com' + ).first() + + if not housekeeping_user: + print("✗ Housekeeping user not found! Please run add_housekeeping_user.py first.") + sys.exit(1) + + print(f"✓ Found housekeeping user: {housekeeping_user.email} (ID: {housekeeping_user.id})") + + # Get admin user for created_by + admin_role = db.query(Role).filter(Role.name == 'admin').first() + admin_user = db.query(User).filter(User.role_id == admin_role.id).first() if admin_role else None + + # Get some rooms + rooms = db.query(Room).limit(5).all() + + if not rooms: + print("✗ No rooms found in the database! Please seed rooms first.") + sys.exit(1) + + print(f"✓ Found {len(rooms)} rooms") + + # Default checklist items for different task types + checklists = { + 'checkout': [ + {'item': 'Bathroom cleaned', 'completed': False, 'notes': ''}, + {'item': 'Beds made with fresh linens', 'completed': False, 'notes': ''}, + {'item': 'Trash emptied', 'completed': False, 'notes': ''}, + {'item': 'Towels replaced', 'completed': False, 'notes': ''}, + {'item': 'Amenities restocked', 'completed': False, 'notes': ''}, + {'item': 'Floor vacuumed and mopped', 'completed': False, 'notes': ''}, + {'item': 'Surfaces dusted', 'completed': False, 'notes': ''}, + {'item': 'Windows and mirrors cleaned', 'completed': False, 'notes': ''}, + ], + 'stayover': [ + {'item': 'Beds made', 'completed': False, 'notes': ''}, + {'item': 'Trash emptied', 'completed': False, 'notes': ''}, + {'item': 'Towels replaced', 'completed': False, 'notes': ''}, + {'item': 'Bathroom cleaned', 'completed': False, 'notes': ''}, + ], + 'vacant': [ + {'item': 'Deep clean bathroom', 'completed': False, 'notes': ''}, + {'item': 'Change linens', 'completed': False, 'notes': ''}, + {'item': 'Vacuum and mop', 'completed': False, 'notes': ''}, + {'item': 'Dust surfaces', 'completed': False, 'notes': ''}, + {'item': 'Check amenities', 'completed': False, 'notes': ''}, + ], + 'inspection': [ + {'item': 'Check all amenities', 'completed': False, 'notes': ''}, + {'item': 'Test electronics', 'completed': False, 'notes': ''}, + {'item': 'Check for damages', 'completed': False, 'notes': ''}, + {'item': 'Verify cleanliness', 'completed': False, 'notes': ''}, + ], + } + + # Create tasks for today + today = datetime.utcnow().replace(hour=9, minute=0, second=0, microsecond=0) + task_types = ['checkout', 'stayover', 'vacant', 'inspection'] + + created_count = 0 + skipped_count = 0 + + for i, room in enumerate(rooms): + # Cycle through task types + task_type = task_types[i % len(task_types)] + + # Check if task already exists for this room today + existing_task = db.query(HousekeepingTask).filter( + HousekeepingTask.room_id == room.id, + HousekeepingTask.assigned_to == housekeeping_user.id, + HousekeepingTask.status == HousekeepingStatus.pending, + HousekeepingTask.scheduled_time >= today.replace(hour=0, minute=0), + HousekeepingTask.scheduled_time < today.replace(hour=23, minute=59, second=59) + ).first() + + if existing_task: + print(f" ⚠️ Task already exists for Room {room.room_number}, skipping...") + skipped_count += 1 + continue + + # Schedule tasks at different times throughout the day + scheduled_time = today + timedelta(hours=i) + + task = HousekeepingTask( + room_id=room.id, + booking_id=None, + task_type=HousekeepingType(task_type), + status=HousekeepingStatus.pending, + scheduled_time=scheduled_time, + assigned_to=housekeeping_user.id, + created_by=admin_user.id if admin_user else housekeeping_user.id, + checklist_items=checklists.get(task_type, []), + notes=f'Test task for Room {room.room_number} - {task_type} cleaning', + estimated_duration_minutes=45 if task_type == 'checkout' else 30 + ) + + db.add(task) + created_count += 1 + print(f" ✓ Created {task_type} task for Room {room.room_number} (scheduled: {scheduled_time.strftime('%Y-%m-%d %H:%M')})") + + db.commit() + + print(f"\n✓ Tasks assigned successfully!") + print(f" - Created: {created_count} task(s)") + print(f" - Skipped: {skipped_count} task(s) (already exist)") + print(f" - Assigned to: {housekeeping_user.email}") + + except Exception as e: + db.rollback() + print(f"✗ Error assigning housekeeping tasks: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + finally: + db.close() + +if __name__ == '__main__': + print("Assigning test housekeeping tasks to housekeeping user...") + print("=" * 60) + assign_housekeeping_tasks() + print("=" * 60) + print("Done!") + diff --git a/Backend/seeds_data/seed_initial_data.py b/Backend/seeds_data/seed_initial_data.py index 7abf8c82..5510251f 100644 --- a/Backend/seeds_data/seed_initial_data.py +++ b/Backend/seeds_data/seed_initial_data.py @@ -26,7 +26,8 @@ def seed_roles(db: Session): {'name': 'admin', 'description': 'Administrator with full access'}, {'name': 'staff', 'description': 'Staff member with limited admin access'}, {'name': 'customer', 'description': 'Regular customer'}, - {'name': 'accountant', 'description': 'Accountant role with access to financial data, payments, and invoices'} + {'name': 'accountant', 'description': 'Accountant role with access to financial data, payments, and invoices'}, + {'name': 'housekeeping', 'description': 'Housekeeping staff role with access to room cleaning tasks and status updates'} ] for role_data in roles_data: diff --git a/Backend/src/bookings/routes/__pycache__/booking_routes.cpython-312.pyc b/Backend/src/bookings/routes/__pycache__/booking_routes.cpython-312.pyc index a9c2067ed36d5953319b089480d438525cf8365f..367ca47970a392d2e32df444fd3ba1cc35f1f4ee 100644 GIT binary patch delta 4926 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 delta 3407 zcma)8YitzP6`nIQ`}96NJNxjyyxui_vwmSP?>D4~1{X?cAQ;L(Vex|uE894CaMnVp zL{V56RhI-zc{C~nDJhJi7dpTuIFMa7AUksO6U#0qa->#k zlUW;>owoL}fRl`5=N1yTOWYI-v-EWfchQL@0IB11DZz zHbsYbxuAh_B4=*M1=|7Etav~KyVeEjMPv{H7KQ{8J6bzuE5*Q|;#4nfN_<6xzgY%#d$sg^52cE4 zdZE|nn;cGJ6fZK$&mjYTD-K?q@xh{v-_i{ce*yBc;zK^kA0PuMuzM-P$U&*3pa~x5 zhgdQS(RuO>KmKZXGLC3=(Gv#6i^G0cQRw0#ymBW9o*irtlQqcQS~8^_OFNe-e*C-@ z0^rAgH$#X={?sA$NK}9rK2iczmH-OKeiTT(qGm#Z!A2#V34&m+NN08KsiW$l&jRor ze8~$Thg2rT_3|qvG}ltR>;S1L-SamC5LaXG3%u+_CaqYv21Nr%2_=Zzt>F6{jq*H= z5DF0*p#lveQ5s@kni5VQ*NLETp@ie`lY7J{KY7k!ARCxgH{-*#=J3%ZuKM{`h z9Vs=RdV_}L=$BO2?oU67u}Rha26Au_!h}fXMbfRZI0Z#@TEh*6!31woKh7zL)SLK) z{Sd~d!ob5t8n3(rMTToGIP-jz7%G+H=^qr>l}fdQ*%>v`L}h#YoF8+hq*^QbKKBN`IoI1|=dz<{quAR#7D zA`5$ur$_X#Qt|+U+wI_2PukqRa#Sw4wB|2MzpF!1?@?C@El43Aa^W)a{hA^8d0 z`;LQQK4r#VVn+Eb96sCxufReaX@)j(K+YVMoI+^&M&CjNG{o62)na=k6g z-35!IWOP3QeY5%dQJ%{2MGeH}frmzTAo_Z0xd*iz)g&#w_1uG519iPM+*rAhsK#mx zL^W1Vr^lLrSdX<>`et%t3+nnBx$#;f(Tq15h-Q2y^*uh%(jVhKGcI!Vhq#Ac>f&L@ zKvWN7)WyR@u~N!CY*}2c1i42+0~tIjaU|K^$vZt)7NjQ?!(;n_2l2pqyg>&{`70mVi+egujS$!;F(7D&wQ@yAdDCc zEGhqcx#lc%2=Gbn^jT8!BDyRxMawLH^gZ%l9n1w^hjZNAtsHEDV{1nlO}JXWRF~Fm c(51eCd->Uaa16T!Mp+Fyy4-~Sdk#ka2R~ChMF0Q* diff --git a/Backend/src/bookings/routes/booking_routes.py b/Backend/src/bookings/routes/booking_routes.py index 9e92ee74..3dfe052e 100644 --- a/Backend/src/bookings/routes/booking_routes.py +++ b/Backend/src/bookings/routes/booking_routes.py @@ -730,6 +730,46 @@ async def update_booking(id: int, booking_data: UpdateBookingRequest, current_us room.status = RoomStatus.maintenance else: room.status = RoomStatus.cleaning + + # Auto-create housekeeping task for checkout cleaning + from ...hotel_services.models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType + + # Check if a pending checkout task already exists for this room + existing_task = db.query(HousekeepingTask).filter( + and_( + HousekeepingTask.room_id == room.id, + HousekeepingTask.booking_id == booking.id, + HousekeepingTask.task_type == HousekeepingType.checkout, + HousekeepingTask.status.in_([HousekeepingStatus.pending, HousekeepingStatus.in_progress]) + ) + ).first() + + if not existing_task: + # Default checklist items for checkout cleaning + checkout_checklist = [ + {'item': 'Bathroom cleaned', 'completed': False, 'notes': ''}, + {'item': 'Beds made with fresh linens', 'completed': False, 'notes': ''}, + {'item': 'Trash emptied', 'completed': False, 'notes': ''}, + {'item': 'Towels replaced', 'completed': False, 'notes': ''}, + {'item': 'Amenities restocked', 'completed': False, 'notes': ''}, + {'item': 'Floor vacuumed and mopped', 'completed': False, 'notes': ''}, + {'item': 'Surfaces dusted', 'completed': False, 'notes': ''}, + {'item': 'Windows and mirrors cleaned', 'completed': False, 'notes': ''}, + ] + + housekeeping_task = HousekeepingTask( + room_id=room.id, + booking_id=booking.id, + task_type=HousekeepingType.checkout, + status=HousekeepingStatus.pending, + scheduled_time=datetime.utcnow(), # Schedule immediately + created_by=current_user.id, + checklist_items=checkout_checklist, + notes=f'Auto-created on checkout for booking {booking.booking_number}', + estimated_duration_minutes=45 # Default 45 minutes for checkout cleaning + ) + db.add(housekeeping_task) + db.flush() # Flush to get the task ID if needed for notifications elif new_status == BookingStatus.cancelled: # Update room status when booking is cancelled if booking.payments: diff --git a/Backend/src/rooms/routes/__pycache__/advanced_room_routes.cpython-312.pyc b/Backend/src/rooms/routes/__pycache__/advanced_room_routes.cpython-312.pyc index 15b8a060c0b6d472058febc02bb8028997f902b5..e921a20ca1a0c59405440916381e2991ea9e7268 100644 GIT binary patch delta 4846 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 LUt{?iGShzpbQtwx delta 3911 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 NrnAoyT`7$De*ma^X=MNa delta 7695 zcmbtZ3vd+2nV#O+nSJZEyLvy-1M!dq76uCm(1IX5jRZOa;vp8zO4(&u$+Lqb#L5DM z!Dr)>?IuYDhC|Fj(tg_LJ7{sRWX;kbCOFOos-Jl<#E3M@7a+? zGT4r93jOi7q)+fEmiweksd6~?5>Kz1+=*r>&u5Yr_-ds4;cq^D zho3_hQZrvj7Cj@TB@^iZ-%>WD)MsrKr7HRd5G@8#0g%4cTrlY)4Wz&o%yT2jPH40@s;Q_ zuhwZ^r)gffW>Pb*=x46HD3xjNWa=z<4>@yqwguMuQhY)NsF|>Ame%Pkuh&*<&0k{~ z_v`xM4Vr(SwqBrGe-L(>ww7jTqt5OII$}s8^{^pr>R!doXr#X|Ez80ez*^E~-Gq$? zT(cUZkDIS2Mz3Z6EJ+MNOmdK(FFRclGx(*L(CU}NiY#Jx7Lo%;&LMdS$s#0QMRJjPJT5XwAM)ghY~)dT z&@-PLq%V7NR-eHUXOX-JBxd!8{h_ep4Yl}KSTUi~8{F@czllPjHQ4S|?liy&V{!d~iY$YJbq6p21^;AJ&*l(2{(-u`u zRYitC4ZX9&4fDwfq-<%5Kn9FUvh1yccdlO;Ccq%gAtH$pbD#*#gBvArH!kl)r)I}n z4ZB@N7*tM^cR2++Nm3Z#t#6G`r8-H$|6#Q3$ zGfB?V?FDTR2(-XWY$iEZGO6O%=bR!gYFr~U`$7fM8+b{%_+>{A)MAjq4(XoRXr=E8^r_!b!=>My*GyS~@v73D1(&&r$23 zv-}9B)N4I3cVn;Zv*zMCv@4sm34y=C0>Y7^V6I|M#74_I(>6?=RHx+9A1+DkwLN0% zBFa{61{iJl5=OVU^+vUA{~Y(cK}sD?)7Fe|x^tc`W!%k`E>2y=m-#L-E0bpJsU=-> z$DUl$Ne}KR==&BpI?JYJS(f@(s%43orB#+?r=tqYQB$h{xQhvD8lMVTYA$E9nVx&7 z$mV30o>^vQV1(~e9!4@q7qyfm_W&ESDY7>d_O>vX^|oBeEsF_zd~N=a+<+sqku)Od zM{DQz=RSbj}{xH8M zVMT`{sD{CQK5#KH*xup|hUtGwnal0+KcSGp%T?qU)ZRo6Lynn!-TtuR54FXta(nx} z<__5ph(CyO6@EB+d?>fV)*}17imw^R$hSax8~(!Y3LN(f?!OOl<7Uq39xQ#jl+Hf1 zZro5skNcKe({9`INA3Az_8D}5bcx5h*~H&6*`aaE>9IHF@nd-c zG{%bf#+h{Y-dy`QAWR&NjT3Mz02~`9;h2_ICDKoVS@efnb4&o9fW!hlb2N{}&Rc2r zwj@I6)87OP3<`unxC*{z7r2ZM9coIA4^dG#+hmHE^h)6NWz-+Y2r6{*VzY&QE0iY; z=$x5HujD!DYoS!fUf4#8Q8j@!(r9VOL2qo!Hkl)4!1oCklSRoiO*Ol=bS6yaHcNLvo6f=Mi}X!f%ZiXu{lvUHaY^QQ6|hK~uL^LfL;z(0o6kb=Az}@|i}8{`jF$u=t_Sqaj@JOlX*GaI zU#(#^0Amb+YODb?or$~BU|J2U0GI$P070_ED*#L|>;b#;mQ1Svte96KBGwO3czj3Y z;uQev1^$Wx6@b?Gjp71htlXfRnpp*)3wKhWRRFe$nXOPe#AgyUMp)H&;ld)V`g9qv zChj`-nPq^LzOXwdM}_-!+6;sTd>Gsd^xE#S92L~>H3EX zWd9inqON=c2}4YTa2eM{gUw@IdE$4meUqN=subTs_HFvJu7(TM@feqHI+et;ENkwBx zB~<7M>6$P0^bkFE9zJ-5{J*esTkq##=k_Vsxn&_aS#N91i*eWcOGfO67l?YKO*;XL#&e%K6*n0yJ({@-Txo98QgvKXR04 zUpn6Co3|AUFc}MXC=>6taoC>0?Gh6uU(l!cWcJo~;54?CnsKh6zPGE`i^&P9yV^s= z!h67W)4-v>Sm(hDQ8AldU6YCz=o}spn+xD>pZ4?`BL>=V(oK8q7WxC5nZBRydd6WR zR(7?yvCV0Y7}X<4vtFE;2KVebZa{1?v~ewloe3P*3wRF~$7M${Dowx}L28amEg%J` zjhH$~n4AFaX~PMdk?XZUuSG6^8!Nl57wHObxUEMl&v6p}JU?vIumx`IpY5U%U8MPo zi)eGdO|XLEG`xPKD)^a>JrQffD49+hBy+?{&-75Sw^f^oH`h%HVxB8k7Q?jd@-v20Ge>xxSwW$db9PC*@FLmY9jAL%{ z7OgmTs?Mn<$)AAit{eVw6l_N_nJI5$lkNL_O zR{2GeQ_sCB_jevEgTj8-y~OLX3;ISD>=;YfIbz=Vr*X(ryP(F$Ja8m=bt*aO$*Wt+ zU8{7~EihgeMc}X7Ep=7K>#0VhnaeYv>qaGkp*I#-kzZQA+R1-+uE)A+A%AnB9U5;L zJoeQI{M!ivG~RadFt*2DanLXIR`~aM+kAXjz8F3Kw?8*(32fl+`ce=iXBp)>_)*cT zhvpBI!iUy11Et!R*RFv_7vs3;xoao(?Lxw`ShAYM*vPBc8&72mpDMKd7TPi1*R9AO zQR%4;0lsE%-;Vy|sXU&Xi~bM&6|ed9%fb7|&uRA8XZJDB__$N3ZY>g)Yg6yK^(Jzy z{2R1p-!BJ){ypU#UODWOc`z6=c6gPq%Ck`qR)}ha@CW36iR4#6Vj{a`hGVg zw0C;YEYu`5R02xnt2k8+73U{}+U0%Tpufi_HG>DLVUal@$sgg^|3&f%M~hFDkU!D& zrxpng&~q#L*r^MGwhRkjsDO9S`of~VgE$&LoU5T{eNO0@RpMXskz*J3V&oo0!Vcy! zl`Bq!oZiQ#rx@kucw%^u1x!?ULg(Y&X66vv_g+eBXe;>Uz)G8KFt=eX(-~VH#{qSnwc*`uxSLpheGT=WCT3^~I zs$tVNU#iYz)+b*sld!m6rAZgk*JOhc_0xF{a+6gQ3n?G_piJiEJR~euJazK4&1;qW z>BASCA}f&!=;IeQ2_-PW5xsNaq=D2&J1=z;GK2nhcsKc&*1ufYhcz0ctB?{iLi|Cw z6_xstoJR5nk~e|CrfY})bh(8r-?3kjvC5KJN<@5@?;yeBAmc8Oami$~e3APdNI2OC zeeKfzpl_*M3nKW(8N&ObmRIr&(ao>s8>*HNFBvJgZ=6HsjV?JZ?_<`1Q#QgB6plX_O?>@XVq69R{U69}Bh3H+ diff --git a/Backend/src/rooms/routes/advanced_room_routes.py b/Backend/src/rooms/routes/advanced_room_routes.py index 2f663808..5350cf25 100644 --- a/Backend/src/rooms/routes/advanced_room_routes.py +++ b/Backend/src/rooms/routes/advanced_room_routes.py @@ -346,19 +346,20 @@ async def get_housekeeping_tasks( date: Optional[str] = Query(None), page: int = Query(1, ge=1), limit: int = Query(20, ge=1, le=100), - current_user: User = Depends(authorize_roles('admin', 'staff')), + current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')), db: Session = Depends(get_db) ): """Get housekeeping tasks with filtering""" try: - # Check if user is staff (not admin) - staff should only see their assigned tasks + # Check user role - housekeeping and staff users should only see their assigned tasks role = db.query(Role).filter(Role.id == current_user.role_id).first() - is_staff = role and role.name == 'staff' + is_admin = role and role.name == 'admin' + is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff') query = db.query(HousekeepingTask) - # Filter by assigned_to for staff users - if is_staff: + # Filter by assigned_to for housekeeping and staff users (not admin) + if is_housekeeping_or_staff: query = query.filter(HousekeepingTask.assigned_to == current_user.id) if room_id: @@ -488,7 +489,7 @@ async def create_housekeeping_task( async def update_housekeeping_task( task_id: int, task_data: dict, - current_user: User = Depends(authorize_roles('admin', 'staff')), + current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')), db: Session = Depends(get_db) ): """Update a housekeeping task""" @@ -497,22 +498,23 @@ async def update_housekeeping_task( if not task: raise HTTPException(status_code=404, detail='Housekeeping task not found') - # Check if user is staff (not admin) - staff can only update their own assigned tasks + # Check user role - housekeeping and staff users can only update their own assigned tasks role = db.query(Role).filter(Role.id == current_user.role_id).first() - is_staff = role and role.name == 'staff' + is_admin = role and role.name == 'admin' + is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff') - if is_staff: - # Staff can only update tasks assigned to them + if is_housekeeping_or_staff: + # Housekeeping and staff can only update tasks assigned to them if task.assigned_to != current_user.id: raise HTTPException(status_code=403, detail='You can only update tasks assigned to you') - # Staff cannot change assignment + # Housekeeping and staff cannot change assignment if 'assigned_to' in task_data and task_data.get('assigned_to') != task.assigned_to: raise HTTPException(status_code=403, detail='You cannot change task assignment') old_assigned_to = task.assigned_to assigned_to_changed = False - if 'assigned_to' in task_data and not is_staff: + if 'assigned_to' in task_data and is_admin: new_assigned_to = task_data.get('assigned_to') if new_assigned_to != old_assigned_to: task.assigned_to = new_assigned_to @@ -537,6 +539,37 @@ async def update_housekeeping_task( if task.started_at: duration = (task.completed_at - task.started_at).total_seconds() / 60 task.actual_duration_minutes = int(duration) + + # Update room status when housekeeping task is completed + room = db.query(Room).filter(Room.id == task.room_id).first() + if room: + # Check if there are other pending housekeeping tasks for this room + pending_tasks = db.query(HousekeepingTask).filter( + and_( + HousekeepingTask.room_id == room.id, + HousekeepingTask.id != task.id, + HousekeepingTask.status.in_([HousekeepingStatus.pending, HousekeepingStatus.in_progress]) + ) + ).count() + + # Check if there's active maintenance + from ...rooms.models.room_maintenance import RoomMaintenance, MaintenanceStatus + active_maintenance = db.query(RoomMaintenance).filter( + and_( + RoomMaintenance.room_id == room.id, + RoomMaintenance.blocks_room == True, + RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress]) + ) + ).first() + + if active_maintenance: + room.status = RoomStatus.maintenance + elif pending_tasks > 0: + # Keep room as cleaning if there are other pending tasks + room.status = RoomStatus.cleaning + else: + # No pending tasks and no maintenance - room is ready + room.status = RoomStatus.available if 'checklist_items' in task_data: task.checklist_items = task_data['checklist_items'] diff --git a/Backend/src/rooms/routes/room_routes.py b/Backend/src/rooms/routes/room_routes.py index 83849fce..0eae485f 100644 --- a/Backend/src/rooms/routes/room_routes.py +++ b/Backend/src/rooms/routes/room_routes.py @@ -72,6 +72,26 @@ 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.get('/room-types') +async def get_room_types(db: Session=Depends(get_db)): + """Get all room types for dropdowns and forms.""" + try: + room_types = db.query(RoomType).order_by(RoomType.name).all() + room_types_list = [ + { + 'id': rt.id, + 'name': rt.name, + 'description': rt.description, + 'base_price': float(rt.base_price) if rt.base_price else 0.0, + 'capacity': rt.capacity, + } + for rt in room_types + ] + return {'status': 'success', 'data': {'room_types': room_types_list}} + except Exception as e: + logger.error(f'Error fetching room types: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + @router.get('/available') async def search_available_rooms(request: Request, from_date: str=Query(..., alias='from'), to_date: str=Query(..., alias='to'), roomId: Optional[int]=Query(None, alias='roomId'), type: Optional[str]=Query(None), capacity: Optional[int]=Query(None), page: int=Query(1, ge=1), limit: int=Query(12, ge=1, le=100), db: Session=Depends(get_db)): try: @@ -215,19 +235,17 @@ async def get_room_by_number(room_number: str, request: Request, db: Session=Dep @router.post('/', dependencies=[Depends(authorize_roles('admin'))]) async def create_room(room_data: CreateRoomRequest, request: Request, current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)): """Create a new room with validated input using Pydantic schema.""" - # Start transaction - transaction = db.begin() try: # Lock room type to prevent race conditions room_type = db.query(RoomType).filter(RoomType.id == room_data.room_type_id).with_for_update().first() if not room_type: - transaction.rollback() + db.rollback() raise HTTPException(status_code=404, detail='Room type not found') # Check for duplicate room number with locking existing = db.query(Room).filter(Room.room_number == room_data.room_number).with_for_update().first() if existing: - transaction.rollback() + db.rollback() raise HTTPException(status_code=400, detail='Room number already exists') # Use price from request or default to room type base price @@ -250,7 +268,7 @@ async def create_room(room_data: CreateRoomRequest, request: Request, current_us db.flush() # Commit transaction - transaction.commit() + db.commit() db.refresh(room) base_url = get_base_url(request) room_dict = {'id': room.id, 'room_type_id': room.room_type_id, 'room_number': room.room_number, 'floor': room.floor, 'status': room.status.value if isinstance(room.status, RoomStatus) else room.status, 'price': float(room.price) if room.price is not None and room.price > 0 else None, 'featured': room.featured, 'description': room.description, 'capacity': room.capacity, 'room_size': room.room_size, 'view': room.view, 'amenities': room.amenities if room.amenities else [], 'created_at': room.created_at.isoformat() if room.created_at else None, 'updated_at': room.updated_at.isoformat() if room.updated_at else None} @@ -262,36 +280,31 @@ async def create_room(room_data: CreateRoomRequest, request: Request, current_us room_dict['room_type'] = {'id': room.room_type.id, 'name': room.room_type.name, 'description': room.room_type.description, 'base_price': float(room.room_type.base_price) if room.room_type.base_price else 0.0, 'capacity': room.room_type.capacity, 'amenities': room.room_type.amenities if room.room_type.amenities else [], 'images': []} return success_response(data={'room': room_dict}, message='Room created successfully') except HTTPException: - if 'transaction' in locals(): - transaction.rollback() + db.rollback() raise except IntegrityError as e: - if 'transaction' in locals(): - transaction.rollback() + db.rollback() logger.error(f'Database integrity error during room creation: {str(e)}') raise HTTPException(status_code=409, detail='Room conflict detected. Please check room number.') except Exception as e: - if 'transaction' in locals(): - transaction.rollback() + db.rollback() logger.error(f'Error creating room: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail='An error occurred while creating the room') @router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))]) async def update_room(id: int, room_data: UpdateRoomRequest, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): """Update a room with validated input using Pydantic schema.""" - # Start transaction - transaction = db.begin() try: # Lock room row to prevent race conditions room = db.query(Room).filter(Room.id == id).with_for_update().first() if not room: - transaction.rollback() + db.rollback() raise HTTPException(status_code=404, detail='Room not found') if room_data.room_type_id: room_type = db.query(RoomType).filter(RoomType.id == room_data.room_type_id).first() if not room_type: - transaction.rollback() + db.rollback() raise HTTPException(status_code=404, detail='Room type not found') room.room_type_id = room_data.room_type_id @@ -299,7 +312,7 @@ async def update_room(id: int, room_data: UpdateRoomRequest, request: Request, c # Check for duplicate room number existing = db.query(Room).filter(Room.room_number == room_data.room_number, Room.id != id).first() if existing: - transaction.rollback() + db.rollback() raise HTTPException(status_code=400, detail='Room number already exists') room.room_number = room_data.room_number @@ -323,7 +336,7 @@ async def update_room(id: int, room_data: UpdateRoomRequest, request: Request, c room.amenities = room_data.amenities or [] # Commit transaction - transaction.commit() + db.commit() db.refresh(room) base_url = get_base_url(request) @@ -359,17 +372,14 @@ async def update_room(id: int, room_data: UpdateRoomRequest, request: Request, c } return success_response(data={'room': room_dict}, message='Room updated successfully') except HTTPException: - if 'transaction' in locals(): - transaction.rollback() + db.rollback() raise except IntegrityError as e: - if 'transaction' in locals(): - transaction.rollback() + db.rollback() logger.error(f'Database integrity error during room update: {str(e)}') raise HTTPException(status_code=409, detail='Room conflict detected. Please check room number.') except Exception as e: - if 'transaction' in locals(): - transaction.rollback() + db.rollback() logger.error(f'Error updating room: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail='An error occurred while updating the room') @@ -391,8 +401,6 @@ async def delete_room(id: int, current_user: User=Depends(authorize_roles('admin @router.post('/bulk-delete', dependencies=[Depends(authorize_roles('admin'))]) async def bulk_delete_rooms(room_ids: BulkDeleteRoomsRequest, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): """Bulk delete rooms with validated input using Pydantic schema.""" - # Start transaction - transaction = db.begin() try: ids = room_ids.room_ids @@ -401,30 +409,27 @@ async def bulk_delete_rooms(room_ids: BulkDeleteRoomsRequest, current_user: User found_ids = [room.id for room in rooms] not_found_ids = [id for id in ids if id not in found_ids] if not_found_ids: - transaction.rollback() + db.rollback() raise HTTPException(status_code=404, detail=f'Rooms with IDs {not_found_ids} not found') # Delete rooms deleted_count = db.query(Room).filter(Room.id.in_(ids)).delete(synchronize_session=False) # Commit transaction - transaction.commit() + db.commit() return success_response( data={'deleted_count': deleted_count, 'deleted_ids': ids}, message=f'Successfully deleted {deleted_count} room(s)' ) except HTTPException: - if 'transaction' in locals(): - transaction.rollback() + db.rollback() raise except IntegrityError as e: - if 'transaction' in locals(): - transaction.rollback() + db.rollback() logger.error(f'Database integrity error during bulk room deletion: {str(e)}') raise HTTPException(status_code=409, detail='Cannot delete rooms due to existing relationships (bookings, etc.)') except Exception as e: - if 'transaction' in locals(): - transaction.rollback() + db.rollback() logger.error(f'Error bulk deleting rooms: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail='An error occurred while deleting rooms') diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index 5a41cb5d..cbe8f85e 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -14,6 +14,7 @@ import { CurrencyProvider } from './features/payments/contexts/CurrencyContext'; import { CompanySettingsProvider } from './shared/contexts/CompanySettingsContext'; import { AuthModalProvider } from './features/auth/contexts/AuthModalContext'; import { AntibotProvider } from './features/auth/contexts/AntibotContext'; +import { RoomProvider } from './features/rooms/contexts/RoomContext'; import { logDebug } from './shared/utils/errorReporter'; import OfflineIndicator from './shared/components/OfflineIndicator'; import CookieConsentBanner from './shared/components/CookieConsentBanner'; @@ -37,7 +38,8 @@ import { AdminRoute, StaffRoute, AccountantRoute, - CustomerRoute + CustomerRoute, + HousekeepingRoute } from './features/auth/components'; const HomePage = lazy(() => import('./features/content/pages/HomePage')); @@ -122,6 +124,11 @@ const AccountantPaymentManagementPage = lazy(() => import('./pages/accountant/Pa const AccountantInvoiceManagementPage = lazy(() => import('./pages/accountant/InvoiceManagementPage')); const AccountantAnalyticsDashboardPage = lazy(() => import('./pages/accountant/AnalyticsDashboardPage')); const AccountantLayout = lazy(() => import('./pages/AccountantLayout')); + +const HousekeepingDashboardPage = lazy(() => import('./pages/housekeeping/DashboardPage')); +const HousekeepingTasksPage = lazy(() => import('./pages/housekeeping/TasksPage')); +const HousekeepingLayout = lazy(() => import('./pages/HousekeepingLayout')); + const AdminProfilePage = lazy(() => import('./pages/admin/ProfilePage')); const StaffProfilePage = lazy(() => import('./pages/staff/ProfilePage')); const AccountantProfilePage = lazy(() => import('./pages/accountant/ProfilePage')); @@ -210,6 +217,7 @@ function App() { + + {/* Housekeeping Routes */} + + + + + + } + > + } + /> + } /> + + {} + diff --git a/Frontend/src/features/auth/components/AdminRoute.tsx b/Frontend/src/features/auth/components/AdminRoute.tsx index d51570b8..a3556cc4 100644 --- a/Frontend/src/features/auth/components/AdminRoute.tsx +++ b/Frontend/src/features/auth/components/AdminRoute.tsx @@ -62,6 +62,8 @@ const AdminRoute: React.FC = ({ return ; } else if (userInfo?.role === 'accountant') { return ; + } else if (userInfo?.role === 'housekeeping') { + return ; } return ; } diff --git a/Frontend/src/features/auth/components/HousekeepingRoute.tsx b/Frontend/src/features/auth/components/HousekeepingRoute.tsx new file mode 100644 index 00000000..fbb51410 --- /dev/null +++ b/Frontend/src/features/auth/components/HousekeepingRoute.tsx @@ -0,0 +1,52 @@ +import React, { useEffect } from 'react'; +import { Navigate } from 'react-router-dom'; +import useAuthStore from '../../../store/useAuthStore'; +import { useAuthModal } from '../contexts/AuthModalContext'; + +interface HousekeepingRouteProps { + children: React.ReactNode; +} + +const HousekeepingRoute: React.FC = ({ children }) => { + const { isAuthenticated, userInfo, isLoading } = useAuthStore(); + const { openModal } = useAuthModal(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + openModal('login'); + } + }, [isLoading, isAuthenticated, openModal]); + + if (isLoading) { + return ( +
+
+
+

Authenticating...

+
+
+ ); + } + + if (!isAuthenticated) { + return null; // Modal will be shown by AuthModalManager + } + + // Only allow housekeeping role - no admin or staff access + if (userInfo?.role !== 'housekeeping') { + // Redirect to appropriate dashboard based on role + if (userInfo?.role === 'admin') { + return ; + } else if (userInfo?.role === 'staff') { + return ; + } else if (userInfo?.role === 'accountant') { + return ; + } + return ; + } + + return <>{children}; +}; + +export default HousekeepingRoute; + diff --git a/Frontend/src/features/auth/components/LoginModal.tsx b/Frontend/src/features/auth/components/LoginModal.tsx index 0a5b31c0..af75f25a 100644 --- a/Frontend/src/features/auth/components/LoginModal.tsx +++ b/Frontend/src/features/auth/components/LoginModal.tsx @@ -77,6 +77,8 @@ const LoginModal: React.FC = () => { navigate('/staff/dashboard', { replace: true }); } else if (role === 'accountant') { navigate('/accountant/dashboard', { replace: true }); + } else if (role === 'housekeeping') { + navigate('/housekeeping/dashboard', { replace: true }); } else { // Customer or default - go to customer dashboard navigate('/dashboard', { replace: true }); diff --git a/Frontend/src/features/auth/components/StaffRoute.tsx b/Frontend/src/features/auth/components/StaffRoute.tsx index 3d1ef361..76f3680f 100644 --- a/Frontend/src/features/auth/components/StaffRoute.tsx +++ b/Frontend/src/features/auth/components/StaffRoute.tsx @@ -52,6 +52,8 @@ const StaffRoute: React.FC = ({ return ; } else if (userInfo?.role === 'accountant') { return ; + } else if (userInfo?.role === 'housekeeping') { + return ; } return ; } diff --git a/Frontend/src/features/auth/components/index.ts b/Frontend/src/features/auth/components/index.ts index df5bec05..c60fdc76 100644 --- a/Frontend/src/features/auth/components/index.ts +++ b/Frontend/src/features/auth/components/index.ts @@ -3,4 +3,5 @@ export { default as AdminRoute } from './AdminRoute'; export { default as StaffRoute } from './StaffRoute'; export { default as AccountantRoute } from './AccountantRoute'; export { default as CustomerRoute } from './CustomerRoute'; +export { default as HousekeepingRoute } from './HousekeepingRoute'; export { default as ResetPasswordRouteHandler } from './ResetPasswordRouteHandler'; diff --git a/Frontend/src/features/hotel_services/components/HousekeepingManagement.tsx b/Frontend/src/features/hotel_services/components/HousekeepingManagement.tsx index 340d3b0c..eefac3b2 100644 --- a/Frontend/src/features/hotel_services/components/HousekeepingManagement.tsx +++ b/Frontend/src/features/hotel_services/components/HousekeepingManagement.tsx @@ -21,6 +21,7 @@ import 'react-datepicker/dist/react-datepicker.css'; const HousekeepingManagement: React.FC = () => { const { userInfo } = useAuthStore(); const isAdmin = userInfo?.role === 'admin'; + const isHousekeeping = userInfo?.role === 'housekeeping'; const [loading, setLoading] = useState(true); const [tasks, setTasks] = useState([]); const [rooms, setRooms] = useState([]); @@ -227,8 +228,8 @@ const HousekeepingManagement: React.FC = () => { } toast.success('Housekeeping task updated successfully'); } else { - // Only admin can create tasks - if (!isAdmin) { + // Only admin and staff can create tasks + if (!isAdmin && userInfo?.role !== 'staff') { toast.error('You do not have permission to create tasks'); return; } @@ -321,7 +322,7 @@ const HousekeepingManagement: React.FC = () => { className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
- {isAdmin && ( + {(isAdmin || userInfo?.role === 'staff') && ( ) : ( - // Staff can only edit their own assigned tasks - task.assigned_to === userInfo?.id && task.status !== 'completed' && ( + // Housekeeping and staff can only edit their own assigned tasks + (isHousekeeping || userInfo?.role === 'staff') && + task.assigned_to === userInfo?.id && + task.status !== 'completed' && ( <> + )} + + {/* Sidebar */} +
+
+ {/* Logo/Brand */} +
+
+ + Housekeeping +
+
+ + {/* Navigation */} + + + {/* User info and logout */} +
+
+

{userInfo?.name || userInfo?.email || 'User'}

+

{userInfo?.role || 'housekeeping'}

+
+ +
+
+
+ + {/* Overlay for mobile */} + {isMobile && sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Main content */} +
+
+ +
+
+
+ ); +}; + +export default HousekeepingLayout; + diff --git a/Frontend/src/pages/admin/AdvancedRoomManagementPage.tsx b/Frontend/src/pages/admin/AdvancedRoomManagementPage.tsx index 55a7e0a5..ce6d4a2c 100644 --- a/Frontend/src/pages/admin/AdvancedRoomManagementPage.tsx +++ b/Frontend/src/pages/admin/AdvancedRoomManagementPage.tsx @@ -13,54 +13,49 @@ import { ChevronUp, Calendar, MapPin, - Search, Plus, Edit, - Trash2, X, Image as ImageIcon, Check, } from 'lucide-react'; import { toast } from 'react-toastify'; import Loading from '../../shared/components/Loading'; -import advancedRoomService, { +import { RoomStatusBoardItem, } from '../../features/rooms/services/advancedRoomService'; import roomService, { Room } from '../../features/rooms/services/roomService'; import MaintenanceManagement from '../../features/hotel_services/components/MaintenanceManagement'; import HousekeepingManagement from '../../features/hotel_services/components/HousekeepingManagement'; import InspectionManagement from '../../features/hotel_services/components/InspectionManagement'; -import Pagination from '../../shared/components/Pagination'; import apiClient from '../../shared/services/apiClient'; -import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency'; import { logger } from '../../shared/utils/logger'; +import { useRoomContext } from '../../features/rooms/contexts/RoomContext'; -type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections' | 'rooms'; +type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections'; const AdvancedRoomManagementPage: React.FC = () => { - const { formatCurrency } = useFormatCurrency(); + const { + statusBoardRooms, + statusBoardLoading, + rooms: contextRooms, + refreshStatusBoard, + refreshRooms, + updateRoom: contextUpdateRoom, + deleteRoom: contextDeleteRoom, + createRoom: contextCreateRoom, + setStatusBoardFloor, + statusBoardFloor, + } = useRoomContext(); + const [activeTab, setActiveTab] = useState('status-board'); - const [loading, setLoading] = useState(true); - const [rooms, setRooms] = useState([]); const [selectedFloor, setSelectedFloor] = useState(null); const [floors, setFloors] = useState([]); const [expandedRooms, setExpandedRooms] = useState>(new Set()); // Rooms management state - const [roomList, setRoomList] = useState([]); - const [roomsLoading, setRoomsLoading] = useState(true); const [showRoomModal, setShowRoomModal] = useState(false); const [editingRoom, setEditingRoom] = useState(null); - const [selectedRooms, setSelectedRooms] = useState([]); - const [roomFilters, setRoomFilters] = useState({ - search: '', - status: '', - type: '', - }); - const [roomCurrentPage, setRoomCurrentPage] = useState(1); - const [roomTotalPages, setRoomTotalPages] = useState(1); - const [roomTotalItems, setRoomTotalItems] = useState(0); - const roomItemsPerPage = 5; const [roomFormData, setRoomFormData] = useState({ room_number: '', floor: 1, @@ -79,38 +74,79 @@ const AdvancedRoomManagementPage: React.FC = () => { const [uploadingImages, setUploadingImages] = useState(false); const [selectedFiles, setSelectedFiles] = useState([]); - useEffect(() => { - fetchRoomStatusBoard(); - fetchFloors(); - }, [selectedFloor]); - - const fetchRoomStatusBoard = async () => { + // Define fetchFloors before using it in useEffect + const fetchFloors = useCallback(async () => { try { - setLoading(true); - const response = await advancedRoomService.getRoomStatusBoard(selectedFloor || undefined); - if (response.status === 'success') { - setRooms(response.data.rooms); - } - } catch (error: any) { - toast.error(error.response?.data?.detail || 'Failed to fetch room status board'); - } finally { - setLoading(false); - } - }; - - const fetchFloors = async () => { - try { - const response = await roomService.getRooms({ limit: 1000, page: 1 }); + const response = await roomService.getRooms({ limit: 100, page: 1 }); if (response.data?.rooms) { const uniqueFloors = Array.from( - new Set(response.data.rooms.map((r: any) => r.floor).filter((f: any) => f != null)) - ).sort((a: any, b: any) => a - b) as number[]; + new Set(response.data.rooms.map((r: Room) => r.floor).filter((f: number | undefined) => f != null)) + ).sort((a: number, b: number) => a - b) as number[]; setFloors(uniqueFloors); } } catch (error) { logger.error('Failed to fetch floors', error); + toast.error('Failed to load floor information'); } - }; + }, []); + + // Sync selectedFloor with context + useEffect(() => { + setStatusBoardFloor(selectedFloor); + }, [selectedFloor, setStatusBoardFloor]); + + // Use rooms directly from context - no need for local state + + // Update selectedFloor when context changes + useEffect(() => { + if (statusBoardFloor !== selectedFloor) { + setSelectedFloor(statusBoardFloor); + } + }, [statusBoardFloor, selectedFloor]); + + // Refresh status board when floor filter changes + useEffect(() => { + let isMounted = true; + const abortController = new AbortController(); + + const refresh = async () => { + if (isMounted && !abortController.signal.aborted) { + await refreshStatusBoard(selectedFloor || undefined); + } + }; + + refresh(); + + return () => { + isMounted = false; + abortController.abort(); + }; + }, [selectedFloor, refreshStatusBoard]); + + useEffect(() => { + let isMounted = true; + const abortController = new AbortController(); + + const initializeData = async () => { + try { + await Promise.all([ + fetchFloors(), + refreshStatusBoard(), + ]); + } catch (error) { + if (isMounted && !abortController.signal.aborted) { + logger.error('Error initializing data', error); + } + } + }; + + initializeData(); + + return () => { + isMounted = false; + abortController.abort(); + }; + }, [refreshStatusBoard, fetchFloors]); const toggleRoomExpansion = (roomId: number) => { const newExpanded = new Set(expandedRooms); @@ -125,14 +161,14 @@ const AdvancedRoomManagementPage: React.FC = () => { // Group rooms by floor const roomsByFloor = useMemo(() => { const grouped: Record = {}; - rooms.forEach(room => { + statusBoardRooms.forEach(room => { if (!grouped[room.floor]) { grouped[room.floor] = []; } grouped[room.floor].push(room); }); return grouped; - }, [rooms]); + }, [statusBoardRooms]); const getStatusColor = (status: string) => { switch (status) { @@ -219,88 +255,20 @@ const AdvancedRoomManagementPage: React.FC = () => { } } catch (error) { logger.error('Failed to fetch amenities', error); + toast.error('Failed to load amenities'); } }, []); - const fetchRoomList = useCallback(async () => { + + // Fetch room types using dedicated endpoint + const fetchRoomTypes = useCallback(async () => { try { - setRoomsLoading(true); - const response = await roomService.getRooms({ - ...roomFilters, - page: roomCurrentPage, - limit: roomItemsPerPage, - }); - setRoomList(response.data.rooms); - if (response.data.pagination) { - setRoomTotalPages(response.data.pagination.totalPages); - setRoomTotalItems(response.data.pagination.total); - } - - const uniqueRoomTypes = new Map(); - response.data.rooms.forEach((room: Room) => { - if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) { - uniqueRoomTypes.set(room.room_type.id, { - id: room.room_type.id, - name: room.room_type.name, - }); - } - }); - setRoomTypes(Array.from(uniqueRoomTypes.values())); - } catch (error: any) { - toast.error(error.response?.data?.message || 'Unable to load rooms list'); - } finally { - setRoomsLoading(false); - } - }, [roomFilters.search, roomFilters.status, roomFilters.type, roomCurrentPage]); - - useEffect(() => { - setRoomCurrentPage(1); - setSelectedRooms([]); - }, [roomFilters.search, roomFilters.status, roomFilters.type]); - - useEffect(() => { - if (activeTab === 'rooms') { - fetchRoomList(); - fetchAvailableAmenities(); - } - }, [activeTab, fetchRoomList, fetchAvailableAmenities]); - - useEffect(() => { - if (activeTab !== 'rooms') return; - - const fetchAllRoomTypes = 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); - } - } - } - - const roomTypesList = Array.from(allUniqueRoomTypes.values()); + const response = await roomService.getRoomTypes(); + if (response.data?.room_types) { + const roomTypesList = response.data.room_types.map((rt: { id: number; name: string }) => ({ + id: rt.id, + name: rt.name, + })); setRoomTypes(roomTypesList); setRoomFormData(prev => { if (!editingRoom && prev.room_type_id === 1 && roomTypesList.length > 0) { @@ -308,16 +276,68 @@ const AdvancedRoomManagementPage: React.FC = () => { } return prev; }); + } + } catch (error) { + logger.error('Failed to fetch room types', error); + toast.error('Failed to load room types'); + } + }, [editingRoom]); + + useEffect(() => { + let isMounted = true; + const abortController = new AbortController(); + + const fetchData = async () => { + try { + await Promise.all([ + fetchAvailableAmenities(), + fetchRoomTypes(), + ]); } catch (error) { - logger.error('Failed to fetch room types', error); + if (isMounted && !abortController.signal.aborted) { + logger.error('Error fetching initial data', error); + } } }; - - fetchAllRoomTypes(); - }, [activeTab, editingRoom]); + + fetchData(); + + return () => { + isMounted = false; + abortController.abort(); + }; + }, [fetchAvailableAmenities, fetchRoomTypes]); + + // Frontend validation + const validateRoomForm = (): string | null => { + if (!roomFormData.room_number.trim()) { + return 'Room number is required'; + } + if (roomFormData.floor < 1) { + return 'Floor must be at least 1'; + } + if (roomFormData.room_type_id < 1) { + return 'Room type is required'; + } + if (roomFormData.price && parseFloat(roomFormData.price) < 0) { + return 'Price cannot be negative'; + } + if (roomFormData.capacity && parseInt(roomFormData.capacity) < 1) { + return 'Capacity must be at least 1'; + } + return null; + }; const handleRoomSubmit = async (e: React.FormEvent) => { e.preventDefault(); + + // Frontend validation + const validationError = validateRoomForm(); + if (validationError) { + toast.error(validationError); + return; + } + try { if (editingRoom) { const updateData = { @@ -329,14 +349,15 @@ const AdvancedRoomManagementPage: React.FC = () => { view: roomFormData.view || undefined, amenities: Array.isArray(roomFormData.amenities) ? roomFormData.amenities : [], }; - await roomService.updateRoom(editingRoom.id, updateData); - toast.success('Room updated successfully'); - await fetchRoomList(); + await contextUpdateRoom(editingRoom.id, updateData); + + // Refresh room details for editing try { const updatedRoom = await roomService.getRoomByNumber(editingRoom.room_number); setEditingRoom(updatedRoom.data.room); } catch (err) { logger.error('Failed to refresh room data', err); + toast.error('Room updated but failed to refresh details'); } } else { const createData = { @@ -349,7 +370,6 @@ const AdvancedRoomManagementPage: React.FC = () => { amenities: Array.isArray(roomFormData.amenities) ? roomFormData.amenities : [], }; const response = await roomService.createRoom(createData); - toast.success('Room added successfully'); if (response.data?.room) { if (selectedFiles.length > 0) { @@ -396,18 +416,41 @@ const AdvancedRoomManagementPage: React.FC = () => { amenities: response.data.room.amenities || [], }); - await fetchRoomList(); + await contextCreateRoom(createData); return; } } setShowRoomModal(false); resetRoomForm(); - fetchRoomList(); + await refreshRooms(); } catch (error: any) { toast.error(error.response?.data?.message || 'An error occurred'); } }; + // Handle edit from status board + const handleEditRoomFromStatusBoard = async (roomId: number) => { + try { + // Find the room in context rooms + const room = contextRooms.find(r => r.id === roomId); + if (room) { + await handleEditRoom(room); + } else { + // If not found in context, fetch it + const response = await roomService.getRoomById(roomId); + const foundRoom = response.data?.room; + if (foundRoom) { + await handleEditRoom(foundRoom); + } else { + toast.error('Room not found'); + } + } + } catch (error: any) { + logger.error('Error fetching room for edit', error); + toast.error('Failed to load room details'); + } + }; + const handleEditRoom = async (room: Room) => { setEditingRoom(room); @@ -478,6 +521,7 @@ const AdvancedRoomManagementPage: React.FC = () => { setEditingRoom(roomData); } catch (error) { logger.error('Failed to fetch full room details', error); + toast.error('Failed to load complete room details'); } }; @@ -485,46 +529,9 @@ const AdvancedRoomManagementPage: React.FC = () => { if (!window.confirm('Are you sure you want to delete this room?')) return; try { - await roomService.deleteRoom(id); - toast.success('Room deleted successfully'); - setSelectedRooms(selectedRooms.filter(roomId => roomId !== id)); - fetchRoomList(); + await contextDeleteRoom(id); } catch (error: any) { - toast.error(error.response?.data?.message || 'Unable to delete room'); - } - }; - - const handleBulkDeleteRooms = async () => { - if (selectedRooms.length === 0) { - toast.warning('Please select at least one room to delete'); - return; - } - - if (!window.confirm(`Are you sure you want to delete ${selectedRooms.length} room(s)?`)) return; - - try { - await roomService.bulkDeleteRooms(selectedRooms); - toast.success(`Successfully deleted ${selectedRooms.length} room(s)`); - setSelectedRooms([]); - fetchRoomList(); - } catch (error: any) { - toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to delete rooms'); - } - }; - - const handleSelectRoom = (roomId: number) => { - setSelectedRooms(prev => - prev.includes(roomId) - ? prev.filter(id => id !== roomId) - : [...prev, roomId] - ); - }; - - const handleSelectAllRooms = () => { - if (selectedRooms.length === roomList.length) { - setSelectedRooms([]); - } else { - setSelectedRooms(roomList.map(room => room.id)); + // Error already handled in context } }; @@ -581,7 +588,10 @@ const AdvancedRoomManagementPage: React.FC = () => { toast.success('Images uploaded successfully'); setSelectedFiles([]); - fetchRoomList(); + await Promise.all([ + refreshRooms(), + refreshStatusBoard(), + ]); const response = await roomService.getRoomByNumber(editingRoom.room_number); setEditingRoom(response.data.room); @@ -613,7 +623,10 @@ const AdvancedRoomManagementPage: React.FC = () => { }); toast.success('Image deleted successfully'); - fetchRoomList(); + await Promise.all([ + refreshRooms(), + refreshStatusBoard(), + ]); const response = await roomService.getRoomByNumber(editingRoom.room_number); setEditingRoom(response.data.room); @@ -652,7 +665,7 @@ const AdvancedRoomManagementPage: React.FC = () => { ); }; - if (loading && rooms.length === 0 && activeTab !== 'rooms') { + if (statusBoardLoading && statusBoardRooms.length === 0 && activeTab === 'status-board') { return ; } @@ -667,8 +680,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
- {rooms.length} rooms + {statusBoardRooms.length} rooms
- +
+ + +
{/* Floors Display */} @@ -761,26 +785,41 @@ const AdvancedRoomManagementPage: React.FC = () => { {/* Rooms Grid */}
{floorRooms.map((room) => { - const statusColors = getStatusColor(room.status); + // Determine effective status: if there are pending housekeeping tasks and room is available, show as cleaning + const effectiveStatus = room.pending_housekeeping_count > 0 && room.status === 'available' + ? 'cleaning' + : room.status; + const statusColors = getStatusColor(effectiveStatus); return (
toggleRoomExpansion(room.id)} > {/* Status Badge */} -
- {getStatusIcon(room.status)} - {getStatusLabel(room.status)} +
+ {getStatusIcon(effectiveStatus)} + {getStatusLabel(effectiveStatus)}
+ {/* Edit Button */} + + {/* Room Content */} -
+
toggleRoomExpansion(room.id)}>

{room.room_number}

@@ -794,16 +833,16 @@ const AdvancedRoomManagementPage: React.FC = () => { {/* Expanded Details */} {expandedRooms.has(room.id) && ( -
+
{room.current_booking && (
- + Guest
-

{room.current_booking.guest_name}

+

{room.current_booking.guest_name}

- + Check-out: {new Date(room.current_booking.check_out).toLocaleDateString()}
@@ -812,10 +851,10 @@ const AdvancedRoomManagementPage: React.FC = () => { {room.active_maintenance && (
- + Maintenance
-

{room.active_maintenance.title}

+

{room.active_maintenance.title}

{room.active_maintenance.type}

)} @@ -823,7 +862,7 @@ const AdvancedRoomManagementPage: React.FC = () => { {room.pending_housekeeping_count > 0 && (
- + Housekeeping

@@ -873,438 +912,266 @@ const AdvancedRoomManagementPage: React.FC = () => { {/* Inspections Tab */} {activeTab === 'inspections' && } - {/* Rooms Tab */} - {activeTab === 'rooms' && ( -

- {roomsLoading && } - -
-
-
-
-
- -
-

Room Management

-
-

- Manage hotel room information and availability + {/* Room Modal */} + {showRoomModal && ( +

+
+
+
+

+ {editingRoom ? 'Update Room' : 'Add New Room'} +

+

+ {editingRoom ? 'Modify room details and amenities' : 'Create a new luxurious room'}

-
- {selectedRooms.length > 0 && ( - - )} - -
-
-
- -
-
-
- - setRoomFilters({ ...roomFilters, search: e.target.value })} - className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-gray-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-gray-700 placeholder-gray-400 font-medium shadow-sm hover:shadow-md" - /> -
- - + +
-
- -
-
- - - - - - - - - - - - - - - {roomList.map((room) => ( - - - - - - - - - - - ))} - -
- 0 && selectedRooms.length === roomList.length} - onChange={handleSelectAllRooms} - title="Select all rooms" - className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 bg-slate-700 border-slate-600 rounded focus:ring-amber-500 cursor-pointer" - /> - Room NumberRoom TypeFloorPriceStatusFeaturedActions
- handleSelectRoom(room.id)} - className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 bg-white border-slate-300 rounded focus:ring-amber-500 cursor-pointer" - title={`Select room ${room.room_number}`} - /> - -
{room.room_number}
-
-
{room.room_type?.name || 'N/A'}
-
-
Floor {room.floor}
-
-
- {formatCurrency(room.price || room.room_type?.base_price || 0)} -
-
- {getRoomStatusBadge(room.status)} - - {room.featured ? ( - - ) : ( - - - )} - -
- - -
-
-
- -
- - {showRoomModal && ( -
-
-
+ +
+
+

+
+ Basic Information +

+ +
-

- {editingRoom ? 'Update Room' : 'Add New Room'} -

-

- {editingRoom ? 'Modify room details and amenities' : 'Create a new luxurious room'} -

+ + setRoomFormData({ ...roomFormData, room_number: e.target.value })} + className="w-full px-4 py-3 bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg text-white placeholder-gray-500 focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] transition-all duration-300" + placeholder="e.g., 1001" + required + /> +
+
+ + setRoomFormData({ ...roomFormData, floor: parseInt(e.target.value) })} + className="w-full px-4 py-3 bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg text-white placeholder-gray-500 focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] transition-all duration-300" + required + min="1" + />
-
- -
-

-
- Basic Information -

- -
-
- - setRoomFormData({ ...roomFormData, room_number: e.target.value })} - className="w-full px-4 py-3 bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg text-white placeholder-gray-500 focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] transition-all duration-300" - placeholder="e.g., 1001" - required - /> -
-
- - setRoomFormData({ ...roomFormData, floor: parseInt(e.target.value) })} - className="w-full px-4 py-3 bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg text-white placeholder-gray-500 focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] transition-all duration-300" - required - min="1" - /> -
-
- -
- - -
- -
- - -
- -
- setRoomFormData({ ...roomFormData, featured: e.target.checked })} - className="w-5 h-5 text-[#d4af37] bg-[#1a1a1a] border-[#d4af37]/30 rounded focus:ring-[#d4af37]/50 focus:ring-2 cursor-pointer transition-all" - /> - -
- -
- - setRoomFormData({ ...roomFormData, price: e.target.value })} - className="w-full px-4 py-3 bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg text-white placeholder-gray-500 focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] transition-all duration-300" - placeholder="e.g., 150.00" - /> -
- -
- -