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

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""
Script to add the 'housekeeping' role to the database.
Run this script once to create the housekeeping role if it doesn't exist.
"""
import sys
import os
from pathlib import Path
# Add the Backend directory to the path
backend_dir = Path(__file__).parent.parent
sys.path.insert(0, str(backend_dir))
from src.shared.config.database import SessionLocal
from src.models import Role # Use the models __init__ which handles all imports
def add_housekeeping_role():
"""Add the housekeeping role to the database if it doesn't exist."""
db = SessionLocal()
try:
# Check if housekeeping role already exists
existing_role = db.query(Role).filter(Role.name == 'housekeeping').first()
if existing_role:
print("✓ Housekeeping role already exists in the database.")
print(f" Role ID: {existing_role.id}")
print(f" Role Name: {existing_role.name}")
return
# Create the housekeeping role
housekeeping_role = Role(
name='housekeeping',
description='Housekeeping staff role with access to room cleaning tasks and status updates'
)
db.add(housekeeping_role)
db.commit()
db.refresh(housekeeping_role)
print("✓ Housekeeping role created successfully!")
print(f" Role ID: {housekeeping_role.id}")
print(f" Role Name: {housekeeping_role.name}")
print(f" Description: {housekeeping_role.description}")
except Exception as e:
db.rollback()
print(f"✗ Error creating housekeeping role: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
finally:
db.close()
if __name__ == '__main__':
print("Adding housekeeping role to the database...")
print("=" * 60)
add_housekeeping_role()
print("=" * 60)
print("Done!")

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""
Script to add a housekeeping user to the database.
"""
import sys
import os
from pathlib import Path
# Add the Backend directory to the path
backend_dir = Path(__file__).parent.parent
sys.path.insert(0, str(backend_dir))
from src.shared.config.database import SessionLocal
from src.models import Role, User
import bcrypt
def add_housekeeping_user():
"""Add the housekeeping user to the database if it doesn't exist."""
db = SessionLocal()
try:
# Get housekeeping role
housekeeping_role = db.query(Role).filter(Role.name == 'housekeeping').first()
if not housekeeping_role:
print("✗ Housekeeping role not found! Please run add_housekeeping_role.py first.")
sys.exit(1)
# Check if user already exists
existing_user = db.query(User).filter(User.email == 'housekeeping@gnxsoft.com').first()
if existing_user:
print("✓ Housekeeping user already exists in the database.")
print(f" User ID: {existing_user.id}")
print(f" Email: {existing_user.email}")
print(f" Full Name: {existing_user.full_name}")
print(f" Role: {existing_user.role.name if existing_user.role else 'N/A'}")
return
# Hash password
password = 'P4eli240453.'
password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt()
hashed_password = bcrypt.hashpw(password_bytes, salt).decode('utf-8')
# Create the housekeeping user
housekeeping_user = User(
email='housekeeping@gnxsoft.com',
password=hashed_password,
full_name='Housekeeping Staff',
role_id=housekeeping_role.id,
is_active=True,
currency='EUR'
)
db.add(housekeeping_user)
db.commit()
db.refresh(housekeeping_user)
print("✓ Housekeeping user created successfully!")
print(f" User ID: {housekeeping_user.id}")
print(f" Email: {housekeeping_user.email}")
print(f" Full Name: {housekeeping_user.full_name}")
print(f" Role: {housekeeping_role.name}")
print(f" Password: P4eli240453.")
except Exception as e:
db.rollback()
print(f"✗ Error creating housekeeping user: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
finally:
db.close()
if __name__ == '__main__':
print("Adding housekeeping user to the database...")
print("=" * 60)
add_housekeeping_user()
print("=" * 60)
print("Done!")

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
Script to assign test housekeeping tasks to the housekeeping user.
This creates sample tasks for testing the housekeeping dashboard.
"""
import sys
import os
from pathlib import Path
from datetime import datetime, timedelta
# Add the Backend directory to the path
backend_dir = Path(__file__).parent.parent
sys.path.insert(0, str(backend_dir))
from src.shared.config.database import SessionLocal
from src.models import Role, User, Room
from src.hotel_services.models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
def assign_housekeeping_tasks():
"""Assign test housekeeping tasks to the housekeeping user."""
db = SessionLocal()
try:
# Get housekeeping user
housekeeping_user = db.query(User).join(Role).filter(
Role.name == 'housekeeping',
User.email == 'housekeeping@gnxsoft.com'
).first()
if not housekeeping_user:
print("✗ Housekeeping user not found! Please run add_housekeeping_user.py first.")
sys.exit(1)
print(f"✓ Found housekeeping user: {housekeeping_user.email} (ID: {housekeeping_user.id})")
# Get admin user for created_by
admin_role = db.query(Role).filter(Role.name == 'admin').first()
admin_user = db.query(User).filter(User.role_id == admin_role.id).first() if admin_role else None
# Get some rooms
rooms = db.query(Room).limit(5).all()
if not rooms:
print("✗ No rooms found in the database! Please seed rooms first.")
sys.exit(1)
print(f"✓ Found {len(rooms)} rooms")
# Default checklist items for different task types
checklists = {
'checkout': [
{'item': 'Bathroom cleaned', 'completed': False, 'notes': ''},
{'item': 'Beds made with fresh linens', 'completed': False, 'notes': ''},
{'item': 'Trash emptied', 'completed': False, 'notes': ''},
{'item': 'Towels replaced', 'completed': False, 'notes': ''},
{'item': 'Amenities restocked', 'completed': False, 'notes': ''},
{'item': 'Floor vacuumed and mopped', 'completed': False, 'notes': ''},
{'item': 'Surfaces dusted', 'completed': False, 'notes': ''},
{'item': 'Windows and mirrors cleaned', 'completed': False, 'notes': ''},
],
'stayover': [
{'item': 'Beds made', 'completed': False, 'notes': ''},
{'item': 'Trash emptied', 'completed': False, 'notes': ''},
{'item': 'Towels replaced', 'completed': False, 'notes': ''},
{'item': 'Bathroom cleaned', 'completed': False, 'notes': ''},
],
'vacant': [
{'item': 'Deep clean bathroom', 'completed': False, 'notes': ''},
{'item': 'Change linens', 'completed': False, 'notes': ''},
{'item': 'Vacuum and mop', 'completed': False, 'notes': ''},
{'item': 'Dust surfaces', 'completed': False, 'notes': ''},
{'item': 'Check amenities', 'completed': False, 'notes': ''},
],
'inspection': [
{'item': 'Check all amenities', 'completed': False, 'notes': ''},
{'item': 'Test electronics', 'completed': False, 'notes': ''},
{'item': 'Check for damages', 'completed': False, 'notes': ''},
{'item': 'Verify cleanliness', 'completed': False, 'notes': ''},
],
}
# Create tasks for today
today = datetime.utcnow().replace(hour=9, minute=0, second=0, microsecond=0)
task_types = ['checkout', 'stayover', 'vacant', 'inspection']
created_count = 0
skipped_count = 0
for i, room in enumerate(rooms):
# Cycle through task types
task_type = task_types[i % len(task_types)]
# Check if task already exists for this room today
existing_task = db.query(HousekeepingTask).filter(
HousekeepingTask.room_id == room.id,
HousekeepingTask.assigned_to == housekeeping_user.id,
HousekeepingTask.status == HousekeepingStatus.pending,
HousekeepingTask.scheduled_time >= today.replace(hour=0, minute=0),
HousekeepingTask.scheduled_time < today.replace(hour=23, minute=59, second=59)
).first()
if existing_task:
print(f" ⚠️ Task already exists for Room {room.room_number}, skipping...")
skipped_count += 1
continue
# Schedule tasks at different times throughout the day
scheduled_time = today + timedelta(hours=i)
task = HousekeepingTask(
room_id=room.id,
booking_id=None,
task_type=HousekeepingType(task_type),
status=HousekeepingStatus.pending,
scheduled_time=scheduled_time,
assigned_to=housekeeping_user.id,
created_by=admin_user.id if admin_user else housekeeping_user.id,
checklist_items=checklists.get(task_type, []),
notes=f'Test task for Room {room.room_number} - {task_type} cleaning',
estimated_duration_minutes=45 if task_type == 'checkout' else 30
)
db.add(task)
created_count += 1
print(f" ✓ Created {task_type} task for Room {room.room_number} (scheduled: {scheduled_time.strftime('%Y-%m-%d %H:%M')})")
db.commit()
print(f"\n✓ Tasks assigned successfully!")
print(f" - Created: {created_count} task(s)")
print(f" - Skipped: {skipped_count} task(s) (already exist)")
print(f" - Assigned to: {housekeeping_user.email}")
except Exception as e:
db.rollback()
print(f"✗ Error assigning housekeeping tasks: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
finally:
db.close()
if __name__ == '__main__':
print("Assigning test housekeeping tasks to housekeeping user...")
print("=" * 60)
assign_housekeeping_tasks()
print("=" * 60)
print("Done!")

View File

@@ -26,7 +26,8 @@ def seed_roles(db: Session):
{'name': 'admin', 'description': 'Administrator with full access'},
{'name': 'staff', 'description': 'Staff member with limited admin access'},
{'name': 'customer', 'description': 'Regular customer'},
{'name': 'accountant', 'description': 'Accountant role with access to financial data, payments, and invoices'}
{'name': 'accountant', 'description': 'Accountant role with access to financial data, payments, and invoices'},
{'name': 'housekeeping', 'description': 'Housekeeping staff role with access to room cleaning tasks and status updates'}
]
for role_data in roles_data:

View File

@@ -730,6 +730,46 @@ async def update_booking(id: int, booking_data: UpdateBookingRequest, current_us
room.status = RoomStatus.maintenance
else:
room.status = RoomStatus.cleaning
# Auto-create housekeeping task for checkout cleaning
from ...hotel_services.models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
# Check if a pending checkout task already exists for this room
existing_task = db.query(HousekeepingTask).filter(
and_(
HousekeepingTask.room_id == room.id,
HousekeepingTask.booking_id == booking.id,
HousekeepingTask.task_type == HousekeepingType.checkout,
HousekeepingTask.status.in_([HousekeepingStatus.pending, HousekeepingStatus.in_progress])
)
).first()
if not existing_task:
# Default checklist items for checkout cleaning
checkout_checklist = [
{'item': 'Bathroom cleaned', 'completed': False, 'notes': ''},
{'item': 'Beds made with fresh linens', 'completed': False, 'notes': ''},
{'item': 'Trash emptied', 'completed': False, 'notes': ''},
{'item': 'Towels replaced', 'completed': False, 'notes': ''},
{'item': 'Amenities restocked', 'completed': False, 'notes': ''},
{'item': 'Floor vacuumed and mopped', 'completed': False, 'notes': ''},
{'item': 'Surfaces dusted', 'completed': False, 'notes': ''},
{'item': 'Windows and mirrors cleaned', 'completed': False, 'notes': ''},
]
housekeeping_task = HousekeepingTask(
room_id=room.id,
booking_id=booking.id,
task_type=HousekeepingType.checkout,
status=HousekeepingStatus.pending,
scheduled_time=datetime.utcnow(), # Schedule immediately
created_by=current_user.id,
checklist_items=checkout_checklist,
notes=f'Auto-created on checkout for booking {booking.booking_number}',
estimated_duration_minutes=45 # Default 45 minutes for checkout cleaning
)
db.add(housekeeping_task)
db.flush() # Flush to get the task ID if needed for notifications
elif new_status == BookingStatus.cancelled:
# Update room status when booking is cancelled
if booking.payments:

View File

@@ -346,19 +346,20 @@ async def get_housekeeping_tasks(
date: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(authorize_roles('admin', 'staff')),
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
db: Session = Depends(get_db)
):
"""Get housekeeping tasks with filtering"""
try:
# Check if user is staff (not admin) - staff should only see their assigned tasks
# Check user role - housekeeping and staff users should only see their assigned tasks
role = db.query(Role).filter(Role.id == current_user.role_id).first()
is_staff = role and role.name == 'staff'
is_admin = role and role.name == 'admin'
is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff')
query = db.query(HousekeepingTask)
# Filter by assigned_to for staff users
if is_staff:
# Filter by assigned_to for housekeeping and staff users (not admin)
if is_housekeeping_or_staff:
query = query.filter(HousekeepingTask.assigned_to == current_user.id)
if room_id:
@@ -488,7 +489,7 @@ async def create_housekeeping_task(
async def update_housekeeping_task(
task_id: int,
task_data: dict,
current_user: User = Depends(authorize_roles('admin', 'staff')),
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
db: Session = Depends(get_db)
):
"""Update a housekeeping task"""
@@ -497,22 +498,23 @@ async def update_housekeeping_task(
if not task:
raise HTTPException(status_code=404, detail='Housekeeping task not found')
# Check if user is staff (not admin) - staff can only update their own assigned tasks
# Check user role - housekeeping and staff users can only update their own assigned tasks
role = db.query(Role).filter(Role.id == current_user.role_id).first()
is_staff = role and role.name == 'staff'
is_admin = role and role.name == 'admin'
is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff')
if is_staff:
# Staff can only update tasks assigned to them
if is_housekeeping_or_staff:
# Housekeeping and staff can only update tasks assigned to them
if task.assigned_to != current_user.id:
raise HTTPException(status_code=403, detail='You can only update tasks assigned to you')
# Staff cannot change assignment
# Housekeeping and staff cannot change assignment
if 'assigned_to' in task_data and task_data.get('assigned_to') != task.assigned_to:
raise HTTPException(status_code=403, detail='You cannot change task assignment')
old_assigned_to = task.assigned_to
assigned_to_changed = False
if 'assigned_to' in task_data and not is_staff:
if 'assigned_to' in task_data and is_admin:
new_assigned_to = task_data.get('assigned_to')
if new_assigned_to != old_assigned_to:
task.assigned_to = new_assigned_to
@@ -538,6 +540,37 @@ async def update_housekeeping_task(
duration = (task.completed_at - task.started_at).total_seconds() / 60
task.actual_duration_minutes = int(duration)
# Update room status when housekeeping task is completed
room = db.query(Room).filter(Room.id == task.room_id).first()
if room:
# Check if there are other pending housekeeping tasks for this room
pending_tasks = db.query(HousekeepingTask).filter(
and_(
HousekeepingTask.room_id == room.id,
HousekeepingTask.id != task.id,
HousekeepingTask.status.in_([HousekeepingStatus.pending, HousekeepingStatus.in_progress])
)
).count()
# Check if there's active maintenance
from ...rooms.models.room_maintenance import RoomMaintenance, MaintenanceStatus
active_maintenance = db.query(RoomMaintenance).filter(
and_(
RoomMaintenance.room_id == room.id,
RoomMaintenance.blocks_room == True,
RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress])
)
).first()
if active_maintenance:
room.status = RoomStatus.maintenance
elif pending_tasks > 0:
# Keep room as cleaning if there are other pending tasks
room.status = RoomStatus.cleaning
else:
# No pending tasks and no maintenance - room is ready
room.status = RoomStatus.available
if 'checklist_items' in task_data:
task.checklist_items = task_data['checklist_items']
if 'notes' in task_data:

View File

@@ -72,6 +72,26 @@ async def get_amenities(db: Session=Depends(get_db)):
logger.error(f'Error fetching amenities: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get('/room-types')
async def get_room_types(db: Session=Depends(get_db)):
"""Get all room types for dropdowns and forms."""
try:
room_types = db.query(RoomType).order_by(RoomType.name).all()
room_types_list = [
{
'id': rt.id,
'name': rt.name,
'description': rt.description,
'base_price': float(rt.base_price) if rt.base_price else 0.0,
'capacity': rt.capacity,
}
for rt in room_types
]
return {'status': 'success', 'data': {'room_types': room_types_list}}
except Exception as e:
logger.error(f'Error fetching room types: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get('/available')
async def search_available_rooms(request: Request, from_date: str=Query(..., alias='from'), to_date: str=Query(..., alias='to'), roomId: Optional[int]=Query(None, alias='roomId'), type: Optional[str]=Query(None), capacity: Optional[int]=Query(None), page: int=Query(1, ge=1), limit: int=Query(12, ge=1, le=100), db: Session=Depends(get_db)):
try:
@@ -215,19 +235,17 @@ async def get_room_by_number(room_number: str, request: Request, db: Session=Dep
@router.post('/', dependencies=[Depends(authorize_roles('admin'))])
async def create_room(room_data: CreateRoomRequest, request: Request, current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)):
"""Create a new room with validated input using Pydantic schema."""
# Start transaction
transaction = db.begin()
try:
# Lock room type to prevent race conditions
room_type = db.query(RoomType).filter(RoomType.id == room_data.room_type_id).with_for_update().first()
if not room_type:
transaction.rollback()
db.rollback()
raise HTTPException(status_code=404, detail='Room type not found')
# Check for duplicate room number with locking
existing = db.query(Room).filter(Room.room_number == room_data.room_number).with_for_update().first()
if existing:
transaction.rollback()
db.rollback()
raise HTTPException(status_code=400, detail='Room number already exists')
# Use price from request or default to room type base price
@@ -250,7 +268,7 @@ async def create_room(room_data: CreateRoomRequest, request: Request, current_us
db.flush()
# Commit transaction
transaction.commit()
db.commit()
db.refresh(room)
base_url = get_base_url(request)
room_dict = {'id': room.id, 'room_type_id': room.room_type_id, 'room_number': room.room_number, 'floor': room.floor, 'status': room.status.value if isinstance(room.status, RoomStatus) else room.status, 'price': float(room.price) if room.price is not None and room.price > 0 else None, 'featured': room.featured, 'description': room.description, 'capacity': room.capacity, 'room_size': room.room_size, 'view': room.view, 'amenities': room.amenities if room.amenities else [], 'created_at': room.created_at.isoformat() if room.created_at else None, 'updated_at': room.updated_at.isoformat() if room.updated_at else None}
@@ -262,36 +280,31 @@ async def create_room(room_data: CreateRoomRequest, request: Request, current_us
room_dict['room_type'] = {'id': room.room_type.id, 'name': room.room_type.name, 'description': room.room_type.description, 'base_price': float(room.room_type.base_price) if room.room_type.base_price else 0.0, 'capacity': room.room_type.capacity, 'amenities': room.room_type.amenities if room.room_type.amenities else [], 'images': []}
return success_response(data={'room': room_dict}, message='Room created successfully')
except HTTPException:
if 'transaction' in locals():
transaction.rollback()
db.rollback()
raise
except IntegrityError as e:
if 'transaction' in locals():
transaction.rollback()
db.rollback()
logger.error(f'Database integrity error during room creation: {str(e)}')
raise HTTPException(status_code=409, detail='Room conflict detected. Please check room number.')
except Exception as e:
if 'transaction' in locals():
transaction.rollback()
db.rollback()
logger.error(f'Error creating room: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while creating the room')
@router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))])
async def update_room(id: int, room_data: UpdateRoomRequest, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
"""Update a room with validated input using Pydantic schema."""
# Start transaction
transaction = db.begin()
try:
# Lock room row to prevent race conditions
room = db.query(Room).filter(Room.id == id).with_for_update().first()
if not room:
transaction.rollback()
db.rollback()
raise HTTPException(status_code=404, detail='Room not found')
if room_data.room_type_id:
room_type = db.query(RoomType).filter(RoomType.id == room_data.room_type_id).first()
if not room_type:
transaction.rollback()
db.rollback()
raise HTTPException(status_code=404, detail='Room type not found')
room.room_type_id = room_data.room_type_id
@@ -299,7 +312,7 @@ async def update_room(id: int, room_data: UpdateRoomRequest, request: Request, c
# Check for duplicate room number
existing = db.query(Room).filter(Room.room_number == room_data.room_number, Room.id != id).first()
if existing:
transaction.rollback()
db.rollback()
raise HTTPException(status_code=400, detail='Room number already exists')
room.room_number = room_data.room_number
@@ -323,7 +336,7 @@ async def update_room(id: int, room_data: UpdateRoomRequest, request: Request, c
room.amenities = room_data.amenities or []
# Commit transaction
transaction.commit()
db.commit()
db.refresh(room)
base_url = get_base_url(request)
@@ -359,17 +372,14 @@ async def update_room(id: int, room_data: UpdateRoomRequest, request: Request, c
}
return success_response(data={'room': room_dict}, message='Room updated successfully')
except HTTPException:
if 'transaction' in locals():
transaction.rollback()
db.rollback()
raise
except IntegrityError as e:
if 'transaction' in locals():
transaction.rollback()
db.rollback()
logger.error(f'Database integrity error during room update: {str(e)}')
raise HTTPException(status_code=409, detail='Room conflict detected. Please check room number.')
except Exception as e:
if 'transaction' in locals():
transaction.rollback()
db.rollback()
logger.error(f'Error updating room: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while updating the room')
@@ -391,8 +401,6 @@ async def delete_room(id: int, current_user: User=Depends(authorize_roles('admin
@router.post('/bulk-delete', dependencies=[Depends(authorize_roles('admin'))])
async def bulk_delete_rooms(room_ids: BulkDeleteRoomsRequest, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
"""Bulk delete rooms with validated input using Pydantic schema."""
# Start transaction
transaction = db.begin()
try:
ids = room_ids.room_ids
@@ -401,30 +409,27 @@ async def bulk_delete_rooms(room_ids: BulkDeleteRoomsRequest, current_user: User
found_ids = [room.id for room in rooms]
not_found_ids = [id for id in ids if id not in found_ids]
if not_found_ids:
transaction.rollback()
db.rollback()
raise HTTPException(status_code=404, detail=f'Rooms with IDs {not_found_ids} not found')
# Delete rooms
deleted_count = db.query(Room).filter(Room.id.in_(ids)).delete(synchronize_session=False)
# Commit transaction
transaction.commit()
db.commit()
return success_response(
data={'deleted_count': deleted_count, 'deleted_ids': ids},
message=f'Successfully deleted {deleted_count} room(s)'
)
except HTTPException:
if 'transaction' in locals():
transaction.rollback()
db.rollback()
raise
except IntegrityError as e:
if 'transaction' in locals():
transaction.rollback()
db.rollback()
logger.error(f'Database integrity error during bulk room deletion: {str(e)}')
raise HTTPException(status_code=409, detail='Cannot delete rooms due to existing relationships (bookings, etc.)')
except Exception as e:
if 'transaction' in locals():
transaction.rollback()
db.rollback()
logger.error(f'Error bulk deleting rooms: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while deleting rooms')

View File

@@ -14,6 +14,7 @@ import { CurrencyProvider } from './features/payments/contexts/CurrencyContext';
import { CompanySettingsProvider } from './shared/contexts/CompanySettingsContext';
import { AuthModalProvider } from './features/auth/contexts/AuthModalContext';
import { AntibotProvider } from './features/auth/contexts/AntibotContext';
import { RoomProvider } from './features/rooms/contexts/RoomContext';
import { logDebug } from './shared/utils/errorReporter';
import OfflineIndicator from './shared/components/OfflineIndicator';
import CookieConsentBanner from './shared/components/CookieConsentBanner';
@@ -37,7 +38,8 @@ import {
AdminRoute,
StaffRoute,
AccountantRoute,
CustomerRoute
CustomerRoute,
HousekeepingRoute
} from './features/auth/components';
const HomePage = lazy(() => import('./features/content/pages/HomePage'));
@@ -122,6 +124,11 @@ const AccountantPaymentManagementPage = lazy(() => import('./pages/accountant/Pa
const AccountantInvoiceManagementPage = lazy(() => import('./pages/accountant/InvoiceManagementPage'));
const AccountantAnalyticsDashboardPage = lazy(() => import('./pages/accountant/AnalyticsDashboardPage'));
const AccountantLayout = lazy(() => import('./pages/AccountantLayout'));
const HousekeepingDashboardPage = lazy(() => import('./pages/housekeeping/DashboardPage'));
const HousekeepingTasksPage = lazy(() => import('./pages/housekeeping/TasksPage'));
const HousekeepingLayout = lazy(() => import('./pages/HousekeepingLayout'));
const AdminProfilePage = lazy(() => import('./pages/admin/ProfilePage'));
const StaffProfilePage = lazy(() => import('./pages/staff/ProfilePage'));
const AccountantProfilePage = lazy(() => import('./pages/accountant/ProfilePage'));
@@ -210,6 +217,7 @@ function App() {
<CompanySettingsProvider>
<AntibotProvider>
<AuthModalProvider>
<RoomProvider>
<BrowserRouter
future={{
v7_startTransition: true,
@@ -709,6 +717,24 @@ function App() {
/>
</Route>
{/* Housekeeping Routes */}
<Route
path="/housekeeping"
element={
<ErrorBoundaryRoute>
<HousekeepingRoute>
<HousekeepingLayout />
</HousekeepingRoute>
</ErrorBoundaryRoute>
}
>
<Route
index
element={<Navigate to="dashboard" replace />}
/>
<Route path="dashboard" element={<HousekeepingDashboardPage />} />
</Route>
{}
<Route
path="*"
@@ -737,6 +763,7 @@ function App() {
<AuthModalManager />
</Suspense>
</BrowserRouter>
</RoomProvider>
</AuthModalProvider>
</AntibotProvider>
</CompanySettingsProvider>

View File

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

View File

@@ -0,0 +1,52 @@
import React, { useEffect } from 'react';
import { Navigate } from 'react-router-dom';
import useAuthStore from '../../../store/useAuthStore';
import { useAuthModal } from '../contexts/AuthModalContext';
interface HousekeepingRouteProps {
children: React.ReactNode;
}
const HousekeepingRoute: React.FC<HousekeepingRouteProps> = ({ children }) => {
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
const { openModal } = useAuthModal();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
openModal('login');
}
}, [isLoading, isAuthenticated, openModal]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto" />
<p className="mt-4 text-gray-600">Authenticating...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null; // Modal will be shown by AuthModalManager
}
// Only allow housekeeping role - no admin or staff access
if (userInfo?.role !== 'housekeeping') {
// Redirect to appropriate dashboard based on role
if (userInfo?.role === 'admin') {
return <Navigate to="/admin/dashboard" replace />;
} else if (userInfo?.role === 'staff') {
return <Navigate to="/staff/dashboard" replace />;
} else if (userInfo?.role === 'accountant') {
return <Navigate to="/accountant/dashboard" replace />;
}
return <Navigate to="/" replace />;
}
return <>{children}</>;
};
export default HousekeepingRoute;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,316 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
import roomService, { Room } from '../services/roomService';
import advancedRoomService, { RoomStatusBoardItem } from '../services/advancedRoomService';
import { toast } from 'react-toastify';
import { logger } from '../../../shared/utils/logger';
interface RoomContextType {
// Room list state
rooms: Room[];
roomsLoading: boolean;
roomsError: string | null;
// Room status board state
statusBoardRooms: RoomStatusBoardItem[];
statusBoardLoading: boolean;
statusBoardError: string | null;
// Actions
refreshRooms: () => Promise<void>;
refreshStatusBoard: (floor?: number) => Promise<void>;
updateRoom: (roomId: number, updates: Partial<Room>) => Promise<void>;
deleteRoom: (roomId: number) => Promise<void>;
createRoom: (roomData: Partial<Room> & { room_number: string; floor: number; room_type_id: number; status: 'available' | 'occupied' | 'maintenance' }) => Promise<void>;
// Filters and pagination
setRoomFilters: (filters: { search?: string; status?: string; type?: string }) => void;
roomFilters: { search: string; status: string; type: string };
setRoomPage: (page: number) => void;
roomPage: number;
// Status board filters
setStatusBoardFloor: (floor: number | null) => void;
statusBoardFloor: number | null;
// Last update timestamp for synchronization
lastUpdate: number;
}
const RoomContext = createContext<RoomContextType | undefined>(undefined);
export const useRoomContext = () => {
const context = useContext(RoomContext);
if (!context) {
throw new Error('useRoomContext must be used within a RoomProvider');
}
return context;
};
interface RoomProviderProps {
children: React.ReactNode;
}
export const RoomProvider: React.FC<RoomProviderProps> = ({ children }) => {
// Room list state
const [rooms, setRooms] = useState<Room[]>([]);
const [roomsLoading, setRoomsLoading] = useState(false);
const [roomsError, setRoomsError] = useState<string | null>(null);
// Room status board state
const [statusBoardRooms, setStatusBoardRooms] = useState<RoomStatusBoardItem[]>([]);
const [statusBoardLoading, setStatusBoardLoading] = useState(false);
const [statusBoardError, setStatusBoardError] = useState<string | null>(null);
// Filters and pagination
const [roomFilters, setRoomFiltersState] = useState({ search: '', status: '', type: '' });
const [roomPage, setRoomPageState] = useState(1);
const [statusBoardFloor, setStatusBoardFloorState] = useState<number | null>(null);
// Last update timestamp
const [lastUpdate, setLastUpdate] = useState<number>(Date.now());
// Abort controllers for cleanup
const roomsAbortRef = useRef<AbortController | null>(null);
const statusBoardAbortRef = useRef<AbortController | null>(null);
// Auto-refresh interval (30 seconds)
const autoRefreshIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Refresh rooms list - fetch all rooms for better sync across components
const refreshRooms = useCallback(async () => {
// Cancel previous request
if (roomsAbortRef.current) {
roomsAbortRef.current.abort();
}
// Create new abort controller
roomsAbortRef.current = new AbortController();
try {
setRoomsLoading(true);
setRoomsError(null);
// Fetch rooms with reasonable pagination
// Individual components can handle their own pagination/filtering
const response = await roomService.getRooms({
limit: 100, // Reasonable batch size for sync
page: 1,
});
if (response.data?.rooms) {
setRooms(response.data.rooms);
setLastUpdate(Date.now());
}
} catch (error: any) {
if (error.name === 'AbortError') {
return;
}
logger.error('Error refreshing rooms', error);
setRoomsError(error.response?.data?.message || 'Failed to refresh rooms');
// Don't show toast on every auto-refresh, only on manual refresh
} finally {
setRoomsLoading(false);
}
}, []);
// Refresh status board
const refreshStatusBoard = useCallback(async (floor?: number) => {
// Cancel previous request
if (statusBoardAbortRef.current) {
statusBoardAbortRef.current.abort();
}
// Create new abort controller
statusBoardAbortRef.current = new AbortController();
try {
setStatusBoardLoading(true);
setStatusBoardError(null);
const response = await advancedRoomService.getRoomStatusBoard(floor || statusBoardFloor || undefined);
if (response.status === 'success' && response.data?.rooms) {
setStatusBoardRooms(response.data.rooms);
setLastUpdate(Date.now());
}
} catch (error: any) {
if (error.name === 'AbortError') {
return;
}
// Handle 401 Unauthorized gracefully - user may not have admin/staff role
if (error.response?.status === 401) {
setStatusBoardError(null); // Don't set error for unauthorized access
setStatusBoardRooms([]); // Clear status board if unauthorized
return; // Silently return without logging
}
logger.error('Error refreshing status board', error);
setStatusBoardError(error.response?.data?.detail || 'Failed to refresh status board');
// Don't show toast on every auto-refresh, only on manual refresh
} finally {
setStatusBoardLoading(false);
}
}, [statusBoardFloor]);
// Update room
const updateRoom = useCallback(async (roomId: number, updates: Partial<Room>) => {
try {
await roomService.updateRoom(roomId, updates);
toast.success('Room updated successfully');
// Refresh both views to ensure they're in sync
await Promise.all([
refreshRooms(),
refreshStatusBoard(),
]);
} catch (error: any) {
logger.error('Error updating room', error);
toast.error(error.response?.data?.message || 'Failed to update room');
throw error;
}
}, [refreshRooms, refreshStatusBoard]);
// Delete room
const deleteRoom = useCallback(async (roomId: number) => {
try {
await roomService.deleteRoom(roomId);
toast.success('Room deleted successfully');
// Refresh both views
await Promise.all([
refreshRooms(),
refreshStatusBoard(),
]);
} catch (error: any) {
logger.error('Error deleting room', error);
toast.error(error.response?.data?.message || 'Failed to delete room');
throw error;
}
}, [refreshRooms, refreshStatusBoard]);
// Create room
const createRoom = useCallback(async (roomData: Partial<Room> & { room_number: string; floor: number; room_type_id: number; status: 'available' | 'occupied' | 'maintenance' }) => {
try {
await roomService.createRoom(roomData as any);
toast.success('Room created successfully');
// Refresh both views
await Promise.all([
refreshRooms(),
refreshStatusBoard(),
]);
} catch (error: any) {
logger.error('Error creating room', error);
toast.error(error.response?.data?.message || 'Failed to create room');
throw error;
}
}, [refreshRooms, refreshStatusBoard]);
// Set room filters
const setRoomFilters = useCallback((filters: { search?: string; status?: string; type?: string }) => {
setRoomFiltersState(prev => ({ ...prev, ...filters }));
setRoomPageState(1); // Reset to first page when filters change
}, []);
// Set room page
const setRoomPage = useCallback((page: number) => {
setRoomPageState(page);
}, []);
// Set status board floor
const setStatusBoardFloor = useCallback((floor: number | null) => {
setStatusBoardFloorState(floor);
}, []);
// Initial load - only fetch rooms, status board will be fetched when needed
useEffect(() => {
refreshRooms();
// Don't fetch status board on initial load - it requires admin/staff role
// It will be fetched when the component that needs it mounts
}, []);
// Initial load and periodic refresh handled by auto-refresh interval
// Auto-refresh status board when floor changes (only if component using it is mounted)
// This will be called by AdvancedRoomManagementPage when floor filter changes
// Set up auto-refresh interval (every 60 seconds)
// Pause when tab is inactive to save resources
useEffect(() => {
let intervalId: NodeJS.Timeout | null = null;
const startRefresh = () => {
if (intervalId) clearInterval(intervalId);
intervalId = setInterval(() => {
if (!document.hidden) {
refreshRooms();
}
}, 60000); // 60 seconds
};
const handleVisibilityChange = () => {
if (document.hidden) {
// Pause refresh when tab is hidden
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
} else {
// Resume refresh when tab becomes visible
startRefresh();
// Immediate refresh when tab becomes visible
refreshRooms();
}
};
startRefresh();
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
if (intervalId) {
clearInterval(intervalId);
}
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [refreshRooms]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (roomsAbortRef.current) {
roomsAbortRef.current.abort();
}
if (statusBoardAbortRef.current) {
statusBoardAbortRef.current.abort();
}
if (autoRefreshIntervalRef.current) {
clearInterval(autoRefreshIntervalRef.current);
}
};
}, []);
const value: RoomContextType = {
rooms,
roomsLoading,
roomsError,
statusBoardRooms,
statusBoardLoading,
statusBoardError,
refreshRooms,
refreshStatusBoard,
updateRoom,
deleteRoom,
createRoom,
setRoomFilters,
roomFilters,
setRoomPage,
roomPage,
setStatusBoardFloor,
statusBoardFloor,
lastUpdate,
};
return <RoomContext.Provider value={value}>{children}</RoomContext.Provider>;
};

View File

@@ -169,6 +169,28 @@ export const getAmenities = async (): Promise<{
};
};
export interface RoomType {
id: number;
name: string;
description: string;
base_price: number;
capacity: number;
}
export const getRoomTypes = async (): Promise<{
success: boolean;
status?: string;
data: { room_types: RoomType[] };
}> => {
const response = await apiClient.get('/rooms/room-types');
const data = response.data;
return {
success: data.status === 'success' || data.success === true,
status: data.status,
data: data.data || { room_types: [] },
};
};
export interface CreateRoomData {
room_number: string;
floor: number;
@@ -242,6 +264,7 @@ export default {
getRoomByNumber,
searchAvailableRooms,
getAmenities,
getRoomTypes,
createRoom,
updateRoom,
deleteRoom,

View File

@@ -0,0 +1,123 @@
import React from 'react';
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
LogOut,
Menu,
X,
} from 'lucide-react';
import useAuthStore from '../store/useAuthStore';
import { useResponsive } from '../shared/hooks/useResponsive';
const HousekeepingLayout: React.FC = () => {
const { isMobile } = useResponsive();
const [sidebarOpen, setSidebarOpen] = React.useState(!isMobile);
const location = useLocation();
const navigate = useNavigate();
const { userInfo, logout } = useAuthStore();
const handleLogout = async () => {
try {
await logout();
navigate('/');
} catch (error) {
console.error('Logout error:', error);
}
};
const navigation = [
{ name: 'Dashboard', href: '/housekeeping/dashboard', icon: LayoutDashboard },
];
const isActive = (path: string) => location.pathname === path;
return (
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
{/* Mobile menu button */}
{isMobile && (
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="fixed top-4 left-4 z-50 p-2 bg-white rounded-lg shadow-lg"
>
{sidebarOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
)}
{/* Sidebar */}
<div
className={`
fixed lg:static inset-y-0 left-0 z-40
w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}
>
<div className="flex flex-col h-full">
{/* Logo/Brand */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div className="flex items-center space-x-2">
<LayoutDashboard className="w-8 h-8 text-blue-600" />
<span className="text-xl font-bold text-gray-900">Housekeeping</span>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2">
{navigation.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.name}
to={item.href}
onClick={() => isMobile && setSidebarOpen(false)}
className={`
flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors
${isActive(item.href)
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-700 hover:bg-gray-50'
}
`}
>
<Icon className="w-5 h-5" />
<span>{item.name}</span>
</Link>
);
})}
</nav>
{/* User info and logout */}
<div className="p-4 border-t border-gray-200">
<div className="mb-3 px-4 py-2">
<p className="text-sm font-medium text-gray-900">{userInfo?.name || userInfo?.email || 'User'}</p>
<p className="text-xs text-gray-500 capitalize">{userInfo?.role || 'housekeeping'}</p>
</div>
<button
onClick={handleLogout}
className="w-full flex items-center space-x-3 px-4 py-2 text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
>
<LogOut className="w-5 h-5" />
<span>Logout</span>
</button>
</div>
</div>
</div>
{/* Overlay for mobile */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-30"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Main content */}
<div className="flex-1 overflow-auto lg:ml-0">
<div className={`min-h-screen ${isMobile ? 'pt-20' : 'pt-0'}`}>
<Outlet />
</div>
</div>
</div>
);
};
export default HousekeepingLayout;

View File

@@ -13,54 +13,49 @@ import {
ChevronUp,
Calendar,
MapPin,
Search,
Plus,
Edit,
Trash2,
X,
Image as ImageIcon,
Check,
} from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import advancedRoomService, {
import {
RoomStatusBoardItem,
} from '../../features/rooms/services/advancedRoomService';
import roomService, { Room } from '../../features/rooms/services/roomService';
import MaintenanceManagement from '../../features/hotel_services/components/MaintenanceManagement';
import HousekeepingManagement from '../../features/hotel_services/components/HousekeepingManagement';
import InspectionManagement from '../../features/hotel_services/components/InspectionManagement';
import Pagination from '../../shared/components/Pagination';
import apiClient from '../../shared/services/apiClient';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { logger } from '../../shared/utils/logger';
import { useRoomContext } from '../../features/rooms/contexts/RoomContext';
type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections' | 'rooms';
type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections';
const AdvancedRoomManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const {
statusBoardRooms,
statusBoardLoading,
rooms: contextRooms,
refreshStatusBoard,
refreshRooms,
updateRoom: contextUpdateRoom,
deleteRoom: contextDeleteRoom,
createRoom: contextCreateRoom,
setStatusBoardFloor,
statusBoardFloor,
} = useRoomContext();
const [activeTab, setActiveTab] = useState<Tab>('status-board');
const [loading, setLoading] = useState(true);
const [rooms, setRooms] = useState<RoomStatusBoardItem[]>([]);
const [selectedFloor, setSelectedFloor] = useState<number | null>(null);
const [floors, setFloors] = useState<number[]>([]);
const [expandedRooms, setExpandedRooms] = useState<Set<number>>(new Set());
// Rooms management state
const [roomList, setRoomList] = useState<Room[]>([]);
const [roomsLoading, setRoomsLoading] = useState(true);
const [showRoomModal, setShowRoomModal] = useState(false);
const [editingRoom, setEditingRoom] = useState<Room | null>(null);
const [selectedRooms, setSelectedRooms] = useState<number[]>([]);
const [roomFilters, setRoomFilters] = useState({
search: '',
status: '',
type: '',
});
const [roomCurrentPage, setRoomCurrentPage] = useState(1);
const [roomTotalPages, setRoomTotalPages] = useState(1);
const [roomTotalItems, setRoomTotalItems] = useState(0);
const roomItemsPerPage = 5;
const [roomFormData, setRoomFormData] = useState({
room_number: '',
floor: 1,
@@ -79,39 +74,80 @@ const AdvancedRoomManagementPage: React.FC = () => {
const [uploadingImages, setUploadingImages] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
useEffect(() => {
fetchRoomStatusBoard();
fetchFloors();
}, [selectedFloor]);
const fetchRoomStatusBoard = async () => {
// Define fetchFloors before using it in useEffect
const fetchFloors = useCallback(async () => {
try {
setLoading(true);
const response = await advancedRoomService.getRoomStatusBoard(selectedFloor || undefined);
if (response.status === 'success') {
setRooms(response.data.rooms);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to fetch room status board');
} finally {
setLoading(false);
}
};
const fetchFloors = async () => {
try {
const response = await roomService.getRooms({ limit: 1000, page: 1 });
const response = await roomService.getRooms({ limit: 100, page: 1 });
if (response.data?.rooms) {
const uniqueFloors = Array.from(
new Set(response.data.rooms.map((r: any) => r.floor).filter((f: any) => f != null))
).sort((a: any, b: any) => a - b) as number[];
new Set(response.data.rooms.map((r: Room) => r.floor).filter((f: number | undefined) => f != null))
).sort((a: number, b: number) => a - b) as number[];
setFloors(uniqueFloors);
}
} catch (error) {
logger.error('Failed to fetch floors', error);
toast.error('Failed to load floor information');
}
}, []);
// Sync selectedFloor with context
useEffect(() => {
setStatusBoardFloor(selectedFloor);
}, [selectedFloor, setStatusBoardFloor]);
// Use rooms directly from context - no need for local state
// Update selectedFloor when context changes
useEffect(() => {
if (statusBoardFloor !== selectedFloor) {
setSelectedFloor(statusBoardFloor);
}
}, [statusBoardFloor, selectedFloor]);
// Refresh status board when floor filter changes
useEffect(() => {
let isMounted = true;
const abortController = new AbortController();
const refresh = async () => {
if (isMounted && !abortController.signal.aborted) {
await refreshStatusBoard(selectedFloor || undefined);
}
};
refresh();
return () => {
isMounted = false;
abortController.abort();
};
}, [selectedFloor, refreshStatusBoard]);
useEffect(() => {
let isMounted = true;
const abortController = new AbortController();
const initializeData = async () => {
try {
await Promise.all([
fetchFloors(),
refreshStatusBoard(),
]);
} catch (error) {
if (isMounted && !abortController.signal.aborted) {
logger.error('Error initializing data', error);
}
}
};
initializeData();
return () => {
isMounted = false;
abortController.abort();
};
}, [refreshStatusBoard, fetchFloors]);
const toggleRoomExpansion = (roomId: number) => {
const newExpanded = new Set(expandedRooms);
if (newExpanded.has(roomId)) {
@@ -125,14 +161,14 @@ const AdvancedRoomManagementPage: React.FC = () => {
// Group rooms by floor
const roomsByFloor = useMemo(() => {
const grouped: Record<number, RoomStatusBoardItem[]> = {};
rooms.forEach(room => {
statusBoardRooms.forEach(room => {
if (!grouped[room.floor]) {
grouped[room.floor] = [];
}
grouped[room.floor].push(room);
});
return grouped;
}, [rooms]);
}, [statusBoardRooms]);
const getStatusColor = (status: string) => {
switch (status) {
@@ -219,88 +255,20 @@ const AdvancedRoomManagementPage: React.FC = () => {
}
} catch (error) {
logger.error('Failed to fetch amenities', error);
toast.error('Failed to load amenities');
}
}, []);
const fetchRoomList = useCallback(async () => {
// Fetch room types using dedicated endpoint
const fetchRoomTypes = useCallback(async () => {
try {
setRoomsLoading(true);
const response = await roomService.getRooms({
...roomFilters,
page: roomCurrentPage,
limit: roomItemsPerPage,
});
setRoomList(response.data.rooms);
if (response.data.pagination) {
setRoomTotalPages(response.data.pagination.totalPages);
setRoomTotalItems(response.data.pagination.total);
}
const uniqueRoomTypes = new Map<number, { id: number; name: string }>();
response.data.rooms.forEach((room: Room) => {
if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) {
uniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
name: room.room_type.name,
});
}
});
setRoomTypes(Array.from(uniqueRoomTypes.values()));
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load rooms list');
} finally {
setRoomsLoading(false);
}
}, [roomFilters.search, roomFilters.status, roomFilters.type, roomCurrentPage]);
useEffect(() => {
setRoomCurrentPage(1);
setSelectedRooms([]);
}, [roomFilters.search, roomFilters.status, roomFilters.type]);
useEffect(() => {
if (activeTab === 'rooms') {
fetchRoomList();
fetchAvailableAmenities();
}
}, [activeTab, fetchRoomList, fetchAvailableAmenities]);
useEffect(() => {
if (activeTab !== 'rooms') return;
const fetchAllRoomTypes = async () => {
try {
const response = await roomService.getRooms({ limit: 100, page: 1 });
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
response.data.rooms.forEach((room: Room) => {
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
allUniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
name: room.room_type.name,
});
}
});
if (response.data.pagination && response.data.pagination.totalPages > 1) {
const totalPages = response.data.pagination.totalPages;
for (let page = 2; page <= totalPages; page++) {
try {
const pageResponse = await roomService.getRooms({ limit: 100, page });
pageResponse.data.rooms.forEach((room: Room) => {
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
allUniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
name: room.room_type.name,
});
}
});
} catch (err) {
logger.error(`Failed to fetch page ${page}`, err);
}
}
}
const roomTypesList = Array.from(allUniqueRoomTypes.values());
const response = await roomService.getRoomTypes();
if (response.data?.room_types) {
const roomTypesList = response.data.room_types.map((rt: { id: number; name: string }) => ({
id: rt.id,
name: rt.name,
}));
setRoomTypes(roomTypesList);
setRoomFormData(prev => {
if (!editingRoom && prev.room_type_id === 1 && roomTypesList.length > 0) {
@@ -308,16 +276,68 @@ const AdvancedRoomManagementPage: React.FC = () => {
}
return prev;
});
}
} catch (error) {
logger.error('Failed to fetch room types', error);
toast.error('Failed to load room types');
}
}, [editingRoom]);
useEffect(() => {
let isMounted = true;
const abortController = new AbortController();
const fetchData = async () => {
try {
await Promise.all([
fetchAvailableAmenities(),
fetchRoomTypes(),
]);
} catch (error) {
if (isMounted && !abortController.signal.aborted) {
logger.error('Error fetching initial data', error);
}
}
};
fetchAllRoomTypes();
}, [activeTab, editingRoom]);
fetchData();
return () => {
isMounted = false;
abortController.abort();
};
}, [fetchAvailableAmenities, fetchRoomTypes]);
// Frontend validation
const validateRoomForm = (): string | null => {
if (!roomFormData.room_number.trim()) {
return 'Room number is required';
}
if (roomFormData.floor < 1) {
return 'Floor must be at least 1';
}
if (roomFormData.room_type_id < 1) {
return 'Room type is required';
}
if (roomFormData.price && parseFloat(roomFormData.price) < 0) {
return 'Price cannot be negative';
}
if (roomFormData.capacity && parseInt(roomFormData.capacity) < 1) {
return 'Capacity must be at least 1';
}
return null;
};
const handleRoomSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Frontend validation
const validationError = validateRoomForm();
if (validationError) {
toast.error(validationError);
return;
}
try {
if (editingRoom) {
const updateData = {
@@ -329,14 +349,15 @@ const AdvancedRoomManagementPage: React.FC = () => {
view: roomFormData.view || undefined,
amenities: Array.isArray(roomFormData.amenities) ? roomFormData.amenities : [],
};
await roomService.updateRoom(editingRoom.id, updateData);
toast.success('Room updated successfully');
await fetchRoomList();
await contextUpdateRoom(editingRoom.id, updateData);
// Refresh room details for editing
try {
const updatedRoom = await roomService.getRoomByNumber(editingRoom.room_number);
setEditingRoom(updatedRoom.data.room);
} catch (err) {
logger.error('Failed to refresh room data', err);
toast.error('Room updated but failed to refresh details');
}
} else {
const createData = {
@@ -349,7 +370,6 @@ const AdvancedRoomManagementPage: React.FC = () => {
amenities: Array.isArray(roomFormData.amenities) ? roomFormData.amenities : [],
};
const response = await roomService.createRoom(createData);
toast.success('Room added successfully');
if (response.data?.room) {
if (selectedFiles.length > 0) {
@@ -396,18 +416,41 @@ const AdvancedRoomManagementPage: React.FC = () => {
amenities: response.data.room.amenities || [],
});
await fetchRoomList();
await contextCreateRoom(createData);
return;
}
}
setShowRoomModal(false);
resetRoomForm();
fetchRoomList();
await refreshRooms();
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred');
}
};
// Handle edit from status board
const handleEditRoomFromStatusBoard = async (roomId: number) => {
try {
// Find the room in context rooms
const room = contextRooms.find(r => r.id === roomId);
if (room) {
await handleEditRoom(room);
} else {
// If not found in context, fetch it
const response = await roomService.getRoomById(roomId);
const foundRoom = response.data?.room;
if (foundRoom) {
await handleEditRoom(foundRoom);
} else {
toast.error('Room not found');
}
}
} catch (error: any) {
logger.error('Error fetching room for edit', error);
toast.error('Failed to load room details');
}
};
const handleEditRoom = async (room: Room) => {
setEditingRoom(room);
@@ -478,6 +521,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
setEditingRoom(roomData);
} catch (error) {
logger.error('Failed to fetch full room details', error);
toast.error('Failed to load complete room details');
}
};
@@ -485,46 +529,9 @@ const AdvancedRoomManagementPage: React.FC = () => {
if (!window.confirm('Are you sure you want to delete this room?')) return;
try {
await roomService.deleteRoom(id);
toast.success('Room deleted successfully');
setSelectedRooms(selectedRooms.filter(roomId => roomId !== id));
fetchRoomList();
await contextDeleteRoom(id);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete room');
}
};
const handleBulkDeleteRooms = async () => {
if (selectedRooms.length === 0) {
toast.warning('Please select at least one room to delete');
return;
}
if (!window.confirm(`Are you sure you want to delete ${selectedRooms.length} room(s)?`)) return;
try {
await roomService.bulkDeleteRooms(selectedRooms);
toast.success(`Successfully deleted ${selectedRooms.length} room(s)`);
setSelectedRooms([]);
fetchRoomList();
} catch (error: any) {
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to delete rooms');
}
};
const handleSelectRoom = (roomId: number) => {
setSelectedRooms(prev =>
prev.includes(roomId)
? prev.filter(id => id !== roomId)
: [...prev, roomId]
);
};
const handleSelectAllRooms = () => {
if (selectedRooms.length === roomList.length) {
setSelectedRooms([]);
} else {
setSelectedRooms(roomList.map(room => room.id));
// Error already handled in context
}
};
@@ -581,7 +588,10 @@ const AdvancedRoomManagementPage: React.FC = () => {
toast.success('Images uploaded successfully');
setSelectedFiles([]);
fetchRoomList();
await Promise.all([
refreshRooms(),
refreshStatusBoard(),
]);
const response = await roomService.getRoomByNumber(editingRoom.room_number);
setEditingRoom(response.data.room);
@@ -613,7 +623,10 @@ const AdvancedRoomManagementPage: React.FC = () => {
});
toast.success('Image deleted successfully');
fetchRoomList();
await Promise.all([
refreshRooms(),
refreshStatusBoard(),
]);
const response = await roomService.getRoomByNumber(editingRoom.room_number);
setEditingRoom(response.data.room);
@@ -652,7 +665,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
);
};
if (loading && rooms.length === 0 && activeTab !== 'rooms') {
if (statusBoardLoading && statusBoardRooms.length === 0 && activeTab === 'status-board') {
return <Loading />;
}
@@ -667,8 +680,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
<div className="mb-6 border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
{[
{ id: 'status-board' as Tab, label: 'Room Status Board', icon: Hotel },
{ id: 'rooms' as Tab, label: 'Rooms', icon: Hotel },
{ id: 'status-board' as Tab, label: 'Rooms & Status Board', icon: Hotel },
{ id: 'maintenance' as Tab, label: 'Maintenance', icon: Wrench },
{ id: 'housekeeping' as Tab, label: 'Housekeeping', icon: Sparkles },
{ id: 'inspections' as Tab, label: 'Inspections', icon: ClipboardCheck },
@@ -717,17 +729,29 @@ const AdvancedRoomManagementPage: React.FC = () => {
</select>
</div>
<div className="text-sm text-slate-600">
<span className="font-semibold text-slate-900">{rooms.length}</span> rooms
<span className="font-semibold text-slate-900">{statusBoardRooms.length}</span> rooms
</div>
</div>
<div className="flex items-center space-x-3">
<button
onClick={fetchRoomStatusBoard}
onClick={() => {
resetRoomForm();
setShowRoomModal(true);
}}
className="flex items-center space-x-2 px-5 py-2.5 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-md hover:shadow-lg font-medium"
>
<Plus className="w-4 h-4" />
<span>Add Room</span>
</button>
<button
onClick={() => refreshStatusBoard(selectedFloor || undefined)}
className="flex items-center space-x-2 px-5 py-2.5 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg hover:from-blue-700 hover:to-indigo-700 transition-all duration-200 shadow-md hover:shadow-lg font-medium"
>
<RefreshCw className="w-4 h-4" />
<span>Refresh</span>
</button>
</div>
</div>
{/* Floors Display */}
{Object.keys(roomsByFloor).length === 0 ? (
@@ -761,26 +785,41 @@ const AdvancedRoomManagementPage: React.FC = () => {
{/* Rooms Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{floorRooms.map((room) => {
const statusColors = getStatusColor(room.status);
// Determine effective status: if there are pending housekeeping tasks and room is available, show as cleaning
const effectiveStatus = room.pending_housekeeping_count > 0 && room.status === 'available'
? 'cleaning'
: room.status;
const statusColors = getStatusColor(effectiveStatus);
return (
<div
key={room.id}
className={`
group relative overflow-hidden rounded-xl border-2 transition-all duration-300
${statusColors.bg} ${statusColors.border}
hover:shadow-xl hover:scale-[1.02] cursor-pointer
hover:shadow-xl hover:scale-[1.02]
${expandedRooms.has(room.id) ? 'shadow-lg' : 'shadow-md'}
`}
onClick={() => toggleRoomExpansion(room.id)}
>
{/* Status Badge */}
<div className={`absolute top-3 right-3 px-3 py-1 rounded-full text-xs font-semibold shadow-lg ${statusColors.badge} flex items-center space-x-1.5`}>
{getStatusIcon(room.status)}
<span>{getStatusLabel(room.status)}</span>
<div className={`absolute top-3 right-3 px-3 py-1 rounded-full text-xs font-semibold shadow-lg ${statusColors.badge} flex items-center space-x-1.5 z-20`}>
{getStatusIcon(effectiveStatus)}
<span>{getStatusLabel(effectiveStatus)}</span>
</div>
{/* Edit Button */}
<button
onClick={(e) => {
e.stopPropagation();
handleEditRoomFromStatusBoard(room.id);
}}
className="absolute top-3 left-3 p-2 bg-white/90 backdrop-blur-sm rounded-lg text-blue-600 hover:text-blue-700 hover:bg-white transition-all duration-200 shadow-md hover:shadow-lg border border-blue-200 hover:border-blue-300 z-20"
title="Edit Room"
>
<Edit className="w-4 h-4" />
</button>
{/* Room Content */}
<div className="p-5 pt-4">
<div className="p-5 pt-16 cursor-pointer" onClick={() => toggleRoomExpansion(room.id)}>
<div className="mb-4">
<div className="flex items-center space-x-2 mb-2">
<h3 className="text-2xl font-bold text-slate-900 font-mono">{room.room_number}</h3>
@@ -794,16 +833,16 @@ const AdvancedRoomManagementPage: React.FC = () => {
{/* Expanded Details */}
{expandedRooms.has(room.id) && (
<div className="mt-4 pt-4 border-t border-slate-300/30 space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="mt-4 pt-4 border-t border-slate-300/30 space-y-3 max-h-96 overflow-y-auto animate-in fade-in slide-in-from-top-2 duration-200">
{room.current_booking && (
<div className="bg-white/60 rounded-lg p-3 space-y-2">
<div className="flex items-center space-x-2 text-sm font-semibold text-slate-700">
<Users className="w-4 h-4" />
<Users className="w-4 h-4 flex-shrink-0" />
<span>Guest</span>
</div>
<p className="text-sm font-medium text-slate-900">{room.current_booking.guest_name}</p>
<p className="text-sm font-medium text-slate-900 break-words">{room.current_booking.guest_name}</p>
<div className="flex items-center space-x-1 text-xs text-slate-600">
<Calendar className="w-3 h-3" />
<Calendar className="w-3 h-3 flex-shrink-0" />
<span>Check-out: {new Date(room.current_booking.check_out).toLocaleDateString()}</span>
</div>
</div>
@@ -812,10 +851,10 @@ const AdvancedRoomManagementPage: React.FC = () => {
{room.active_maintenance && (
<div className="bg-red-50/80 rounded-lg p-3 space-y-2 border border-red-200/50">
<div className="flex items-center space-x-2 text-sm font-semibold text-red-800">
<Wrench className="w-4 h-4" />
<Wrench className="w-4 h-4 flex-shrink-0" />
<span>Maintenance</span>
</div>
<p className="text-sm font-medium text-red-900">{room.active_maintenance.title}</p>
<p className="text-sm font-medium text-red-900 break-words">{room.active_maintenance.title}</p>
<p className="text-xs text-red-700 capitalize">{room.active_maintenance.type}</p>
</div>
)}
@@ -823,7 +862,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
{room.pending_housekeeping_count > 0 && (
<div className="bg-amber-50/80 rounded-lg p-3 space-y-2 border border-amber-200/50">
<div className="flex items-center space-x-2 text-sm font-semibold text-amber-800">
<Sparkles className="w-4 h-4" />
<Sparkles className="w-4 h-4 flex-shrink-0" />
<span>Housekeeping</span>
</div>
<p className="text-sm font-medium text-amber-900">
@@ -873,181 +912,9 @@ const AdvancedRoomManagementPage: React.FC = () => {
{/* Inspections Tab */}
{activeTab === 'inspections' && <InspectionManagement />}
{/* Rooms Tab */}
{activeTab === 'rooms' && (
<div className="space-y-8">
{roomsLoading && <Loading />}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex justify-between items-center">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-gradient-to-br from-amber-500/10 to-yellow-500/10 border border-amber-200/40">
<Hotel className="w-6 h-6 text-amber-600" />
</div>
<h2 className="text-xl sm:text-2xl md:text-2xl font-extrabold text-gray-900">Room Management</h2>
</div>
<p className="text-gray-600 text-base max-w-2xl leading-relaxed">
Manage hotel room information and availability
</p>
</div>
<div className="flex gap-3">
{selectedRooms.length > 0 && (
<button
onClick={handleBulkDeleteRooms}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-rose-500 to-rose-600 text-white rounded-xl font-semibold hover:from-rose-600 hover:to-rose-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Trash2 className="w-5 h-5" />
Delete Selected ({selectedRooms.length})
</button>
)}
<button
onClick={() => {
resetRoomForm();
setShowRoomModal(true);
}}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="w-5 h-5" />
Add Room
</button>
</div>
</div>
</div>
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search rooms..."
value={roomFilters.search}
onChange={(e) => setRoomFilters({ ...roomFilters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-gray-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-gray-700 placeholder-gray-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={roomFilters.status}
onChange={(e) => setRoomFilters({ ...roomFilters, status: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-gray-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-gray-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Statuses</option>
<option value="available">Available</option>
<option value="occupied">Occupied</option>
<option value="maintenance">Maintenance</option>
</select>
<select
value={roomFilters.type}
onChange={(e) => setRoomFilters({ ...roomFilters, type: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-gray-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-gray-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Room Types</option>
{roomTypes.map((roomType) => (
<option key={roomType.id} value={roomType.name}>
{roomType.name}
</option>
))}
</select>
</div>
</div>
<div className="bg-white/90 backdrop-blur-xl rounded-xl sm:rounded-2xl shadow-xl border border-gray-200/50 overflow-hidden">
<div className="overflow-x-auto -mx-2 sm:mx-0 px-2 sm:px-0">
<table className="w-full min-w-[800px] sm:min-w-full">
<thead>
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700 w-12">
<input
type="checkbox"
checked={roomList.length > 0 && selectedRooms.length === roomList.length}
onChange={handleSelectAllRooms}
title="Select all rooms"
className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 bg-slate-700 border-slate-600 rounded focus:ring-amber-500 cursor-pointer"
/>
</th>
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Room Number</th>
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Room Type</th>
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Floor</th>
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Price</th>
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Status</th>
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Featured</th>
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{roomList.map((room) => (
<tr
key={room.id}
className={`hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100 ${selectedRooms.includes(room.id) ? 'bg-amber-50/50' : ''}`}
>
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
<input
type="checkbox"
checked={selectedRooms.includes(room.id)}
onChange={() => handleSelectRoom(room.id)}
className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 bg-white border-slate-300 rounded focus:ring-amber-500 cursor-pointer"
title={`Select room ${room.room_number}`}
/>
</td>
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
<div className="text-xs sm:text-sm font-bold text-gray-900 font-mono">{room.room_number}</div>
</td>
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
<div className="text-xs sm:text-sm font-medium text-gray-900">{room.room_type?.name || 'N/A'}</div>
</td>
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
<div className="text-xs sm:text-sm text-gray-700">Floor {room.floor}</div>
</td>
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
<div className="text-xs sm:text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
{formatCurrency(room.price || room.room_type?.base_price || 0)}
</div>
</td>
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
{getRoomStatusBadge(room.status)}
</td>
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
{room.featured ? (
<span className="text-amber-500 text-base sm:text-lg"></span>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-1 sm:gap-2">
<button
onClick={() => handleEditRoom(room)}
className="p-1.5 sm:p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all duration-200 shadow-sm hover:shadow-md border border-blue-200 hover:border-blue-300"
title="Edit"
>
<Edit className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
<button
onClick={() => handleDeleteRoom(room.id)}
className="p-1.5 sm:p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
title="Delete"
>
<Trash2 className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
currentPage={roomCurrentPage}
totalPages={roomTotalPages}
onPageChange={setRoomCurrentPage}
totalItems={roomTotalItems}
itemsPerPage={roomItemsPerPage}
/>
</div>
{/* Room Modal */}
{showRoomModal && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-[100] p-4">
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] rounded-xl border border-[#d4af37]/30 backdrop-blur-xl shadow-2xl shadow-[#d4af37]/20 p-8 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6 pb-6 border-b border-[#d4af37]/20">
<div>
@@ -1130,7 +997,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
</label>
<select
value={roomFormData.status}
onChange={(e) => setRoomFormData({ ...roomFormData, status: e.target.value as any })}
onChange={(e) => setRoomFormData({ ...roomFormData, status: e.target.value as 'available' | 'occupied' | 'maintenance' })}
className="w-full px-4 py-3 bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg text-white focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37] transition-all duration-300"
required
>
@@ -1467,8 +1334,6 @@ const AdvancedRoomManagementPage: React.FC = () => {
</div>
)}
</div>
)}
</div>
);
};

View File

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

View File

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

View File

@@ -0,0 +1,505 @@
import React, { useEffect, useState, useRef } from 'react';
import {
Sparkles,
Clock,
CheckCircle,
AlertCircle,
RefreshCw,
Calendar,
MapPin,
Play,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
import advancedRoomService, { HousekeepingTask, ChecklistItem } from '../../features/rooms/services/advancedRoomService';
import { logger } from '../../shared/utils/logger';
import useAuthStore from '../../store/useAuthStore';
const HousekeepingDashboardPage: React.FC = () => {
const { userInfo } = useAuthStore();
const [loading, setLoading] = useState(true);
const [tasks, setTasks] = useState<HousekeepingTask[]>([]);
const [stats, setStats] = useState({
pending: 0,
in_progress: 0,
completed: 0,
total: 0,
});
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
const [updatingTasks, setUpdatingTasks] = useState<Set<number>>(new Set());
const tasksAbortRef = useRef<AbortController | null>(null);
const fetchTasks = async () => {
try {
// Cancel previous request if exists
if (tasksAbortRef.current) {
tasksAbortRef.current.abort();
}
tasksAbortRef.current = new AbortController();
setLoading(true);
// Fetch today's tasks assigned to current user
const today = new Date().toISOString().split('T')[0];
const response = await advancedRoomService.getHousekeepingTasks({
date: today,
page: 1,
limit: 50,
});
if (response.status === 'success' && response.data?.tasks) {
const userTasks = response.data.tasks.filter(
(task: HousekeepingTask) => task.assigned_to === userInfo?.id
);
setTasks(userTasks);
// Calculate stats
const pending = userTasks.filter((t: HousekeepingTask) => t.status === 'pending').length;
const in_progress = userTasks.filter((t: HousekeepingTask) => t.status === 'in_progress').length;
const completed = userTasks.filter((t: HousekeepingTask) => t.status === 'completed').length;
setStats({
pending,
in_progress,
completed,
total: userTasks.length,
});
}
} catch (error: any) {
if (error.name === 'AbortError') {
return;
}
logger.error('Error fetching housekeeping tasks', error);
toast.error('Failed to load tasks');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasks();
// Auto-refresh every 30 seconds
const interval = setInterval(() => {
if (document.visibilityState === 'visible') {
fetchTasks();
}
}, 30000);
return () => {
if (tasksAbortRef.current) {
tasksAbortRef.current.abort();
}
clearInterval(interval);
};
}, [userInfo?.id]);
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800 border-green-200';
case 'in_progress':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'pending':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircle className="w-5 h-5" />;
case 'in_progress':
return <Clock className="w-5 h-5" />;
case 'pending':
return <AlertCircle className="w-5 h-5" />;
default:
return <Clock className="w-5 h-5" />;
}
};
const toggleTaskExpansion = (taskId: number) => {
setExpandedTasks(prev => {
const newSet = new Set(prev);
if (newSet.has(taskId)) {
newSet.delete(taskId);
} else {
newSet.add(taskId);
}
return newSet;
});
};
const handleStartTask = async (task: HousekeepingTask) => {
if (updatingTasks.has(task.id)) return;
setUpdatingTasks(prev => new Set(prev).add(task.id));
try {
await advancedRoomService.updateHousekeepingTask(task.id, {
status: 'in_progress',
});
toast.success('Task started');
await fetchTasks();
} catch (error: any) {
logger.error('Error starting task', error);
toast.error(error.response?.data?.detail || 'Failed to start task');
} finally {
setUpdatingTasks(prev => {
const newSet = new Set(prev);
newSet.delete(task.id);
return newSet;
});
}
};
const handleUpdateChecklist = async (task: HousekeepingTask, itemIndex: number, checked: boolean) => {
if (updatingTasks.has(task.id)) return;
if (!task.checklist_items) return;
setUpdatingTasks(prev => new Set(prev).add(task.id));
try {
const updatedChecklist = [...task.checklist_items];
updatedChecklist[itemIndex] = {
...updatedChecklist[itemIndex],
completed: checked,
};
await advancedRoomService.updateHousekeepingTask(task.id, {
checklist_items: updatedChecklist,
});
// Update local state immediately for better UX
setTasks(prevTasks =>
prevTasks.map(t =>
t.id === task.id
? { ...t, checklist_items: updatedChecklist }
: t
)
);
// Recalculate stats
const updatedTask = { ...task, checklist_items: updatedChecklist };
const allTasks = tasks.map(t => t.id === task.id ? updatedTask : t);
const pending = allTasks.filter((t: HousekeepingTask) => t.status === 'pending').length;
const in_progress = allTasks.filter((t: HousekeepingTask) => t.status === 'in_progress').length;
const completed = allTasks.filter((t: HousekeepingTask) => t.status === 'completed').length;
setStats({ pending, in_progress, completed, total: allTasks.length });
} catch (error: any) {
logger.error('Error updating checklist', error);
toast.error(error.response?.data?.detail || 'Failed to update checklist');
} finally {
setUpdatingTasks(prev => {
const newSet = new Set(prev);
newSet.delete(task.id);
return newSet;
});
}
};
const handleCompleteTask = async (task: HousekeepingTask) => {
if (updatingTasks.has(task.id)) return;
// Check if all checklist items are completed
const allCompleted = task.checklist_items?.every(item => item.completed) ?? true;
if (!allCompleted && task.checklist_items && task.checklist_items.length > 0) {
const incomplete = task.checklist_items.filter(item => !item.completed).length;
if (!window.confirm(`You have ${incomplete} incomplete checklist item(s). Mark task as completed anyway?`)) {
return;
}
}
setUpdatingTasks(prev => new Set(prev).add(task.id));
try {
// Mark all checklist items as completed if not already
const updatedChecklist = task.checklist_items?.map(item => ({
...item,
completed: true,
})) || [];
await advancedRoomService.updateHousekeepingTask(task.id, {
status: 'completed',
checklist_items: updatedChecklist,
});
toast.success('Task marked as completed!');
await fetchTasks();
} catch (error: any) {
logger.error('Error completing task', error);
toast.error(error.response?.data?.detail || 'Failed to complete task');
} finally {
setUpdatingTasks(prev => {
const newSet = new Set(prev);
newSet.delete(task.id);
return newSet;
});
}
};
if (loading && tasks.length === 0) {
return <Loading />;
}
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Housekeeping Dashboard</h1>
<p className="mt-1 text-sm text-gray-500">
Welcome back, {userInfo?.name || userInfo?.email || 'Housekeeping Staff'}
</p>
</div>
<button
onClick={fetchTasks}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<RefreshCw className="w-4 h-4" />
<span>Refresh</span>
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-yellow-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Pending</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.pending}</p>
</div>
<AlertCircle className="w-8 h-8 text-yellow-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-blue-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">In Progress</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.in_progress}</p>
</div>
<Clock className="w-8 h-8 text-blue-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-green-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Completed</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.completed}</p>
</div>
<CheckCircle className="w-8 h-8 text-green-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-indigo-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Today</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.total}</p>
</div>
<Sparkles className="w-8 h-8 text-indigo-500" />
</div>
</div>
</div>
{/* Tasks List */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Today's Tasks</h2>
</div>
{tasks.length === 0 ? (
<div className="p-12 text-center">
<Sparkles className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No tasks assigned</h3>
<p className="text-sm text-gray-500">You don't have any housekeeping tasks assigned for today.</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{tasks.map((task) => {
const completedItems = task.checklist_items?.filter(item => item.completed).length || 0;
const totalItems = task.checklist_items?.length || 0;
const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0;
const isExpanded = expandedTasks.has(task.id);
const isUpdating = updatingTasks.has(task.id);
const canStart = task.status === 'pending';
const canComplete = task.status === 'in_progress' || task.status === 'pending';
return (
<div
key={task.id}
className="border-b border-gray-200 last:border-b-0"
>
<div className="p-6 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900">
Room {task.room_number || task.room_id}
</h3>
<span className={`px-3 py-1 rounded-full text-xs font-medium border flex items-center space-x-1 ${getStatusColor(task.status)}`}>
{getStatusIcon(task.status)}
<span>{task.status.replace('_', ' ')}</span>
</span>
</div>
<div className="flex items-center space-x-4 text-sm text-gray-600 mb-3">
<div className="flex items-center space-x-1">
<Calendar className="w-4 h-4" />
<span>{formatDate(task.scheduled_time)}</span>
</div>
<div className="flex items-center space-x-1">
<MapPin className="w-4 h-4" />
<span className="capitalize">{task.task_type}</span>
</div>
</div>
{task.checklist_items && task.checklist_items.length > 0 && (
<div className="mt-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-600">Progress</span>
<span className="text-sm font-medium text-gray-900">{progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">
{completedItems} of {totalItems} items completed
</p>
</div>
)}
</div>
<div className="flex items-center space-x-2 ml-4">
{canStart && (
<button
onClick={(e) => {
e.stopPropagation();
handleStartTask(task);
}}
disabled={isUpdating}
className="flex items-center space-x-1 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Play className="w-4 h-4" />
<span>Start</span>
</button>
)}
{canComplete && (
<button
onClick={(e) => {
e.stopPropagation();
handleCompleteTask(task);
}}
disabled={isUpdating}
className="flex items-center space-x-1 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<CheckCircle className="w-4 h-4" />
<span>Complete</span>
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
toggleTaskExpansion(task.id);
}}
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-5 h-5" />
) : (
<ChevronDown className="w-5 h-5" />
)}
</button>
</div>
</div>
</div>
{/* Expanded Task Details */}
{isExpanded && (
<div className="px-6 pb-6 bg-gray-50 border-t border-gray-200">
<div className="pt-4 space-y-4">
{task.notes && (
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-1">Notes</h4>
<p className="text-sm text-gray-600">{task.notes}</p>
</div>
)}
{task.checklist_items && task.checklist_items.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-3">Checklist</h4>
<div className="space-y-2">
{task.checklist_items.map((item: ChecklistItem, index: number) => (
<label
key={index}
className="flex items-start space-x-3 p-3 bg-white rounded-lg border border-gray-200 hover:border-blue-300 transition-colors cursor-pointer"
>
<input
type="checkbox"
checked={item.completed}
onChange={(e) => {
e.stopPropagation();
handleUpdateChecklist(task, index, e.target.checked);
}}
disabled={isUpdating || task.status === 'completed'}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed"
/>
<div className="flex-1">
<span
className={`text-sm ${
item.completed
? 'text-gray-500 line-through'
: 'text-gray-900'
}`}
>
{item.item}
</span>
{item.notes && (
<p className="text-xs text-gray-500 mt-1">{item.notes}</p>
)}
</div>
{item.completed && (
<CheckCircle className="w-5 h-5 text-green-500 flex-shrink-0" />
)}
</label>
))}
</div>
</div>
)}
{task.status === 'completed' && task.completed_at && (
<div className="pt-2 border-t border-gray-200">
<p className="text-xs text-gray-500">
Completed: {formatDate(task.completed_at)}
</p>
{task.actual_duration_minutes && (
<p className="text-xs text-gray-500 mt-1">
Duration: {task.actual_duration_minutes} minutes
</p>
)}
</div>
)}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
};
export default HousekeepingDashboardPage;

View File

@@ -0,0 +1,19 @@
import React from 'react';
import HousekeepingManagement from '../../features/hotel_services/components/HousekeepingManagement';
const HousekeepingTasksPage: React.FC = () => {
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900">My Housekeeping Tasks</h1>
<p className="mt-1 text-sm text-gray-500">
View and manage your assigned housekeeping tasks
</p>
</div>
<HousekeepingManagement />
</div>
);
};
export default HousekeepingTasksPage;

View File

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

View File

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