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': '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:
|
||||||
|
|||||||
Binary file not shown.
@@ -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:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
@@ -538,6 +540,37 @@ async def update_housekeeping_task(
|
|||||||
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']
|
||||||
if 'notes' in task_data:
|
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)
|
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')
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
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 });
|
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 });
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
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 {
|
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,
|
||||||
|
|||||||
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,
|
ChevronUp,
|
||||||
Calendar,
|
Calendar,
|
||||||
MapPin,
|
MapPin,
|
||||||
Search,
|
|
||||||
Plus,
|
Plus,
|
||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
|
||||||
X,
|
X,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Check,
|
Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import Loading from '../../shared/components/Loading';
|
import Loading from '../../shared/components/Loading';
|
||||||
import advancedRoomService, {
|
import {
|
||||||
RoomStatusBoardItem,
|
RoomStatusBoardItem,
|
||||||
} from '../../features/rooms/services/advancedRoomService';
|
} from '../../features/rooms/services/advancedRoomService';
|
||||||
import roomService, { Room } from '../../features/rooms/services/roomService';
|
import roomService, { Room } from '../../features/rooms/services/roomService';
|
||||||
import MaintenanceManagement from '../../features/hotel_services/components/MaintenanceManagement';
|
import MaintenanceManagement from '../../features/hotel_services/components/MaintenanceManagement';
|
||||||
import HousekeepingManagement from '../../features/hotel_services/components/HousekeepingManagement';
|
import HousekeepingManagement from '../../features/hotel_services/components/HousekeepingManagement';
|
||||||
import InspectionManagement from '../../features/hotel_services/components/InspectionManagement';
|
import InspectionManagement from '../../features/hotel_services/components/InspectionManagement';
|
||||||
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 { logger } from '../../shared/utils/logger';
|
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 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 [activeTab, setActiveTab] = useState<Tab>('status-board');
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [rooms, setRooms] = useState<RoomStatusBoardItem[]>([]);
|
|
||||||
const [selectedFloor, setSelectedFloor] = useState<number | null>(null);
|
const [selectedFloor, setSelectedFloor] = useState<number | null>(null);
|
||||||
const [floors, setFloors] = useState<number[]>([]);
|
const [floors, setFloors] = useState<number[]>([]);
|
||||||
const [expandedRooms, setExpandedRooms] = useState<Set<number>>(new Set());
|
const [expandedRooms, setExpandedRooms] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// Rooms management state
|
// Rooms management state
|
||||||
const [roomList, setRoomList] = useState<Room[]>([]);
|
|
||||||
const [roomsLoading, setRoomsLoading] = useState(true);
|
|
||||||
const [showRoomModal, setShowRoomModal] = useState(false);
|
const [showRoomModal, setShowRoomModal] = useState(false);
|
||||||
const [editingRoom, setEditingRoom] = useState<Room | null>(null);
|
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({
|
const [roomFormData, setRoomFormData] = useState({
|
||||||
room_number: '',
|
room_number: '',
|
||||||
floor: 1,
|
floor: 1,
|
||||||
@@ -79,39 +74,80 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
const [uploadingImages, setUploadingImages] = useState(false);
|
const [uploadingImages, setUploadingImages] = useState(false);
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Define fetchFloors before using it in useEffect
|
||||||
fetchRoomStatusBoard();
|
const fetchFloors = useCallback(async () => {
|
||||||
fetchFloors();
|
|
||||||
}, [selectedFloor]);
|
|
||||||
|
|
||||||
const fetchRoomStatusBoard = async () => {
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
const response = await roomService.getRooms({ limit: 100, page: 1 });
|
||||||
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 });
|
|
||||||
if (response.data?.rooms) {
|
if (response.data?.rooms) {
|
||||||
const uniqueFloors = Array.from(
|
const uniqueFloors = Array.from(
|
||||||
new Set(response.data.rooms.map((r: any) => r.floor).filter((f: any) => f != null))
|
new Set(response.data.rooms.map((r: Room) => r.floor).filter((f: number | undefined) => f != null))
|
||||||
).sort((a: any, b: any) => a - b) as number[];
|
).sort((a: number, b: number) => a - b) as number[];
|
||||||
setFloors(uniqueFloors);
|
setFloors(uniqueFloors);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch floors', 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 toggleRoomExpansion = (roomId: number) => {
|
||||||
const newExpanded = new Set(expandedRooms);
|
const newExpanded = new Set(expandedRooms);
|
||||||
if (newExpanded.has(roomId)) {
|
if (newExpanded.has(roomId)) {
|
||||||
@@ -125,14 +161,14 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
// Group rooms by floor
|
// Group rooms by floor
|
||||||
const roomsByFloor = useMemo(() => {
|
const roomsByFloor = useMemo(() => {
|
||||||
const grouped: Record<number, RoomStatusBoardItem[]> = {};
|
const grouped: Record<number, RoomStatusBoardItem[]> = {};
|
||||||
rooms.forEach(room => {
|
statusBoardRooms.forEach(room => {
|
||||||
if (!grouped[room.floor]) {
|
if (!grouped[room.floor]) {
|
||||||
grouped[room.floor] = [];
|
grouped[room.floor] = [];
|
||||||
}
|
}
|
||||||
grouped[room.floor].push(room);
|
grouped[room.floor].push(room);
|
||||||
});
|
});
|
||||||
return grouped;
|
return grouped;
|
||||||
}, [rooms]);
|
}, [statusBoardRooms]);
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -219,88 +255,20 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch amenities', 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 {
|
try {
|
||||||
setRoomsLoading(true);
|
const response = await roomService.getRoomTypes();
|
||||||
const response = await roomService.getRooms({
|
if (response.data?.room_types) {
|
||||||
...roomFilters,
|
const roomTypesList = response.data.room_types.map((rt: { id: number; name: string }) => ({
|
||||||
page: roomCurrentPage,
|
id: rt.id,
|
||||||
limit: roomItemsPerPage,
|
name: rt.name,
|
||||||
});
|
}));
|
||||||
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());
|
|
||||||
setRoomTypes(roomTypesList);
|
setRoomTypes(roomTypesList);
|
||||||
setRoomFormData(prev => {
|
setRoomFormData(prev => {
|
||||||
if (!editingRoom && prev.room_type_id === 1 && roomTypesList.length > 0) {
|
if (!editingRoom && prev.room_type_id === 1 && roomTypesList.length > 0) {
|
||||||
@@ -308,16 +276,68 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch room types', 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();
|
fetchData();
|
||||||
}, [activeTab, editingRoom]);
|
|
||||||
|
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) => {
|
const handleRoomSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Frontend validation
|
||||||
|
const validationError = validateRoomForm();
|
||||||
|
if (validationError) {
|
||||||
|
toast.error(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingRoom) {
|
if (editingRoom) {
|
||||||
const updateData = {
|
const updateData = {
|
||||||
@@ -329,14 +349,15 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
view: roomFormData.view || undefined,
|
view: roomFormData.view || undefined,
|
||||||
amenities: Array.isArray(roomFormData.amenities) ? roomFormData.amenities : [],
|
amenities: Array.isArray(roomFormData.amenities) ? roomFormData.amenities : [],
|
||||||
};
|
};
|
||||||
await roomService.updateRoom(editingRoom.id, updateData);
|
await contextUpdateRoom(editingRoom.id, updateData);
|
||||||
toast.success('Room updated successfully');
|
|
||||||
await fetchRoomList();
|
// 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);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to refresh room data', err);
|
logger.error('Failed to refresh room data', err);
|
||||||
|
toast.error('Room updated but failed to refresh details');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const createData = {
|
const createData = {
|
||||||
@@ -349,7 +370,6 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
amenities: Array.isArray(roomFormData.amenities) ? roomFormData.amenities : [],
|
amenities: Array.isArray(roomFormData.amenities) ? roomFormData.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) {
|
||||||
if (selectedFiles.length > 0) {
|
if (selectedFiles.length > 0) {
|
||||||
@@ -396,18 +416,41 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
amenities: response.data.room.amenities || [],
|
amenities: response.data.room.amenities || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
await fetchRoomList();
|
await contextCreateRoom(createData);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setShowRoomModal(false);
|
setShowRoomModal(false);
|
||||||
resetRoomForm();
|
resetRoomForm();
|
||||||
fetchRoomList();
|
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');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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) => {
|
const handleEditRoom = async (room: Room) => {
|
||||||
setEditingRoom(room);
|
setEditingRoom(room);
|
||||||
|
|
||||||
@@ -478,6 +521,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
setEditingRoom(roomData);
|
setEditingRoom(roomData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch full room details', 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;
|
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));
|
|
||||||
fetchRoomList();
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.response?.data?.message || 'Unable to delete room');
|
// Error already handled in context
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -581,7 +588,10 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
|
|
||||||
toast.success('Images uploaded successfully');
|
toast.success('Images uploaded successfully');
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
fetchRoomList();
|
await Promise.all([
|
||||||
|
refreshRooms(),
|
||||||
|
refreshStatusBoard(),
|
||||||
|
]);
|
||||||
|
|
||||||
const response = await roomService.getRoomByNumber(editingRoom.room_number);
|
const response = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||||
setEditingRoom(response.data.room);
|
setEditingRoom(response.data.room);
|
||||||
@@ -613,7 +623,10 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast.success('Image deleted successfully');
|
toast.success('Image deleted successfully');
|
||||||
fetchRoomList();
|
await Promise.all([
|
||||||
|
refreshRooms(),
|
||||||
|
refreshStatusBoard(),
|
||||||
|
]);
|
||||||
|
|
||||||
const response = await roomService.getRoomByNumber(editingRoom.room_number);
|
const response = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||||
setEditingRoom(response.data.room);
|
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 />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,8 +680,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
<div className="mb-6 border-b border-gray-200">
|
<div className="mb-6 border-b border-gray-200">
|
||||||
<nav className="-mb-px flex space-x-8">
|
<nav className="-mb-px flex space-x-8">
|
||||||
{[
|
{[
|
||||||
{ id: 'status-board' as Tab, label: 'Room Status Board', icon: Hotel },
|
{ id: 'status-board' as Tab, label: 'Rooms & Status Board', icon: Hotel },
|
||||||
{ id: 'rooms' as Tab, label: 'Rooms', icon: Hotel },
|
|
||||||
{ id: 'maintenance' as Tab, label: 'Maintenance', icon: Wrench },
|
{ id: 'maintenance' as Tab, label: 'Maintenance', icon: Wrench },
|
||||||
{ id: 'housekeeping' as Tab, label: 'Housekeeping', icon: Sparkles },
|
{ id: 'housekeeping' as Tab, label: 'Housekeeping', icon: Sparkles },
|
||||||
{ id: 'inspections' as Tab, label: 'Inspections', icon: ClipboardCheck },
|
{ id: 'inspections' as Tab, label: 'Inspections', icon: ClipboardCheck },
|
||||||
@@ -717,17 +729,29 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-slate-600">
|
<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>
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
<button
|
<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"
|
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" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
<span>Refresh</span>
|
<span>Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Floors Display */}
|
{/* Floors Display */}
|
||||||
{Object.keys(roomsByFloor).length === 0 ? (
|
{Object.keys(roomsByFloor).length === 0 ? (
|
||||||
@@ -761,26 +785,41 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
{/* Rooms Grid */}
|
{/* 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">
|
<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) => {
|
{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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={room.id}
|
key={room.id}
|
||||||
className={`
|
className={`
|
||||||
group relative overflow-hidden rounded-xl border-2 transition-all duration-300
|
group relative overflow-hidden rounded-xl border-2 transition-all duration-300
|
||||||
${statusColors.bg} ${statusColors.border}
|
${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'}
|
${expandedRooms.has(room.id) ? 'shadow-lg' : 'shadow-md'}
|
||||||
`}
|
`}
|
||||||
onClick={() => toggleRoomExpansion(room.id)}
|
|
||||||
>
|
>
|
||||||
{/* Status Badge */}
|
{/* 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`}>
|
<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(room.status)}
|
{getStatusIcon(effectiveStatus)}
|
||||||
<span>{getStatusLabel(room.status)}</span>
|
<span>{getStatusLabel(effectiveStatus)}</span>
|
||||||
</div>
|
</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 */}
|
{/* 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="mb-4">
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
<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>
|
<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 */}
|
{/* Expanded Details */}
|
||||||
{expandedRooms.has(room.id) && (
|
{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 && (
|
{room.current_booking && (
|
||||||
<div className="bg-white/60 rounded-lg p-3 space-y-2">
|
<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">
|
<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>
|
<span>Guest</span>
|
||||||
</div>
|
</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">
|
<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>
|
<span>Check-out: {new Date(room.current_booking.check_out).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -812,10 +851,10 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
{room.active_maintenance && (
|
{room.active_maintenance && (
|
||||||
<div className="bg-red-50/80 rounded-lg p-3 space-y-2 border border-red-200/50">
|
<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">
|
<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>
|
<span>Maintenance</span>
|
||||||
</div>
|
</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>
|
<p className="text-xs text-red-700 capitalize">{room.active_maintenance.type}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -823,7 +862,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
{room.pending_housekeeping_count > 0 && (
|
{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="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">
|
<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>
|
<span>Housekeeping</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-amber-900">
|
<p className="text-sm font-medium text-amber-900">
|
||||||
@@ -873,181 +912,9 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
{/* Inspections Tab */}
|
{/* Inspections Tab */}
|
||||||
{activeTab === 'inspections' && <InspectionManagement />}
|
{activeTab === 'inspections' && <InspectionManagement />}
|
||||||
|
|
||||||
{/* Rooms Tab */}
|
{/* Room Modal */}
|
||||||
{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>
|
|
||||||
|
|
||||||
{showRoomModal && (
|
{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="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 className="flex justify-between items-center mb-6 pb-6 border-b border-[#d4af37]/20">
|
||||||
<div>
|
<div>
|
||||||
@@ -1130,7 +997,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={roomFormData.status}
|
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"
|
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
|
required
|
||||||
>
|
>
|
||||||
@@ -1467,8 +1334,6 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 { 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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
}, [contextRooms]);
|
||||||
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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" />
|
<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"
|
||||||
|
|||||||
Reference in New Issue
Block a user