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;
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Plus, Search, Edit, Trash2, X, Upload, Image as ImageIcon, Check } from 'lucide-react';
|
import { Plus, Search, Edit, Trash2, X, Upload, Image as ImageIcon, Check } from 'lucide-react';
|
||||||
import roomService, { Room } from '../../features/rooms/services/roomService';
|
import roomService, { Room } from '../../features/rooms/services/roomService';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -7,9 +7,22 @@ import Pagination from '../../shared/components/Pagination';
|
|||||||
import apiClient from '../../shared/services/apiClient';
|
import apiClient from '../../shared/services/apiClient';
|
||||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||||
import { logger } from '../../shared/utils/logger';
|
import { logger } from '../../shared/utils/logger';
|
||||||
|
import { useRoomContext } from '../../features/rooms/contexts/RoomContext';
|
||||||
|
|
||||||
const RoomManagementPage: React.FC = () => {
|
const RoomManagementPage: React.FC = () => {
|
||||||
const { formatCurrency } = useFormatCurrency();
|
const { formatCurrency } = useFormatCurrency();
|
||||||
|
const {
|
||||||
|
rooms: contextRooms,
|
||||||
|
roomsLoading,
|
||||||
|
refreshRooms,
|
||||||
|
updateRoom: contextUpdateRoom,
|
||||||
|
deleteRoom: contextDeleteRoom,
|
||||||
|
createRoom: contextCreateRoom,
|
||||||
|
setRoomFilters,
|
||||||
|
setRoomPage,
|
||||||
|
} = useRoomContext();
|
||||||
|
|
||||||
|
// Use context rooms, but filter/paginate locally for this page's display
|
||||||
const [rooms, setRooms] = useState<Room[]>([]);
|
const [rooms, setRooms] = useState<Room[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@@ -43,7 +56,45 @@ const RoomManagementPage: React.FC = () => {
|
|||||||
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
|
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
|
||||||
const [uploadingImages, setUploadingImages] = useState(false);
|
const [uploadingImages, setUploadingImages] = useState(false);
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
|
// Sync local filters with context
|
||||||
|
useEffect(() => {
|
||||||
|
setRoomFilters(filters);
|
||||||
|
}, [filters, setRoomFilters]);
|
||||||
|
|
||||||
|
// Sync local page with context
|
||||||
|
useEffect(() => {
|
||||||
|
setRoomPage(currentPage);
|
||||||
|
}, [currentPage, setRoomPage]);
|
||||||
|
|
||||||
|
// Update local rooms from context and apply local pagination
|
||||||
|
useEffect(() => {
|
||||||
|
if (contextRooms.length > 0) {
|
||||||
|
// Apply local filters
|
||||||
|
let filteredRooms = contextRooms.filter((room) => {
|
||||||
|
const matchesSearch = !filters.search ||
|
||||||
|
room.room_number.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||||
|
room.room_type?.name.toLowerCase().includes(filters.search.toLowerCase());
|
||||||
|
const matchesStatus = !filters.status || room.status === filters.status;
|
||||||
|
const matchesType = !filters.type || room.room_type?.name === filters.type;
|
||||||
|
return matchesSearch && matchesStatus && matchesType;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
|
const endIndex = startIndex + itemsPerPage;
|
||||||
|
const paginatedRooms = filteredRooms.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
setRooms(paginatedRooms);
|
||||||
|
setTotalPages(Math.ceil(filteredRooms.length / itemsPerPage));
|
||||||
|
setTotalItems(filteredRooms.length);
|
||||||
|
setLoading(false);
|
||||||
|
} else if (!roomsLoading) {
|
||||||
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
setLoading(roomsLoading);
|
||||||
|
}
|
||||||
|
}, [contextRooms, filters, currentPage, itemsPerPage, roomsLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
@@ -51,24 +102,8 @@ const RoomManagementPage: React.FC = () => {
|
|||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Cancel previous request if exists
|
|
||||||
if (abortControllerRef.current) {
|
|
||||||
abortControllerRef.current.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new abort controller
|
|
||||||
abortControllerRef.current = new AbortController();
|
|
||||||
|
|
||||||
fetchRooms();
|
|
||||||
fetchAvailableAmenities();
|
fetchAvailableAmenities();
|
||||||
|
}, []);
|
||||||
// Cleanup: abort request on unmount
|
|
||||||
return () => {
|
|
||||||
if (abortControllerRef.current) {
|
|
||||||
abortControllerRef.current.abort();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [filters, currentPage]);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -132,23 +167,11 @@ const RoomManagementPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchRooms = async () => {
|
// Extract unique room types from context rooms
|
||||||
try {
|
useEffect(() => {
|
||||||
setLoading(true);
|
if (contextRooms.length > 0) {
|
||||||
const response = await roomService.getRooms({
|
|
||||||
...filters,
|
|
||||||
page: currentPage,
|
|
||||||
limit: itemsPerPage,
|
|
||||||
});
|
|
||||||
setRooms(response.data.rooms);
|
|
||||||
if (response.data.pagination) {
|
|
||||||
setTotalPages(response.data.pagination.totalPages);
|
|
||||||
setTotalItems(response.data.pagination.total);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const uniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
const uniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
||||||
response.data.rooms.forEach((room: Room) => {
|
contextRooms.forEach((room: Room) => {
|
||||||
if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) {
|
if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) {
|
||||||
uniqueRoomTypes.set(room.room_type.id, {
|
uniqueRoomTypes.set(room.room_type.id, {
|
||||||
id: room.room_type.id,
|
id: room.room_type.id,
|
||||||
@@ -157,60 +180,8 @@ const RoomManagementPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
setRoomTypes(Array.from(uniqueRoomTypes.values()));
|
setRoomTypes(Array.from(uniqueRoomTypes.values()));
|
||||||
|
|
||||||
|
|
||||||
if (roomTypes.length === 0 && response.data.pagination) {
|
|
||||||
try {
|
|
||||||
|
|
||||||
const allRoomsResponse = await roomService.getRooms({ limit: 100, page: 1 });
|
|
||||||
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
|
||||||
allRoomsResponse.data.rooms.forEach((room: Room) => {
|
|
||||||
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
|
|
||||||
allUniqueRoomTypes.set(room.room_type.id, {
|
|
||||||
id: room.room_type.id,
|
|
||||||
name: room.room_type.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
if (allRoomsResponse.data.pagination && allRoomsResponse.data.pagination.totalPages > 1) {
|
|
||||||
const totalPages = allRoomsResponse.data.pagination.totalPages;
|
|
||||||
for (let page = 2; page <= Math.min(totalPages, 10); page++) {
|
|
||||||
try {
|
|
||||||
const pageResponse = await roomService.getRooms({ limit: 100, page });
|
|
||||||
pageResponse.data.rooms.forEach((room: Room) => {
|
|
||||||
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
|
|
||||||
allUniqueRoomTypes.set(room.room_type.id, {
|
|
||||||
id: room.room_type.id,
|
|
||||||
name: room.room_type.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allUniqueRoomTypes.size > 0) {
|
|
||||||
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
// Handle AbortError silently
|
|
||||||
if (error.name === 'AbortError') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logger.error('Error fetching rooms', error);
|
|
||||||
toast.error(error.response?.data?.message || 'Unable to load rooms list');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
}, [contextRooms]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -226,13 +197,9 @@ const RoomManagementPage: React.FC = () => {
|
|||||||
view: formData.view || undefined,
|
view: formData.view || undefined,
|
||||||
amenities: Array.isArray(formData.amenities) ? formData.amenities : [],
|
amenities: Array.isArray(formData.amenities) ? formData.amenities : [],
|
||||||
};
|
};
|
||||||
await roomService.updateRoom(editingRoom.id, updateData);
|
await contextUpdateRoom(editingRoom.id, updateData);
|
||||||
toast.success('Room updated successfully');
|
|
||||||
|
|
||||||
|
|
||||||
await fetchRooms();
|
|
||||||
|
|
||||||
|
|
||||||
|
// Refresh room details for editing
|
||||||
try {
|
try {
|
||||||
const updatedRoom = await roomService.getRoomByNumber(editingRoom.room_number);
|
const updatedRoom = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||||
setEditingRoom(updatedRoom.data.room);
|
setEditingRoom(updatedRoom.data.room);
|
||||||
@@ -251,8 +218,6 @@ const RoomManagementPage: React.FC = () => {
|
|||||||
amenities: Array.isArray(formData.amenities) ? formData.amenities : [],
|
amenities: Array.isArray(formData.amenities) ? formData.amenities : [],
|
||||||
};
|
};
|
||||||
const response = await roomService.createRoom(createData);
|
const response = await roomService.createRoom(createData);
|
||||||
toast.success('Room added successfully');
|
|
||||||
|
|
||||||
|
|
||||||
if (response.data?.room) {
|
if (response.data?.room) {
|
||||||
|
|
||||||
@@ -307,7 +272,7 @@ const RoomManagementPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
await fetchRooms();
|
await contextCreateRoom(createData);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -315,7 +280,7 @@ const RoomManagementPage: React.FC = () => {
|
|||||||
|
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
fetchRooms();
|
await refreshRooms();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.response?.data?.message || 'An error occurred');
|
toast.error(error.response?.data?.message || 'An error occurred');
|
||||||
}
|
}
|
||||||
@@ -406,12 +371,10 @@ const RoomManagementPage: React.FC = () => {
|
|||||||
if (!window.confirm('Are you sure you want to delete this room?')) return;
|
if (!window.confirm('Are you sure you want to delete this room?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await roomService.deleteRoom(id);
|
await contextDeleteRoom(id);
|
||||||
toast.success('Room deleted successfully');
|
|
||||||
setSelectedRooms(selectedRooms.filter(roomId => roomId !== id));
|
setSelectedRooms(selectedRooms.filter(roomId => roomId !== id));
|
||||||
fetchRooms();
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.response?.data?.message || 'Unable to delete room');
|
// Error already handled in context
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -427,7 +390,7 @@ const RoomManagementPage: React.FC = () => {
|
|||||||
await roomService.bulkDeleteRooms(selectedRooms);
|
await roomService.bulkDeleteRooms(selectedRooms);
|
||||||
toast.success(`Successfully deleted ${selectedRooms.length} room(s)`);
|
toast.success(`Successfully deleted ${selectedRooms.length} room(s)`);
|
||||||
setSelectedRooms([]);
|
setSelectedRooms([]);
|
||||||
fetchRooms();
|
await refreshRooms();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to delete rooms');
|
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to delete rooms');
|
||||||
}
|
}
|
||||||
@@ -502,7 +465,7 @@ const RoomManagementPage: React.FC = () => {
|
|||||||
|
|
||||||
toast.success('Images uploaded successfully');
|
toast.success('Images uploaded successfully');
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
fetchRooms();
|
await refreshRooms();
|
||||||
|
|
||||||
|
|
||||||
const response = await roomService.getRoomByNumber(editingRoom.room_number);
|
const response = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||||
@@ -540,7 +503,7 @@ const RoomManagementPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast.success('Image deleted successfully');
|
toast.success('Image deleted successfully');
|
||||||
fetchRooms();
|
await refreshRooms();
|
||||||
|
|
||||||
|
|
||||||
const response = await roomService.getRoomByNumber(editingRoom.room_number);
|
const response = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||||
|
|||||||
@@ -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