updates
This commit is contained in:
Binary file not shown.
59
Backend/seeds_data/add_housekeeping_role.py
Normal file
59
Backend/seeds_data/add_housekeeping_role.py
Normal file
@@ -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!")
|
||||
81
Backend/seeds_data/add_housekeeping_user.py
Normal file
81
Backend/seeds_data/add_housekeeping_user.py
Normal file
@@ -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!")
|
||||
|
||||
149
Backend/seeds_data/assign_housekeeping_tasks.py
Normal file
149
Backend/seeds_data/assign_housekeeping_tasks.py
Normal file
@@ -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!")
|
||||
|
||||
@@ -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:
|
||||
|
||||
Binary file not shown.
@@ -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:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -538,6 +540,37 @@ async def update_housekeeping_task(
|
||||
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']
|
||||
if 'notes' in task_data:
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<CompanySettingsProvider>
|
||||
<AntibotProvider>
|
||||
<AuthModalProvider>
|
||||
<RoomProvider>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
@@ -709,6 +717,24 @@ function App() {
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Housekeeping Routes */}
|
||||
<Route
|
||||
path="/housekeeping"
|
||||
element={
|
||||
<ErrorBoundaryRoute>
|
||||
<HousekeepingRoute>
|
||||
<HousekeepingLayout />
|
||||
</HousekeepingRoute>
|
||||
</ErrorBoundaryRoute>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
index
|
||||
element={<Navigate to="dashboard" replace />}
|
||||
/>
|
||||
<Route path="dashboard" element={<HousekeepingDashboardPage />} />
|
||||
</Route>
|
||||
|
||||
{}
|
||||
<Route
|
||||
path="*"
|
||||
@@ -737,6 +763,7 @@ function App() {
|
||||
<AuthModalManager />
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</RoomProvider>
|
||||
</AuthModalProvider>
|
||||
</AntibotProvider>
|
||||
</CompanySettingsProvider>
|
||||
|
||||
@@ -62,6 +62,8 @@ const AdminRoute: React.FC<AdminRouteProps> = ({
|
||||
return <Navigate to="/staff/dashboard" replace />;
|
||||
} else if (userInfo?.role === 'accountant') {
|
||||
return <Navigate to="/accountant/dashboard" replace />;
|
||||
} else if (userInfo?.role === 'housekeeping') {
|
||||
return <Navigate to="/housekeeping/dashboard" replace />;
|
||||
}
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
52
Frontend/src/features/auth/components/HousekeepingRoute.tsx
Normal file
52
Frontend/src/features/auth/components/HousekeepingRoute.tsx
Normal file
@@ -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<HousekeepingRouteProps> = ({ children }) => {
|
||||
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
|
||||
const { openModal } = useAuthModal();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
openModal('login');
|
||||
}
|
||||
}, [isLoading, isAuthenticated, openModal]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto" />
|
||||
<p className="mt-4 text-gray-600">Authenticating...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <Navigate to="/admin/dashboard" replace />;
|
||||
} else if (userInfo?.role === 'staff') {
|
||||
return <Navigate to="/staff/dashboard" replace />;
|
||||
} else if (userInfo?.role === 'accountant') {
|
||||
return <Navigate to="/accountant/dashboard" replace />;
|
||||
}
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default HousekeepingRoute;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -52,6 +52,8 @@ const StaffRoute: React.FC<StaffRouteProps> = ({
|
||||
return <Navigate to="/admin/dashboard" replace />;
|
||||
} else if (userInfo?.role === 'accountant') {
|
||||
return <Navigate to="/accountant/dashboard" replace />;
|
||||
} else if (userInfo?.role === 'housekeeping') {
|
||||
return <Navigate to="/housekeeping/dashboard" replace />;
|
||||
}
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<HousekeepingTask[]>([]);
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
{(isAdmin || userInfo?.role === 'staff') && (
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
@@ -399,8 +400,10 @@ const HousekeepingManagement: React.FC = () => {
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
// 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' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleEdit(task)}
|
||||
|
||||
316
Frontend/src/features/rooms/contexts/RoomContext.tsx
Normal file
316
Frontend/src/features/rooms/contexts/RoomContext.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import roomService, { Room } from '../services/roomService';
|
||||
import advancedRoomService, { RoomStatusBoardItem } from '../services/advancedRoomService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { logger } from '../../../shared/utils/logger';
|
||||
|
||||
interface RoomContextType {
|
||||
// Room list state
|
||||
rooms: Room[];
|
||||
roomsLoading: boolean;
|
||||
roomsError: string | null;
|
||||
|
||||
// Room status board state
|
||||
statusBoardRooms: RoomStatusBoardItem[];
|
||||
statusBoardLoading: boolean;
|
||||
statusBoardError: string | null;
|
||||
|
||||
// Actions
|
||||
refreshRooms: () => Promise<void>;
|
||||
refreshStatusBoard: (floor?: number) => Promise<void>;
|
||||
updateRoom: (roomId: number, updates: Partial<Room>) => Promise<void>;
|
||||
deleteRoom: (roomId: number) => Promise<void>;
|
||||
createRoom: (roomData: Partial<Room> & { room_number: string; floor: number; room_type_id: number; status: 'available' | 'occupied' | 'maintenance' }) => Promise<void>;
|
||||
|
||||
// Filters and pagination
|
||||
setRoomFilters: (filters: { search?: string; status?: string; type?: string }) => void;
|
||||
roomFilters: { search: string; status: string; type: string };
|
||||
setRoomPage: (page: number) => void;
|
||||
roomPage: number;
|
||||
|
||||
// Status board filters
|
||||
setStatusBoardFloor: (floor: number | null) => void;
|
||||
statusBoardFloor: number | null;
|
||||
|
||||
// Last update timestamp for synchronization
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
const RoomContext = createContext<RoomContextType | undefined>(undefined);
|
||||
|
||||
export const useRoomContext = () => {
|
||||
const context = useContext(RoomContext);
|
||||
if (!context) {
|
||||
throw new Error('useRoomContext must be used within a RoomProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface RoomProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const RoomProvider: React.FC<RoomProviderProps> = ({ children }) => {
|
||||
// Room list state
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [roomsLoading, setRoomsLoading] = useState(false);
|
||||
const [roomsError, setRoomsError] = useState<string | null>(null);
|
||||
|
||||
// Room status board state
|
||||
const [statusBoardRooms, setStatusBoardRooms] = useState<RoomStatusBoardItem[]>([]);
|
||||
const [statusBoardLoading, setStatusBoardLoading] = useState(false);
|
||||
const [statusBoardError, setStatusBoardError] = useState<string | null>(null);
|
||||
|
||||
// Filters and pagination
|
||||
const [roomFilters, setRoomFiltersState] = useState({ search: '', status: '', type: '' });
|
||||
const [roomPage, setRoomPageState] = useState(1);
|
||||
const [statusBoardFloor, setStatusBoardFloorState] = useState<number | null>(null);
|
||||
|
||||
// Last update timestamp
|
||||
const [lastUpdate, setLastUpdate] = useState<number>(Date.now());
|
||||
|
||||
// Abort controllers for cleanup
|
||||
const roomsAbortRef = useRef<AbortController | null>(null);
|
||||
const statusBoardAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Auto-refresh interval (30 seconds)
|
||||
const autoRefreshIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Refresh rooms list - fetch all rooms for better sync across components
|
||||
const refreshRooms = useCallback(async () => {
|
||||
// Cancel previous request
|
||||
if (roomsAbortRef.current) {
|
||||
roomsAbortRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
roomsAbortRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
setRoomsLoading(true);
|
||||
setRoomsError(null);
|
||||
|
||||
// Fetch rooms with reasonable pagination
|
||||
// Individual components can handle their own pagination/filtering
|
||||
const response = await roomService.getRooms({
|
||||
limit: 100, // Reasonable batch size for sync
|
||||
page: 1,
|
||||
});
|
||||
|
||||
if (response.data?.rooms) {
|
||||
setRooms(response.data.rooms);
|
||||
setLastUpdate(Date.now());
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error refreshing rooms', error);
|
||||
setRoomsError(error.response?.data?.message || 'Failed to refresh rooms');
|
||||
// Don't show toast on every auto-refresh, only on manual refresh
|
||||
} finally {
|
||||
setRoomsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Refresh status board
|
||||
const refreshStatusBoard = useCallback(async (floor?: number) => {
|
||||
// Cancel previous request
|
||||
if (statusBoardAbortRef.current) {
|
||||
statusBoardAbortRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
statusBoardAbortRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
setStatusBoardLoading(true);
|
||||
setStatusBoardError(null);
|
||||
|
||||
const response = await advancedRoomService.getRoomStatusBoard(floor || statusBoardFloor || undefined);
|
||||
|
||||
if (response.status === 'success' && response.data?.rooms) {
|
||||
setStatusBoardRooms(response.data.rooms);
|
||||
setLastUpdate(Date.now());
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle 401 Unauthorized gracefully - user may not have admin/staff role
|
||||
if (error.response?.status === 401) {
|
||||
setStatusBoardError(null); // Don't set error for unauthorized access
|
||||
setStatusBoardRooms([]); // Clear status board if unauthorized
|
||||
return; // Silently return without logging
|
||||
}
|
||||
|
||||
logger.error('Error refreshing status board', error);
|
||||
setStatusBoardError(error.response?.data?.detail || 'Failed to refresh status board');
|
||||
// Don't show toast on every auto-refresh, only on manual refresh
|
||||
} finally {
|
||||
setStatusBoardLoading(false);
|
||||
}
|
||||
}, [statusBoardFloor]);
|
||||
|
||||
// Update room
|
||||
const updateRoom = useCallback(async (roomId: number, updates: Partial<Room>) => {
|
||||
try {
|
||||
await roomService.updateRoom(roomId, updates);
|
||||
toast.success('Room updated successfully');
|
||||
|
||||
// Refresh both views to ensure they're in sync
|
||||
await Promise.all([
|
||||
refreshRooms(),
|
||||
refreshStatusBoard(),
|
||||
]);
|
||||
} catch (error: any) {
|
||||
logger.error('Error updating room', error);
|
||||
toast.error(error.response?.data?.message || 'Failed to update room');
|
||||
throw error;
|
||||
}
|
||||
}, [refreshRooms, refreshStatusBoard]);
|
||||
|
||||
// Delete room
|
||||
const deleteRoom = useCallback(async (roomId: number) => {
|
||||
try {
|
||||
await roomService.deleteRoom(roomId);
|
||||
toast.success('Room deleted successfully');
|
||||
|
||||
// Refresh both views
|
||||
await Promise.all([
|
||||
refreshRooms(),
|
||||
refreshStatusBoard(),
|
||||
]);
|
||||
} catch (error: any) {
|
||||
logger.error('Error deleting room', error);
|
||||
toast.error(error.response?.data?.message || 'Failed to delete room');
|
||||
throw error;
|
||||
}
|
||||
}, [refreshRooms, refreshStatusBoard]);
|
||||
|
||||
// Create room
|
||||
const createRoom = useCallback(async (roomData: Partial<Room> & { room_number: string; floor: number; room_type_id: number; status: 'available' | 'occupied' | 'maintenance' }) => {
|
||||
try {
|
||||
await roomService.createRoom(roomData as any);
|
||||
toast.success('Room created successfully');
|
||||
|
||||
// Refresh both views
|
||||
await Promise.all([
|
||||
refreshRooms(),
|
||||
refreshStatusBoard(),
|
||||
]);
|
||||
} catch (error: any) {
|
||||
logger.error('Error creating room', error);
|
||||
toast.error(error.response?.data?.message || 'Failed to create room');
|
||||
throw error;
|
||||
}
|
||||
}, [refreshRooms, refreshStatusBoard]);
|
||||
|
||||
// Set room filters
|
||||
const setRoomFilters = useCallback((filters: { search?: string; status?: string; type?: string }) => {
|
||||
setRoomFiltersState(prev => ({ ...prev, ...filters }));
|
||||
setRoomPageState(1); // Reset to first page when filters change
|
||||
}, []);
|
||||
|
||||
// Set room page
|
||||
const setRoomPage = useCallback((page: number) => {
|
||||
setRoomPageState(page);
|
||||
}, []);
|
||||
|
||||
// Set status board floor
|
||||
const setStatusBoardFloor = useCallback((floor: number | null) => {
|
||||
setStatusBoardFloorState(floor);
|
||||
}, []);
|
||||
|
||||
// Initial load - only fetch rooms, status board will be fetched when needed
|
||||
useEffect(() => {
|
||||
refreshRooms();
|
||||
// Don't fetch status board on initial load - it requires admin/staff role
|
||||
// It will be fetched when the component that needs it mounts
|
||||
}, []);
|
||||
|
||||
// Initial load and periodic refresh handled by auto-refresh interval
|
||||
|
||||
// Auto-refresh status board when floor changes (only if component using it is mounted)
|
||||
// This will be called by AdvancedRoomManagementPage when floor filter changes
|
||||
|
||||
// Set up auto-refresh interval (every 60 seconds)
|
||||
// Pause when tab is inactive to save resources
|
||||
useEffect(() => {
|
||||
let intervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
const startRefresh = () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
intervalId = setInterval(() => {
|
||||
if (!document.hidden) {
|
||||
refreshRooms();
|
||||
}
|
||||
}, 60000); // 60 seconds
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
// Pause refresh when tab is hidden
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
} else {
|
||||
// Resume refresh when tab becomes visible
|
||||
startRefresh();
|
||||
// Immediate refresh when tab becomes visible
|
||||
refreshRooms();
|
||||
}
|
||||
};
|
||||
|
||||
startRefresh();
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [refreshRooms]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (roomsAbortRef.current) {
|
||||
roomsAbortRef.current.abort();
|
||||
}
|
||||
if (statusBoardAbortRef.current) {
|
||||
statusBoardAbortRef.current.abort();
|
||||
}
|
||||
if (autoRefreshIntervalRef.current) {
|
||||
clearInterval(autoRefreshIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value: RoomContextType = {
|
||||
rooms,
|
||||
roomsLoading,
|
||||
roomsError,
|
||||
statusBoardRooms,
|
||||
statusBoardLoading,
|
||||
statusBoardError,
|
||||
refreshRooms,
|
||||
refreshStatusBoard,
|
||||
updateRoom,
|
||||
deleteRoom,
|
||||
createRoom,
|
||||
setRoomFilters,
|
||||
roomFilters,
|
||||
setRoomPage,
|
||||
roomPage,
|
||||
setStatusBoardFloor,
|
||||
statusBoardFloor,
|
||||
lastUpdate,
|
||||
};
|
||||
|
||||
return <RoomContext.Provider value={value}>{children}</RoomContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -169,6 +169,28 @@ export const getAmenities = async (): Promise<{
|
||||
};
|
||||
};
|
||||
|
||||
export interface RoomType {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
base_price: number;
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
export const getRoomTypes = async (): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
data: { room_types: RoomType[] };
|
||||
}> => {
|
||||
const response = await apiClient.get('/rooms/room-types');
|
||||
const data = response.data;
|
||||
return {
|
||||
success: data.status === 'success' || data.success === true,
|
||||
status: data.status,
|
||||
data: data.data || { room_types: [] },
|
||||
};
|
||||
};
|
||||
|
||||
export interface CreateRoomData {
|
||||
room_number: string;
|
||||
floor: number;
|
||||
@@ -242,6 +264,7 @@ export default {
|
||||
getRoomByNumber,
|
||||
searchAvailableRooms,
|
||||
getAmenities,
|
||||
getRoomTypes,
|
||||
createRoom,
|
||||
updateRoom,
|
||||
deleteRoom,
|
||||
|
||||
123
Frontend/src/pages/HousekeepingLayout.tsx
Normal file
123
Frontend/src/pages/HousekeepingLayout.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../store/useAuthStore';
|
||||
import { useResponsive } from '../shared/hooks/useResponsive';
|
||||
|
||||
const HousekeepingLayout: React.FC = () => {
|
||||
const { isMobile } = useResponsive();
|
||||
const [sidebarOpen, setSidebarOpen] = React.useState(!isMobile);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { userInfo, logout } = useAuthStore();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/housekeeping/dashboard', icon: LayoutDashboard },
|
||||
];
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
{/* Mobile menu button */}
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="fixed top-4 left-4 z-50 p-2 bg-white rounded-lg shadow-lg"
|
||||
>
|
||||
{sidebarOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`
|
||||
fixed lg:static inset-y-0 left-0 z-40
|
||||
w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Logo/Brand */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<LayoutDashboard className="w-8 h-8 text-blue-600" />
|
||||
<span className="text-xl font-bold text-gray-900">Housekeeping</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-6 space-y-2">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
onClick={() => isMobile && setSidebarOpen(false)}
|
||||
className={`
|
||||
flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors
|
||||
${isActive(item.href)
|
||||
? 'bg-blue-50 text-blue-700 font-medium'
|
||||
: 'text-gray-700 hover:bg-gray-50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User info and logout */}
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<div className="mb-3 px-4 py-2">
|
||||
<p className="text-sm font-medium text-gray-900">{userInfo?.name || userInfo?.email || 'User'}</p>
|
||||
<p className="text-xs text-gray-500 capitalize">{userInfo?.role || 'housekeeping'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center space-x-3 px-4 py-2 text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-30"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 overflow-auto lg:ml-0">
|
||||
<div className={`min-h-screen ${isMobile ? 'pt-20' : 'pt-0'}`}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HousekeepingLayout;
|
||||
|
||||
@@ -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<Tab>('status-board');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rooms, setRooms] = useState<RoomStatusBoardItem[]>([]);
|
||||
const [selectedFloor, setSelectedFloor] = useState<number | null>(null);
|
||||
const [floors, setFloors] = useState<number[]>([]);
|
||||
const [expandedRooms, setExpandedRooms] = useState<Set<number>>(new Set());
|
||||
|
||||
// Rooms management state
|
||||
const [roomList, setRoomList] = useState<Room[]>([]);
|
||||
const [roomsLoading, setRoomsLoading] = useState(true);
|
||||
const [showRoomModal, setShowRoomModal] = useState(false);
|
||||
const [editingRoom, setEditingRoom] = useState<Room | null>(null);
|
||||
const [selectedRooms, setSelectedRooms] = useState<number[]>([]);
|
||||
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,39 +74,80 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
const [uploadingImages, setUploadingImages] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
|
||||
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);
|
||||
if (newExpanded.has(roomId)) {
|
||||
@@ -125,14 +161,14 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
// Group rooms by floor
|
||||
const roomsByFloor = useMemo(() => {
|
||||
const grouped: Record<number, RoomStatusBoardItem[]> = {};
|
||||
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<number, { id: number; name: string }>();
|
||||
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<number, { id: number; name: string }>();
|
||||
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) {
|
||||
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 <Loading />;
|
||||
}
|
||||
|
||||
@@ -667,8 +680,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
<div className="mb-6 border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{[
|
||||
{ id: 'status-board' as Tab, label: 'Room Status Board', icon: Hotel },
|
||||
{ id: 'rooms' as Tab, label: 'Rooms', icon: Hotel },
|
||||
{ id: 'status-board' as Tab, label: 'Rooms & Status Board', icon: Hotel },
|
||||
{ id: 'maintenance' as Tab, label: 'Maintenance', icon: Wrench },
|
||||
{ id: 'housekeeping' as Tab, label: 'Housekeeping', icon: Sparkles },
|
||||
{ id: 'inspections' as Tab, label: 'Inspections', icon: ClipboardCheck },
|
||||
@@ -717,17 +729,29 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
<span className="font-semibold text-slate-900">{rooms.length}</span> rooms
|
||||
<span className="font-semibold text-slate-900">{statusBoardRooms.length}</span> rooms
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={fetchRoomStatusBoard}
|
||||
onClick={() => {
|
||||
resetRoomForm();
|
||||
setShowRoomModal(true);
|
||||
}}
|
||||
className="flex items-center space-x-2 px-5 py-2.5 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add Room</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => refreshStatusBoard(selectedFloor || undefined)}
|
||||
className="flex items-center space-x-2 px-5 py-2.5 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg hover:from-blue-700 hover:to-indigo-700 transition-all duration-200 shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floors Display */}
|
||||
{Object.keys(roomsByFloor).length === 0 ? (
|
||||
@@ -761,26 +785,41 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
{/* Rooms Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{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 (
|
||||
<div
|
||||
key={room.id}
|
||||
className={`
|
||||
group relative overflow-hidden rounded-xl border-2 transition-all duration-300
|
||||
${statusColors.bg} ${statusColors.border}
|
||||
hover:shadow-xl hover:scale-[1.02] cursor-pointer
|
||||
hover:shadow-xl hover:scale-[1.02]
|
||||
${expandedRooms.has(room.id) ? 'shadow-lg' : 'shadow-md'}
|
||||
`}
|
||||
onClick={() => toggleRoomExpansion(room.id)}
|
||||
>
|
||||
{/* Status Badge */}
|
||||
<div className={`absolute top-3 right-3 px-3 py-1 rounded-full text-xs font-semibold shadow-lg ${statusColors.badge} flex items-center space-x-1.5`}>
|
||||
{getStatusIcon(room.status)}
|
||||
<span>{getStatusLabel(room.status)}</span>
|
||||
<div className={`absolute top-3 right-3 px-3 py-1 rounded-full text-xs font-semibold shadow-lg ${statusColors.badge} flex items-center space-x-1.5 z-20`}>
|
||||
{getStatusIcon(effectiveStatus)}
|
||||
<span>{getStatusLabel(effectiveStatus)}</span>
|
||||
</div>
|
||||
|
||||
{/* Edit Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditRoomFromStatusBoard(room.id);
|
||||
}}
|
||||
className="absolute top-3 left-3 p-2 bg-white/90 backdrop-blur-sm rounded-lg text-blue-600 hover:text-blue-700 hover:bg-white transition-all duration-200 shadow-md hover:shadow-lg border border-blue-200 hover:border-blue-300 z-20"
|
||||
title="Edit Room"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Room Content */}
|
||||
<div className="p-5 pt-4">
|
||||
<div className="p-5 pt-16 cursor-pointer" onClick={() => toggleRoomExpansion(room.id)}>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-2xl font-bold text-slate-900 font-mono">{room.room_number}</h3>
|
||||
@@ -794,16 +833,16 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedRooms.has(room.id) && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-300/30 space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="mt-4 pt-4 border-t border-slate-300/30 space-y-3 max-h-96 overflow-y-auto animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
{room.current_booking && (
|
||||
<div className="bg-white/60 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center space-x-2 text-sm font-semibold text-slate-700">
|
||||
<Users className="w-4 h-4" />
|
||||
<Users className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Guest</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-900">{room.current_booking.guest_name}</p>
|
||||
<p className="text-sm font-medium text-slate-900 break-words">{room.current_booking.guest_name}</p>
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<Calendar className="w-3 h-3 flex-shrink-0" />
|
||||
<span>Check-out: {new Date(room.current_booking.check_out).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -812,10 +851,10 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
{room.active_maintenance && (
|
||||
<div className="bg-red-50/80 rounded-lg p-3 space-y-2 border border-red-200/50">
|
||||
<div className="flex items-center space-x-2 text-sm font-semibold text-red-800">
|
||||
<Wrench className="w-4 h-4" />
|
||||
<Wrench className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Maintenance</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-900">{room.active_maintenance.title}</p>
|
||||
<p className="text-sm font-medium text-red-900 break-words">{room.active_maintenance.title}</p>
|
||||
<p className="text-xs text-red-700 capitalize">{room.active_maintenance.type}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -823,7 +862,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
{room.pending_housekeeping_count > 0 && (
|
||||
<div className="bg-amber-50/80 rounded-lg p-3 space-y-2 border border-amber-200/50">
|
||||
<div className="flex items-center space-x-2 text-sm font-semibold text-amber-800">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<Sparkles className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Housekeeping</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-amber-900">
|
||||
@@ -873,181 +912,9 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
{/* Inspections Tab */}
|
||||
{activeTab === 'inspections' && <InspectionManagement />}
|
||||
|
||||
{/* Rooms Tab */}
|
||||
{activeTab === 'rooms' && (
|
||||
<div className="space-y-8">
|
||||
{roomsLoading && <Loading />}
|
||||
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-gradient-to-br from-amber-500/10 to-yellow-500/10 border border-amber-200/40">
|
||||
<Hotel className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl md:text-2xl font-extrabold text-gray-900">Room Management</h2>
|
||||
</div>
|
||||
<p className="text-gray-600 text-base max-w-2xl leading-relaxed">
|
||||
Manage hotel room information and availability
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{selectedRooms.length > 0 && (
|
||||
<button
|
||||
onClick={handleBulkDeleteRooms}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-rose-500 to-rose-600 text-white rounded-xl font-semibold hover:from-rose-600 hover:to-rose-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
Delete Selected ({selectedRooms.length})
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
resetRoomForm();
|
||||
setShowRoomModal(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search rooms..."
|
||||
value={roomFilters.search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={roomFilters.status}
|
||||
onChange={(e) => setRoomFilters({ ...roomFilters, status: e.target.value })}
|
||||
className="px-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 font-medium shadow-sm hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="available">Available</option>
|
||||
<option value="occupied">Occupied</option>
|
||||
<option value="maintenance">Maintenance</option>
|
||||
</select>
|
||||
<select
|
||||
value={roomFilters.type}
|
||||
onChange={(e) => setRoomFilters({ ...roomFilters, type: e.target.value })}
|
||||
className="px-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 font-medium shadow-sm hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<option value="">All Room Types</option>
|
||||
{roomTypes.map((roomType) => (
|
||||
<option key={roomType.id} value={roomType.name}>
|
||||
{roomType.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-xl sm:rounded-2xl shadow-xl border border-gray-200/50 overflow-hidden">
|
||||
<div className="overflow-x-auto -mx-2 sm:mx-0 px-2 sm:px-0">
|
||||
<table className="w-full min-w-[800px] sm:min-w-full">
|
||||
<thead>
|
||||
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700 w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={roomList.length > 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"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Room Number</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Room Type</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Floor</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Price</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Status</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Featured</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-100">
|
||||
{roomList.map((room) => (
|
||||
<tr
|
||||
key={room.id}
|
||||
className={`hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100 ${selectedRooms.includes(room.id) ? 'bg-amber-50/50' : ''}`}
|
||||
>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRooms.includes(room.id)}
|
||||
onChange={() => 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}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
<div className="text-xs sm:text-sm font-bold text-gray-900 font-mono">{room.room_number}</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
<div className="text-xs sm:text-sm font-medium text-gray-900">{room.room_type?.name || 'N/A'}</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
<div className="text-xs sm:text-sm text-gray-700">Floor {room.floor}</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
<div className="text-xs sm:text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||
{formatCurrency(room.price || room.room_type?.base_price || 0)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
{getRoomStatusBadge(room.status)}
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
{room.featured ? (
|
||||
<span className="text-amber-500 text-base sm:text-lg">⭐</span>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-1 sm:gap-2">
|
||||
<button
|
||||
onClick={() => handleEditRoom(room)}
|
||||
className="p-1.5 sm:p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all duration-200 shadow-sm hover:shadow-md border border-blue-200 hover:border-blue-300"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteRoom(room.id)}
|
||||
className="p-1.5 sm:p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={roomCurrentPage}
|
||||
totalPages={roomTotalPages}
|
||||
onPageChange={setRoomCurrentPage}
|
||||
totalItems={roomTotalItems}
|
||||
itemsPerPage={roomItemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Room Modal */}
|
||||
{showRoomModal && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-[100] p-4">
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] rounded-xl border border-[#d4af37]/30 backdrop-blur-xl shadow-2xl shadow-[#d4af37]/20 p-8 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6 pb-6 border-b border-[#d4af37]/20">
|
||||
<div>
|
||||
@@ -1130,7 +997,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
</label>
|
||||
<select
|
||||
value={roomFormData.status}
|
||||
onChange={(e) => setRoomFormData({ ...roomFormData, status: e.target.value as any })}
|
||||
onChange={(e) => setRoomFormData({ ...roomFormData, status: e.target.value as 'available' | 'occupied' | 'maintenance' })}
|
||||
className="w-full px-4 py-3 bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg text-white focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] transition-all duration-300"
|
||||
required
|
||||
>
|
||||
@@ -1467,8 +1334,6 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, X, Upload, Image as ImageIcon, Check } from 'lucide-react';
|
||||
import roomService, { Room } from '../../features/rooms/services/roomService';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -7,9 +7,22 @@ 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';
|
||||
|
||||
const RoomManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const {
|
||||
rooms: contextRooms,
|
||||
roomsLoading,
|
||||
refreshRooms,
|
||||
updateRoom: contextUpdateRoom,
|
||||
deleteRoom: contextDeleteRoom,
|
||||
createRoom: contextCreateRoom,
|
||||
setRoomFilters,
|
||||
setRoomPage,
|
||||
} = useRoomContext();
|
||||
|
||||
// Use context rooms, but filter/paginate locally for this page's display
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -43,7 +56,45 @@ const RoomManagementPage: React.FC = () => {
|
||||
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
|
||||
const [uploadingImages, setUploadingImages] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Sync local filters with context
|
||||
useEffect(() => {
|
||||
setRoomFilters(filters);
|
||||
}, [filters, setRoomFilters]);
|
||||
|
||||
// Sync local page with context
|
||||
useEffect(() => {
|
||||
setRoomPage(currentPage);
|
||||
}, [currentPage, setRoomPage]);
|
||||
|
||||
// Update local rooms from context and apply local pagination
|
||||
useEffect(() => {
|
||||
if (contextRooms.length > 0) {
|
||||
// Apply local filters
|
||||
let filteredRooms = contextRooms.filter((room) => {
|
||||
const matchesSearch = !filters.search ||
|
||||
room.room_number.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||
room.room_type?.name.toLowerCase().includes(filters.search.toLowerCase());
|
||||
const matchesStatus = !filters.status || room.status === filters.status;
|
||||
const matchesType = !filters.type || room.room_type?.name === filters.type;
|
||||
return matchesSearch && matchesStatus && matchesType;
|
||||
});
|
||||
|
||||
// Apply pagination
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedRooms = filteredRooms.slice(startIndex, endIndex);
|
||||
|
||||
setRooms(paginatedRooms);
|
||||
setTotalPages(Math.ceil(filteredRooms.length / itemsPerPage));
|
||||
setTotalItems(filteredRooms.length);
|
||||
setLoading(false);
|
||||
} else if (!roomsLoading) {
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoading(roomsLoading);
|
||||
}
|
||||
}, [contextRooms, filters, currentPage, itemsPerPage, roomsLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
@@ -51,24 +102,8 @@ const RoomManagementPage: React.FC = () => {
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchRooms();
|
||||
fetchAvailableAmenities();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [filters, currentPage]);
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -132,23 +167,11 @@ const RoomManagementPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await roomService.getRooms({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setRooms(response.data.rooms);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
|
||||
|
||||
// Extract unique room types from context rooms
|
||||
useEffect(() => {
|
||||
if (contextRooms.length > 0) {
|
||||
const uniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
||||
response.data.rooms.forEach((room: Room) => {
|
||||
contextRooms.forEach((room: Room) => {
|
||||
if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) {
|
||||
uniqueRoomTypes.set(room.room_type.id, {
|
||||
id: room.room_type.id,
|
||||
@@ -157,60 +180,8 @@ const RoomManagementPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
setRoomTypes(Array.from(uniqueRoomTypes.values()));
|
||||
|
||||
|
||||
if (roomTypes.length === 0 && response.data.pagination) {
|
||||
try {
|
||||
|
||||
const allRoomsResponse = await roomService.getRooms({ limit: 100, page: 1 });
|
||||
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
||||
allRoomsResponse.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 (allRoomsResponse.data.pagination && allRoomsResponse.data.pagination.totalPages > 1) {
|
||||
const totalPages = allRoomsResponse.data.pagination.totalPages;
|
||||
for (let page = 2; page <= Math.min(totalPages, 10); 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) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allUniqueRoomTypes.size > 0) {
|
||||
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle AbortError silently
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching rooms', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to load rooms list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [contextRooms]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -226,13 +197,9 @@ const RoomManagementPage: React.FC = () => {
|
||||
view: formData.view || undefined,
|
||||
amenities: Array.isArray(formData.amenities) ? formData.amenities : [],
|
||||
};
|
||||
await roomService.updateRoom(editingRoom.id, updateData);
|
||||
toast.success('Room updated successfully');
|
||||
|
||||
|
||||
await fetchRooms();
|
||||
|
||||
await contextUpdateRoom(editingRoom.id, updateData);
|
||||
|
||||
// Refresh room details for editing
|
||||
try {
|
||||
const updatedRoom = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
@@ -251,8 +218,6 @@ const RoomManagementPage: React.FC = () => {
|
||||
amenities: Array.isArray(formData.amenities) ? formData.amenities : [],
|
||||
};
|
||||
const response = await roomService.createRoom(createData);
|
||||
toast.success('Room added successfully');
|
||||
|
||||
|
||||
if (response.data?.room) {
|
||||
|
||||
@@ -307,7 +272,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
});
|
||||
|
||||
|
||||
await fetchRooms();
|
||||
await contextCreateRoom(createData);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -315,7 +280,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
fetchRooms();
|
||||
await refreshRooms();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'An error occurred');
|
||||
}
|
||||
@@ -406,12 +371,10 @@ const RoomManagementPage: 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');
|
||||
await contextDeleteRoom(id);
|
||||
setSelectedRooms(selectedRooms.filter(roomId => roomId !== id));
|
||||
fetchRooms();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to delete room');
|
||||
// Error already handled in context
|
||||
}
|
||||
};
|
||||
|
||||
@@ -427,7 +390,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
await roomService.bulkDeleteRooms(selectedRooms);
|
||||
toast.success(`Successfully deleted ${selectedRooms.length} room(s)`);
|
||||
setSelectedRooms([]);
|
||||
fetchRooms();
|
||||
await refreshRooms();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to delete rooms');
|
||||
}
|
||||
@@ -502,7 +465,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
|
||||
toast.success('Images uploaded successfully');
|
||||
setSelectedFiles([]);
|
||||
fetchRooms();
|
||||
await refreshRooms();
|
||||
|
||||
|
||||
const response = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||
@@ -540,7 +503,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
});
|
||||
|
||||
toast.success('Image deleted successfully');
|
||||
fetchRooms();
|
||||
await refreshRooms();
|
||||
|
||||
|
||||
const response = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, X } from 'lucide-react';
|
||||
import userService, { User } from '../../features/auth/services/userService';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -199,6 +199,18 @@ const UserManagementPage: React.FC = () => {
|
||||
label: 'Staff',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
accountant: {
|
||||
bg: 'bg-gradient-to-r from-purple-50 to-violet-50',
|
||||
text: 'text-purple-800',
|
||||
label: 'Accountant',
|
||||
border: 'border-purple-200'
|
||||
},
|
||||
housekeeping: {
|
||||
bg: 'bg-gradient-to-r from-amber-50 to-orange-50',
|
||||
text: 'text-amber-800',
|
||||
label: 'Housekeeping',
|
||||
border: 'border-amber-200'
|
||||
},
|
||||
customer: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
@@ -264,6 +276,8 @@ const UserManagementPage: React.FC = () => {
|
||||
<option value="">All roles</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="staff">Staff</option>
|
||||
<option value="accountant">Accountant</option>
|
||||
<option value="housekeeping">Housekeeping</option>
|
||||
<option value="customer">Customer</option>
|
||||
</select>
|
||||
<select
|
||||
@@ -458,6 +472,7 @@ const UserManagementPage: React.FC = () => {
|
||||
<option value="customer">Customer</option>
|
||||
<option value="staff">Staff</option>
|
||||
<option value="accountant">Accountant</option>
|
||||
<option value="housekeeping">Housekeeping</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
505
Frontend/src/pages/housekeeping/DashboardPage.tsx
Normal file
505
Frontend/src/pages/housekeeping/DashboardPage.tsx
Normal file
@@ -0,0 +1,505 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
MapPin,
|
||||
Play,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
import advancedRoomService, { HousekeepingTask, ChecklistItem } from '../../features/rooms/services/advancedRoomService';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
|
||||
const HousekeepingDashboardPage: React.FC = () => {
|
||||
const { userInfo } = useAuthStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tasks, setTasks] = useState<HousekeepingTask[]>([]);
|
||||
const [stats, setStats] = useState({
|
||||
pending: 0,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
total: 0,
|
||||
});
|
||||
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
|
||||
const [updatingTasks, setUpdatingTasks] = useState<Set<number>>(new Set());
|
||||
const tasksAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
// Cancel previous request if exists
|
||||
if (tasksAbortRef.current) {
|
||||
tasksAbortRef.current.abort();
|
||||
}
|
||||
|
||||
tasksAbortRef.current = new AbortController();
|
||||
setLoading(true);
|
||||
|
||||
// Fetch today's tasks assigned to current user
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const response = await advancedRoomService.getHousekeepingTasks({
|
||||
date: today,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
if (response.status === 'success' && response.data?.tasks) {
|
||||
const userTasks = response.data.tasks.filter(
|
||||
(task: HousekeepingTask) => task.assigned_to === userInfo?.id
|
||||
);
|
||||
setTasks(userTasks);
|
||||
|
||||
// Calculate stats
|
||||
const pending = userTasks.filter((t: HousekeepingTask) => t.status === 'pending').length;
|
||||
const in_progress = userTasks.filter((t: HousekeepingTask) => t.status === 'in_progress').length;
|
||||
const completed = userTasks.filter((t: HousekeepingTask) => t.status === 'completed').length;
|
||||
|
||||
setStats({
|
||||
pending,
|
||||
in_progress,
|
||||
completed,
|
||||
total: userTasks.length,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching housekeeping tasks', error);
|
||||
toast.error('Failed to load tasks');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
const interval = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
fetchTasks();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
if (tasksAbortRef.current) {
|
||||
tasksAbortRef.current.abort();
|
||||
}
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [userInfo?.id]);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'in_progress':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="w-5 h-5" />;
|
||||
case 'in_progress':
|
||||
return <Clock className="w-5 h-5" />;
|
||||
case 'pending':
|
||||
return <AlertCircle className="w-5 h-5" />;
|
||||
default:
|
||||
return <Clock className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTaskExpansion = (taskId: number) => {
|
||||
setExpandedTasks(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(taskId)) {
|
||||
newSet.delete(taskId);
|
||||
} else {
|
||||
newSet.add(taskId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleStartTask = async (task: HousekeepingTask) => {
|
||||
if (updatingTasks.has(task.id)) return;
|
||||
|
||||
setUpdatingTasks(prev => new Set(prev).add(task.id));
|
||||
try {
|
||||
await advancedRoomService.updateHousekeepingTask(task.id, {
|
||||
status: 'in_progress',
|
||||
});
|
||||
toast.success('Task started');
|
||||
await fetchTasks();
|
||||
} catch (error: any) {
|
||||
logger.error('Error starting task', error);
|
||||
toast.error(error.response?.data?.detail || 'Failed to start task');
|
||||
} finally {
|
||||
setUpdatingTasks(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(task.id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateChecklist = async (task: HousekeepingTask, itemIndex: number, checked: boolean) => {
|
||||
if (updatingTasks.has(task.id)) return;
|
||||
|
||||
if (!task.checklist_items) return;
|
||||
|
||||
setUpdatingTasks(prev => new Set(prev).add(task.id));
|
||||
try {
|
||||
const updatedChecklist = [...task.checklist_items];
|
||||
updatedChecklist[itemIndex] = {
|
||||
...updatedChecklist[itemIndex],
|
||||
completed: checked,
|
||||
};
|
||||
|
||||
await advancedRoomService.updateHousekeepingTask(task.id, {
|
||||
checklist_items: updatedChecklist,
|
||||
});
|
||||
|
||||
// Update local state immediately for better UX
|
||||
setTasks(prevTasks =>
|
||||
prevTasks.map(t =>
|
||||
t.id === task.id
|
||||
? { ...t, checklist_items: updatedChecklist }
|
||||
: t
|
||||
)
|
||||
);
|
||||
|
||||
// Recalculate stats
|
||||
const updatedTask = { ...task, checklist_items: updatedChecklist };
|
||||
const allTasks = tasks.map(t => t.id === task.id ? updatedTask : t);
|
||||
const pending = allTasks.filter((t: HousekeepingTask) => t.status === 'pending').length;
|
||||
const in_progress = allTasks.filter((t: HousekeepingTask) => t.status === 'in_progress').length;
|
||||
const completed = allTasks.filter((t: HousekeepingTask) => t.status === 'completed').length;
|
||||
setStats({ pending, in_progress, completed, total: allTasks.length });
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('Error updating checklist', error);
|
||||
toast.error(error.response?.data?.detail || 'Failed to update checklist');
|
||||
} finally {
|
||||
setUpdatingTasks(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(task.id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteTask = async (task: HousekeepingTask) => {
|
||||
if (updatingTasks.has(task.id)) return;
|
||||
|
||||
// Check if all checklist items are completed
|
||||
const allCompleted = task.checklist_items?.every(item => item.completed) ?? true;
|
||||
|
||||
if (!allCompleted && task.checklist_items && task.checklist_items.length > 0) {
|
||||
const incomplete = task.checklist_items.filter(item => !item.completed).length;
|
||||
if (!window.confirm(`You have ${incomplete} incomplete checklist item(s). Mark task as completed anyway?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setUpdatingTasks(prev => new Set(prev).add(task.id));
|
||||
try {
|
||||
// Mark all checklist items as completed if not already
|
||||
const updatedChecklist = task.checklist_items?.map(item => ({
|
||||
...item,
|
||||
completed: true,
|
||||
})) || [];
|
||||
|
||||
await advancedRoomService.updateHousekeepingTask(task.id, {
|
||||
status: 'completed',
|
||||
checklist_items: updatedChecklist,
|
||||
});
|
||||
toast.success('Task marked as completed!');
|
||||
await fetchTasks();
|
||||
} catch (error: any) {
|
||||
logger.error('Error completing task', error);
|
||||
toast.error(error.response?.data?.detail || 'Failed to complete task');
|
||||
} finally {
|
||||
setUpdatingTasks(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(task.id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && tasks.length === 0) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Housekeeping Dashboard</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Welcome back, {userInfo?.name || userInfo?.email || 'Housekeeping Staff'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchTasks}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-yellow-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Pending</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.pending}</p>
|
||||
</div>
|
||||
<AlertCircle className="w-8 h-8 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-blue-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">In Progress</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.in_progress}</p>
|
||||
</div>
|
||||
<Clock className="w-8 h-8 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-green-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Completed</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.completed}</p>
|
||||
</div>
|
||||
<CheckCircle className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-indigo-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Today</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.total}</p>
|
||||
</div>
|
||||
<Sparkles className="w-8 h-8 text-indigo-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Today's Tasks</h2>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<Sparkles className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No tasks assigned</h3>
|
||||
<p className="text-sm text-gray-500">You don't have any housekeeping tasks assigned for today.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{tasks.map((task) => {
|
||||
const completedItems = task.checklist_items?.filter(item => item.completed).length || 0;
|
||||
const totalItems = task.checklist_items?.length || 0;
|
||||
const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0;
|
||||
|
||||
const isExpanded = expandedTasks.has(task.id);
|
||||
const isUpdating = updatingTasks.has(task.id);
|
||||
const canStart = task.status === 'pending';
|
||||
const canComplete = task.status === 'in_progress' || task.status === 'pending';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="border-b border-gray-200 last:border-b-0"
|
||||
>
|
||||
<div className="p-6 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Room {task.room_number || task.room_id}
|
||||
</h3>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium border flex items-center space-x-1 ${getStatusColor(task.status)}`}>
|
||||
{getStatusIcon(task.status)}
|
||||
<span>{task.status.replace('_', ' ')}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-600 mb-3">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{formatDate(task.scheduled_time)}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span className="capitalize">{task.task_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.checklist_items && task.checklist_items.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-gray-600">Progress</span>
|
||||
<span className="text-sm font-medium text-gray-900">{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{completedItems} of {totalItems} items completed
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
{canStart && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartTask(task);
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
className="flex items-center space-x-1 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
<span>Start</span>
|
||||
</button>
|
||||
)}
|
||||
{canComplete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCompleteTask(task);
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
className="flex items-center space-x-1 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>Complete</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleTaskExpansion(task.id);
|
||||
}}
|
||||
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Task Details */}
|
||||
{isExpanded && (
|
||||
<div className="px-6 pb-6 bg-gray-50 border-t border-gray-200">
|
||||
<div className="pt-4 space-y-4">
|
||||
{task.notes && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-1">Notes</h4>
|
||||
<p className="text-sm text-gray-600">{task.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.checklist_items && task.checklist_items.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Checklist</h4>
|
||||
<div className="space-y-2">
|
||||
{task.checklist_items.map((item: ChecklistItem, index: number) => (
|
||||
<label
|
||||
key={index}
|
||||
className="flex items-start space-x-3 p-3 bg-white rounded-lg border border-gray-200 hover:border-blue-300 transition-colors cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.completed}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdateChecklist(task, index, e.target.checked);
|
||||
}}
|
||||
disabled={isUpdating || task.status === 'completed'}
|
||||
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
item.completed
|
||||
? 'text-gray-500 line-through'
|
||||
: 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{item.item}
|
||||
</span>
|
||||
{item.notes && (
|
||||
<p className="text-xs text-gray-500 mt-1">{item.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
{item.completed && (
|
||||
<CheckCircle className="w-5 h-5 text-green-500 flex-shrink-0" />
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.status === 'completed' && task.completed_at && (
|
||||
<div className="pt-2 border-t border-gray-200">
|
||||
<p className="text-xs text-gray-500">
|
||||
Completed: {formatDate(task.completed_at)}
|
||||
</p>
|
||||
{task.actual_duration_minutes && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Duration: {task.actual_duration_minutes} minutes
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HousekeepingDashboardPage;
|
||||
|
||||
19
Frontend/src/pages/housekeeping/TasksPage.tsx
Normal file
19
Frontend/src/pages/housekeeping/TasksPage.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import HousekeepingManagement from '../../features/hotel_services/components/HousekeepingManagement';
|
||||
|
||||
const HousekeepingTasksPage: React.FC = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">My Housekeeping Tasks</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
View and manage your assigned housekeeping tasks
|
||||
</p>
|
||||
</div>
|
||||
<HousekeepingManagement />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HousekeepingTasksPage;
|
||||
|
||||
3
Frontend/src/pages/housekeeping/index.ts
Normal file
3
Frontend/src/pages/housekeeping/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as DashboardPage } from './DashboardPage';
|
||||
export { default as TasksPage } from './TasksPage';
|
||||
|
||||
@@ -135,7 +135,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<User className="w-4 h-4" />
|
||||
<span>Profile</span>
|
||||
</Link>
|
||||
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && (
|
||||
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && userInfo?.role !== 'housekeeping' && (
|
||||
<>
|
||||
<Link
|
||||
to="/favorites"
|
||||
@@ -427,7 +427,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<User className="w-4 h-4" />
|
||||
<span className="font-light tracking-wide">Profile</span>
|
||||
</Link>
|
||||
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && (
|
||||
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && userInfo?.role !== 'housekeeping' && (
|
||||
<>
|
||||
<Link
|
||||
to="/favorites"
|
||||
|
||||
Reference in New Issue
Block a user