This commit is contained in:
Iliyan Angelov
2025-12-01 15:34:45 +02:00
parent 49181cf48c
commit f7d6f24e49
28 changed files with 2121 additions and 832 deletions

View 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!")

View 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!")

View 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!")

View File

@@ -26,7 +26,8 @@ def seed_roles(db: Session):
{'name': 'admin', 'description': 'Administrator with full access'}, {'name': 'admin', 'description': 'Administrator with full access'},
{'name': 'staff', 'description': 'Staff member with limited admin access'}, {'name': 'staff', 'description': 'Staff member with limited admin access'},
{'name': 'customer', 'description': 'Regular customer'}, {'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: for role_data in roles_data:

View File

@@ -730,6 +730,46 @@ async def update_booking(id: int, booking_data: UpdateBookingRequest, current_us
room.status = RoomStatus.maintenance room.status = RoomStatus.maintenance
else: else:
room.status = RoomStatus.cleaning 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: elif new_status == BookingStatus.cancelled:
# Update room status when booking is cancelled # Update room status when booking is cancelled
if booking.payments: if booking.payments:

View File

@@ -346,19 +346,20 @@ async def get_housekeeping_tasks(
date: Optional[str] = Query(None), date: Optional[str] = Query(None),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100), 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) db: Session = Depends(get_db)
): ):
"""Get housekeeping tasks with filtering""" """Get housekeeping tasks with filtering"""
try: 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() 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) query = db.query(HousekeepingTask)
# Filter by assigned_to for staff users # Filter by assigned_to for housekeeping and staff users (not admin)
if is_staff: if is_housekeeping_or_staff:
query = query.filter(HousekeepingTask.assigned_to == current_user.id) query = query.filter(HousekeepingTask.assigned_to == current_user.id)
if room_id: if room_id:
@@ -488,7 +489,7 @@ async def create_housekeeping_task(
async def update_housekeeping_task( async def update_housekeeping_task(
task_id: int, task_id: int,
task_data: dict, 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) db: Session = Depends(get_db)
): ):
"""Update a housekeeping task""" """Update a housekeeping task"""
@@ -497,22 +498,23 @@ async def update_housekeeping_task(
if not task: if not task:
raise HTTPException(status_code=404, detail='Housekeeping task not found') 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() 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: if is_housekeeping_or_staff:
# Staff can only update tasks assigned to them # Housekeeping and staff can only update tasks assigned to them
if task.assigned_to != current_user.id: if task.assigned_to != current_user.id:
raise HTTPException(status_code=403, detail='You can only update tasks assigned to you') 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: 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') raise HTTPException(status_code=403, detail='You cannot change task assignment')
old_assigned_to = task.assigned_to old_assigned_to = task.assigned_to
assigned_to_changed = False 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') new_assigned_to = task_data.get('assigned_to')
if new_assigned_to != old_assigned_to: if new_assigned_to != old_assigned_to:
task.assigned_to = new_assigned_to task.assigned_to = new_assigned_to
@@ -537,6 +539,37 @@ async def update_housekeeping_task(
if task.started_at: if task.started_at:
duration = (task.completed_at - task.started_at).total_seconds() / 60 duration = (task.completed_at - task.started_at).total_seconds() / 60
task.actual_duration_minutes = int(duration) 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: if 'checklist_items' in task_data:
task.checklist_items = task_data['checklist_items'] task.checklist_items = task_data['checklist_items']

View File

@@ -72,6 +72,26 @@ async def get_amenities(db: Session=Depends(get_db)):
logger.error(f'Error fetching amenities: {str(e)}', exc_info=True) logger.error(f'Error fetching amenities: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) 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') @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)): 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: 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'))]) @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)): 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.""" """Create a new room with validated input using Pydantic schema."""
# Start transaction
transaction = db.begin()
try: try:
# Lock room type to prevent race conditions # Lock room type to prevent race conditions
room_type = db.query(RoomType).filter(RoomType.id == room_data.room_type_id).with_for_update().first() room_type = db.query(RoomType).filter(RoomType.id == room_data.room_type_id).with_for_update().first()
if not room_type: if not room_type:
transaction.rollback() db.rollback()
raise HTTPException(status_code=404, detail='Room type not found') raise HTTPException(status_code=404, detail='Room type not found')
# Check for duplicate room number with locking # Check for duplicate room number with locking
existing = db.query(Room).filter(Room.room_number == room_data.room_number).with_for_update().first() existing = db.query(Room).filter(Room.room_number == room_data.room_number).with_for_update().first()
if existing: if existing:
transaction.rollback() db.rollback()
raise HTTPException(status_code=400, detail='Room number already exists') raise HTTPException(status_code=400, detail='Room number already exists')
# Use price from request or default to room type base price # 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() db.flush()
# Commit transaction # Commit transaction
transaction.commit() db.commit()
db.refresh(room) db.refresh(room)
base_url = get_base_url(request) 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} 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': []} 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') return success_response(data={'room': room_dict}, message='Room created successfully')
except HTTPException: except HTTPException:
if 'transaction' in locals(): db.rollback()
transaction.rollback()
raise raise
except IntegrityError as e: except IntegrityError as e:
if 'transaction' in locals(): db.rollback()
transaction.rollback()
logger.error(f'Database integrity error during room creation: {str(e)}') logger.error(f'Database integrity error during room creation: {str(e)}')
raise HTTPException(status_code=409, detail='Room conflict detected. Please check room number.') raise HTTPException(status_code=409, detail='Room conflict detected. Please check room number.')
except Exception as e: except Exception as e:
if 'transaction' in locals(): db.rollback()
transaction.rollback()
logger.error(f'Error creating room: {str(e)}', exc_info=True) logger.error(f'Error creating room: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while creating the room') raise HTTPException(status_code=500, detail='An error occurred while creating the room')
@router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))]) @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)): 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.""" """Update a room with validated input using Pydantic schema."""
# Start transaction
transaction = db.begin()
try: try:
# Lock room row to prevent race conditions # Lock room row to prevent race conditions
room = db.query(Room).filter(Room.id == id).with_for_update().first() room = db.query(Room).filter(Room.id == id).with_for_update().first()
if not room: if not room:
transaction.rollback() db.rollback()
raise HTTPException(status_code=404, detail='Room not found') raise HTTPException(status_code=404, detail='Room not found')
if room_data.room_type_id: if room_data.room_type_id:
room_type = db.query(RoomType).filter(RoomType.id == room_data.room_type_id).first() room_type = db.query(RoomType).filter(RoomType.id == room_data.room_type_id).first()
if not room_type: if not room_type:
transaction.rollback() db.rollback()
raise HTTPException(status_code=404, detail='Room type not found') raise HTTPException(status_code=404, detail='Room type not found')
room.room_type_id = room_data.room_type_id 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 # Check for duplicate room number
existing = db.query(Room).filter(Room.room_number == room_data.room_number, Room.id != id).first() existing = db.query(Room).filter(Room.room_number == room_data.room_number, Room.id != id).first()
if existing: if existing:
transaction.rollback() db.rollback()
raise HTTPException(status_code=400, detail='Room number already exists') raise HTTPException(status_code=400, detail='Room number already exists')
room.room_number = room_data.room_number 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 [] room.amenities = room_data.amenities or []
# Commit transaction # Commit transaction
transaction.commit() db.commit()
db.refresh(room) db.refresh(room)
base_url = get_base_url(request) 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') return success_response(data={'room': room_dict}, message='Room updated successfully')
except HTTPException: except HTTPException:
if 'transaction' in locals(): db.rollback()
transaction.rollback()
raise raise
except IntegrityError as e: except IntegrityError as e:
if 'transaction' in locals(): db.rollback()
transaction.rollback()
logger.error(f'Database integrity error during room update: {str(e)}') logger.error(f'Database integrity error during room update: {str(e)}')
raise HTTPException(status_code=409, detail='Room conflict detected. Please check room number.') raise HTTPException(status_code=409, detail='Room conflict detected. Please check room number.')
except Exception as e: except Exception as e:
if 'transaction' in locals(): db.rollback()
transaction.rollback()
logger.error(f'Error updating room: {str(e)}', exc_info=True) logger.error(f'Error updating room: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while updating the room') 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'))]) @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)): 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.""" """Bulk delete rooms with validated input using Pydantic schema."""
# Start transaction
transaction = db.begin()
try: try:
ids = room_ids.room_ids 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] found_ids = [room.id for room in rooms]
not_found_ids = [id for id in ids if id not in found_ids] not_found_ids = [id for id in ids if id not in found_ids]
if not_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') raise HTTPException(status_code=404, detail=f'Rooms with IDs {not_found_ids} not found')
# Delete rooms # Delete rooms
deleted_count = db.query(Room).filter(Room.id.in_(ids)).delete(synchronize_session=False) deleted_count = db.query(Room).filter(Room.id.in_(ids)).delete(synchronize_session=False)
# Commit transaction # Commit transaction
transaction.commit() db.commit()
return success_response( return success_response(
data={'deleted_count': deleted_count, 'deleted_ids': ids}, data={'deleted_count': deleted_count, 'deleted_ids': ids},
message=f'Successfully deleted {deleted_count} room(s)' message=f'Successfully deleted {deleted_count} room(s)'
) )
except HTTPException: except HTTPException:
if 'transaction' in locals(): db.rollback()
transaction.rollback()
raise raise
except IntegrityError as e: except IntegrityError as e:
if 'transaction' in locals(): db.rollback()
transaction.rollback()
logger.error(f'Database integrity error during bulk room deletion: {str(e)}') 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.)') raise HTTPException(status_code=409, detail='Cannot delete rooms due to existing relationships (bookings, etc.)')
except Exception as e: except Exception as e:
if 'transaction' in locals(): db.rollback()
transaction.rollback()
logger.error(f'Error bulk deleting rooms: {str(e)}', exc_info=True) logger.error(f'Error bulk deleting rooms: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while deleting rooms') raise HTTPException(status_code=500, detail='An error occurred while deleting rooms')

View File

@@ -14,6 +14,7 @@ import { CurrencyProvider } from './features/payments/contexts/CurrencyContext';
import { CompanySettingsProvider } from './shared/contexts/CompanySettingsContext'; import { CompanySettingsProvider } from './shared/contexts/CompanySettingsContext';
import { AuthModalProvider } from './features/auth/contexts/AuthModalContext'; import { AuthModalProvider } from './features/auth/contexts/AuthModalContext';
import { AntibotProvider } from './features/auth/contexts/AntibotContext'; import { AntibotProvider } from './features/auth/contexts/AntibotContext';
import { RoomProvider } from './features/rooms/contexts/RoomContext';
import { logDebug } from './shared/utils/errorReporter'; import { logDebug } from './shared/utils/errorReporter';
import OfflineIndicator from './shared/components/OfflineIndicator'; import OfflineIndicator from './shared/components/OfflineIndicator';
import CookieConsentBanner from './shared/components/CookieConsentBanner'; import CookieConsentBanner from './shared/components/CookieConsentBanner';
@@ -37,7 +38,8 @@ import {
AdminRoute, AdminRoute,
StaffRoute, StaffRoute,
AccountantRoute, AccountantRoute,
CustomerRoute CustomerRoute,
HousekeepingRoute
} from './features/auth/components'; } from './features/auth/components';
const HomePage = lazy(() => import('./features/content/pages/HomePage')); 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 AccountantInvoiceManagementPage = lazy(() => import('./pages/accountant/InvoiceManagementPage'));
const AccountantAnalyticsDashboardPage = lazy(() => import('./pages/accountant/AnalyticsDashboardPage')); const AccountantAnalyticsDashboardPage = lazy(() => import('./pages/accountant/AnalyticsDashboardPage'));
const AccountantLayout = lazy(() => import('./pages/AccountantLayout')); 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 AdminProfilePage = lazy(() => import('./pages/admin/ProfilePage'));
const StaffProfilePage = lazy(() => import('./pages/staff/ProfilePage')); const StaffProfilePage = lazy(() => import('./pages/staff/ProfilePage'));
const AccountantProfilePage = lazy(() => import('./pages/accountant/ProfilePage')); const AccountantProfilePage = lazy(() => import('./pages/accountant/ProfilePage'));
@@ -210,6 +217,7 @@ function App() {
<CompanySettingsProvider> <CompanySettingsProvider>
<AntibotProvider> <AntibotProvider>
<AuthModalProvider> <AuthModalProvider>
<RoomProvider>
<BrowserRouter <BrowserRouter
future={{ future={{
v7_startTransition: true, v7_startTransition: true,
@@ -709,6 +717,24 @@ function App() {
/> />
</Route> </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 <Route
path="*" path="*"
@@ -737,6 +763,7 @@ function App() {
<AuthModalManager /> <AuthModalManager />
</Suspense> </Suspense>
</BrowserRouter> </BrowserRouter>
</RoomProvider>
</AuthModalProvider> </AuthModalProvider>
</AntibotProvider> </AntibotProvider>
</CompanySettingsProvider> </CompanySettingsProvider>

View File

@@ -62,6 +62,8 @@ const AdminRoute: React.FC<AdminRouteProps> = ({
return <Navigate to="/staff/dashboard" replace />; return <Navigate to="/staff/dashboard" replace />;
} else if (userInfo?.role === 'accountant') { } else if (userInfo?.role === 'accountant') {
return <Navigate to="/accountant/dashboard" replace />; return <Navigate to="/accountant/dashboard" replace />;
} else if (userInfo?.role === 'housekeeping') {
return <Navigate to="/housekeeping/dashboard" replace />;
} }
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }

View 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;

View File

@@ -77,6 +77,8 @@ const LoginModal: React.FC = () => {
navigate('/staff/dashboard', { replace: true }); navigate('/staff/dashboard', { replace: true });
} else if (role === 'accountant') { } else if (role === 'accountant') {
navigate('/accountant/dashboard', { replace: true }); navigate('/accountant/dashboard', { replace: true });
} else if (role === 'housekeeping') {
navigate('/housekeeping/dashboard', { replace: true });
} else { } else {
// Customer or default - go to customer dashboard // Customer or default - go to customer dashboard
navigate('/dashboard', { replace: true }); navigate('/dashboard', { replace: true });

View File

@@ -52,6 +52,8 @@ const StaffRoute: React.FC<StaffRouteProps> = ({
return <Navigate to="/admin/dashboard" replace />; return <Navigate to="/admin/dashboard" replace />;
} else if (userInfo?.role === 'accountant') { } else if (userInfo?.role === 'accountant') {
return <Navigate to="/accountant/dashboard" replace />; return <Navigate to="/accountant/dashboard" replace />;
} else if (userInfo?.role === 'housekeeping') {
return <Navigate to="/housekeeping/dashboard" replace />;
} }
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }

View File

@@ -3,4 +3,5 @@ export { default as AdminRoute } from './AdminRoute';
export { default as StaffRoute } from './StaffRoute'; export { default as StaffRoute } from './StaffRoute';
export { default as AccountantRoute } from './AccountantRoute'; export { default as AccountantRoute } from './AccountantRoute';
export { default as CustomerRoute } from './CustomerRoute'; export { default as CustomerRoute } from './CustomerRoute';
export { default as HousekeepingRoute } from './HousekeepingRoute';
export { default as ResetPasswordRouteHandler } from './ResetPasswordRouteHandler'; export { default as ResetPasswordRouteHandler } from './ResetPasswordRouteHandler';

View File

@@ -21,6 +21,7 @@ import 'react-datepicker/dist/react-datepicker.css';
const HousekeepingManagement: React.FC = () => { const HousekeepingManagement: React.FC = () => {
const { userInfo } = useAuthStore(); const { userInfo } = useAuthStore();
const isAdmin = userInfo?.role === 'admin'; const isAdmin = userInfo?.role === 'admin';
const isHousekeeping = userInfo?.role === 'housekeeping';
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tasks, setTasks] = useState<HousekeepingTask[]>([]); const [tasks, setTasks] = useState<HousekeepingTask[]>([]);
const [rooms, setRooms] = useState<Room[]>([]); const [rooms, setRooms] = useState<Room[]>([]);
@@ -227,8 +228,8 @@ const HousekeepingManagement: React.FC = () => {
} }
toast.success('Housekeeping task updated successfully'); toast.success('Housekeeping task updated successfully');
} else { } else {
// Only admin can create tasks // Only admin and staff can create tasks
if (!isAdmin) { if (!isAdmin && userInfo?.role !== 'staff') {
toast.error('You do not have permission to create tasks'); toast.error('You do not have permission to create tasks');
return; 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" className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
</div> </div>
{isAdmin && ( {(isAdmin || userInfo?.role === 'staff') && (
<button <button
onClick={handleCreate} 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" 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" /> <Edit className="w-4 h-4" />
</button> </button>
) : ( ) : (
// Staff can only edit their own assigned tasks // Housekeeping and staff can only edit their own assigned tasks
task.assigned_to === userInfo?.id && task.status !== 'completed' && ( (isHousekeeping || userInfo?.role === 'staff') &&
task.assigned_to === userInfo?.id &&
task.status !== 'completed' && (
<> <>
<button <button
onClick={() => handleEdit(task)} onClick={() => handleEdit(task)}

View 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>;
};

View File

@@ -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 { export interface CreateRoomData {
room_number: string; room_number: string;
floor: number; floor: number;
@@ -242,6 +264,7 @@ export default {
getRoomByNumber, getRoomByNumber,
searchAvailableRooms, searchAvailableRooms,
getAmenities, getAmenities,
getRoomTypes,
createRoom, createRoom,
updateRoom, updateRoom,
deleteRoom, deleteRoom,

View 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;

File diff suppressed because it is too large Load Diff

View File

@@ -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 { Plus, Search, Edit, Trash2, X, Upload, Image as ImageIcon, Check } from 'lucide-react';
import roomService, { Room } from '../../features/rooms/services/roomService'; import roomService, { Room } from '../../features/rooms/services/roomService';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -7,9 +7,22 @@ import Pagination from '../../shared/components/Pagination';
import apiClient from '../../shared/services/apiClient'; import apiClient from '../../shared/services/apiClient';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency'; import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { logger } from '../../shared/utils/logger'; import { logger } from '../../shared/utils/logger';
import { useRoomContext } from '../../features/rooms/contexts/RoomContext';
const RoomManagementPage: React.FC = () => { const RoomManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency(); 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 [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
@@ -43,7 +56,45 @@ const RoomManagementPage: React.FC = () => {
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]); const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
const [uploadingImages, setUploadingImages] = useState(false); const [uploadingImages, setUploadingImages] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]); 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(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
@@ -51,24 +102,8 @@ const RoomManagementPage: React.FC = () => {
}, [filters]); }, [filters]);
useEffect(() => { useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchRooms();
fetchAvailableAmenities(); fetchAvailableAmenities();
}, []);
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [filters, currentPage]);
useEffect(() => { useEffect(() => {
@@ -132,23 +167,11 @@ const RoomManagementPage: React.FC = () => {
} }
}; };
const fetchRooms = async () => { // Extract unique room types from context rooms
try { useEffect(() => {
setLoading(true); if (contextRooms.length > 0) {
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);
}
const uniqueRoomTypes = new Map<number, { id: number; name: string }>(); 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)) { if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) {
uniqueRoomTypes.set(room.room_type.id, { uniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id, id: room.room_type.id,
@@ -157,60 +180,8 @@ const RoomManagementPage: React.FC = () => {
} }
}); });
setRoomTypes(Array.from(uniqueRoomTypes.values())); 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -226,13 +197,9 @@ const RoomManagementPage: React.FC = () => {
view: formData.view || undefined, view: formData.view || undefined,
amenities: Array.isArray(formData.amenities) ? formData.amenities : [], amenities: Array.isArray(formData.amenities) ? formData.amenities : [],
}; };
await roomService.updateRoom(editingRoom.id, updateData); await contextUpdateRoom(editingRoom.id, updateData);
toast.success('Room updated successfully');
await fetchRooms();
// Refresh room details for editing
try { try {
const updatedRoom = await roomService.getRoomByNumber(editingRoom.room_number); const updatedRoom = await roomService.getRoomByNumber(editingRoom.room_number);
setEditingRoom(updatedRoom.data.room); setEditingRoom(updatedRoom.data.room);
@@ -251,8 +218,6 @@ const RoomManagementPage: React.FC = () => {
amenities: Array.isArray(formData.amenities) ? formData.amenities : [], amenities: Array.isArray(formData.amenities) ? formData.amenities : [],
}; };
const response = await roomService.createRoom(createData); const response = await roomService.createRoom(createData);
toast.success('Room added successfully');
if (response.data?.room) { if (response.data?.room) {
@@ -307,7 +272,7 @@ const RoomManagementPage: React.FC = () => {
}); });
await fetchRooms(); await contextCreateRoom(createData);
return; return;
} }
@@ -315,7 +280,7 @@ const RoomManagementPage: React.FC = () => {
setShowModal(false); setShowModal(false);
resetForm(); resetForm();
fetchRooms(); await refreshRooms();
} catch (error: any) { } catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred'); 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; if (!window.confirm('Are you sure you want to delete this room?')) return;
try { try {
await roomService.deleteRoom(id); await contextDeleteRoom(id);
toast.success('Room deleted successfully');
setSelectedRooms(selectedRooms.filter(roomId => roomId !== id)); setSelectedRooms(selectedRooms.filter(roomId => roomId !== id));
fetchRooms();
} catch (error: any) { } 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); await roomService.bulkDeleteRooms(selectedRooms);
toast.success(`Successfully deleted ${selectedRooms.length} room(s)`); toast.success(`Successfully deleted ${selectedRooms.length} room(s)`);
setSelectedRooms([]); setSelectedRooms([]);
fetchRooms(); await refreshRooms();
} catch (error: any) { } catch (error: any) {
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to delete rooms'); 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'); toast.success('Images uploaded successfully');
setSelectedFiles([]); setSelectedFiles([]);
fetchRooms(); await refreshRooms();
const response = await roomService.getRoomByNumber(editingRoom.room_number); const response = await roomService.getRoomByNumber(editingRoom.room_number);
@@ -540,7 +503,7 @@ const RoomManagementPage: React.FC = () => {
}); });
toast.success('Image deleted successfully'); toast.success('Image deleted successfully');
fetchRooms(); await refreshRooms();
const response = await roomService.getRoomByNumber(editingRoom.room_number); const response = await roomService.getRoomByNumber(editingRoom.room_number);

View File

@@ -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 { Plus, Search, Edit, Trash2, X } from 'lucide-react';
import userService, { User } from '../../features/auth/services/userService'; import userService, { User } from '../../features/auth/services/userService';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -199,6 +199,18 @@ const UserManagementPage: React.FC = () => {
label: 'Staff', label: 'Staff',
border: 'border-blue-200' 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: { customer: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50', bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800', text: 'text-emerald-800',
@@ -264,6 +276,8 @@ const UserManagementPage: React.FC = () => {
<option value="">All roles</option> <option value="">All roles</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
<option value="staff">Staff</option> <option value="staff">Staff</option>
<option value="accountant">Accountant</option>
<option value="housekeeping">Housekeeping</option>
<option value="customer">Customer</option> <option value="customer">Customer</option>
</select> </select>
<select <select
@@ -458,6 +472,7 @@ const UserManagementPage: React.FC = () => {
<option value="customer">Customer</option> <option value="customer">Customer</option>
<option value="staff">Staff</option> <option value="staff">Staff</option>
<option value="accountant">Accountant</option> <option value="accountant">Accountant</option>
<option value="housekeeping">Housekeeping</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </select>
</div> </div>

View 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;

View 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;

View File

@@ -0,0 +1,3 @@
export { default as DashboardPage } from './DashboardPage';
export { default as TasksPage } from './TasksPage';

View File

@@ -135,7 +135,7 @@ const Header: React.FC<HeaderProps> = ({
<User className="w-4 h-4" /> <User className="w-4 h-4" />
<span>Profile</span> <span>Profile</span>
</Link> </Link>
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && ( {userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && userInfo?.role !== 'housekeeping' && (
<> <>
<Link <Link
to="/favorites" to="/favorites"
@@ -427,7 +427,7 @@ const Header: React.FC<HeaderProps> = ({
<User className="w-4 h-4" /> <User className="w-4 h-4" />
<span className="font-light tracking-wide">Profile</span> <span className="font-light tracking-wide">Profile</span>
</Link> </Link>
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && ( {userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && userInfo?.role !== 'housekeeping' && (
<> <>
<Link <Link
to="/favorites" to="/favorites"