This commit is contained in:
Iliyan Angelov
2025-12-05 22:12:32 +02:00
parent 13c91f95f4
commit 7667eb5eda
53 changed files with 3065 additions and 9257 deletions

View File

@@ -0,0 +1,62 @@
"""add_enterprise_promotion_conditions
Revision ID: b1c4d7c154ec
Revises: 87e29a777cb3
Create Date: 2025-12-05 20:22:39.893584
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'b1c4d7c154ec'
down_revision = '87e29a777cb3'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add enterprise promotion condition columns
op.add_column('promotions', sa.Column('min_stay_days', sa.Integer(), nullable=True, comment='Minimum number of nights required for booking'))
op.add_column('promotions', sa.Column('max_stay_days', sa.Integer(), nullable=True, comment='Maximum number of nights allowed for booking'))
op.add_column('promotions', sa.Column('advance_booking_days', sa.Integer(), nullable=True, comment='Minimum days in advance the booking must be made'))
op.add_column('promotions', sa.Column('max_advance_booking_days', sa.Integer(), nullable=True, comment='Maximum days in advance the booking can be made'))
# Day of week restrictions (stored as JSON)
op.add_column('promotions', sa.Column('allowed_check_in_days', sa.JSON(), nullable=True, comment='Allowed check-in days of week (0-6, Mon-Sun)'))
op.add_column('promotions', sa.Column('allowed_check_out_days', sa.JSON(), nullable=True, comment='Allowed check-out days of week (0-6, Mon-Sun)'))
# Room type restrictions (stored as JSON arrays)
op.add_column('promotions', sa.Column('allowed_room_type_ids', sa.JSON(), nullable=True, comment='Allowed room type IDs (JSON array)'))
op.add_column('promotions', sa.Column('excluded_room_type_ids', sa.JSON(), nullable=True, comment='Excluded room type IDs (JSON array)'))
# Guest count restrictions
op.add_column('promotions', sa.Column('min_guests', sa.Integer(), nullable=True, comment='Minimum number of guests required'))
op.add_column('promotions', sa.Column('max_guests', sa.Integer(), nullable=True, comment='Maximum number of guests allowed'))
# Customer type restrictions
op.add_column('promotions', sa.Column('first_time_customer_only', sa.Boolean(), nullable=False, server_default='0', comment='Only for first-time customers'))
op.add_column('promotions', sa.Column('repeat_customer_only', sa.Boolean(), nullable=False, server_default='0', comment='Only for returning customers'))
# Blackout dates (stored as JSON array)
op.add_column('promotions', sa.Column('blackout_dates', sa.JSON(), nullable=True, comment='Blackout dates when promotion doesn\'t apply (JSON array of YYYY-MM-DD)'))
def downgrade() -> None:
# Remove all added columns
op.drop_column('promotions', 'blackout_dates')
op.drop_column('promotions', 'repeat_customer_only')
op.drop_column('promotions', 'first_time_customer_only')
op.drop_column('promotions', 'max_guests')
op.drop_column('promotions', 'min_guests')
op.drop_column('promotions', 'excluded_room_type_ids')
op.drop_column('promotions', 'allowed_room_type_ids')
op.drop_column('promotions', 'allowed_check_out_days')
op.drop_column('promotions', 'allowed_check_in_days')
op.drop_column('promotions', 'max_advance_booking_days')
op.drop_column('promotions', 'advance_booking_days')
op.drop_column('promotions', 'max_stay_days')
op.drop_column('promotions', 'min_stay_days')

View File

@@ -1,9 +0,0 @@
{
"filename": "backup_hotel_booking_dev_20251202_232932.sql",
"path": "backups/backup_hotel_booking_dev_20251202_232932.sql",
"size_bytes": 526399,
"size_mb": 0.5,
"created_at": "2025-12-02T23:29:32.937870",
"database": "hotel_booking_dev",
"status": "success"
}

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +0,0 @@
{
"filename": "backup_hotel_booking_dev_20251205_144357.sql",
"path": "backups/backup_hotel_booking_dev_20251205_144357.sql",
"size_bytes": 653233,
"size_mb": 0.62,
"created_at": "2025-12-05T14:43:58.328707",
"database": "hotel_booking_dev",
"status": "success"
}

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
import sys
import os
import json
from datetime import datetime, timedelta
# Add parent directory to path to import from src
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy.orm import Session
@@ -150,34 +151,96 @@ def seed_homepage_content(db: Session):
trust_badges_section_subtitle = 'Awards and certifications that validate our commitment to excellence'
trust_badges_enabled = True
# Promotions
# Promotions - Mix of valid and expired for testing
# Calculate dates relative to current date
today = datetime.now()
next_month = today + timedelta(days=30)
next_3_months = today + timedelta(days=90)
next_6_months = today + timedelta(days=180)
expired_1_month_ago = today - timedelta(days=30)
expired_3_months_ago = today - timedelta(days=90)
promotions = [
{
'title': 'Early Bird Special',
'description': 'Book 30 days in advance and save 20% on your stay. Perfect for planning ahead!',
'image': 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=600',
'discount': '20% OFF',
'valid_until': next_3_months.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'Book Now',
'code': 'EARLYBIRD20'
},
{
'title': 'Weekend Getaway',
'description': 'Perfect weekend escape with complimentary breakfast and spa access. Relax and unwind!',
'image': 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=600',
'discount': '30% OFF',
'valid_until': next_month.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'View Offer',
'code': 'WEEKEND30'
},
{
'title': 'Luxury Suite Package',
'description': 'Experience our premium suites with exclusive amenities, fine dining, and concierge service',
'image': 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=600',
'discount': 'Save $200',
'valid_until': next_6_months.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'Explore Suites',
'code': 'LUXURY200'
},
{
'title': 'Honeymoon Special',
'description': 'Romantic getaway with champagne, flowers, special amenities, and complimentary room upgrade',
'image': 'https://images.unsplash.com/photo-1596394516093-501ba68a0ba6?w=600',
'discount': '25% OFF',
'valid_until': next_3_months.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'Book Package',
'code': 'HONEYMOON25'
},
{
'title': 'Family Fun Package',
'description': 'Perfect for families! Includes family room, kids activities, and complimentary meals for children under 12',
'image': 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=600',
'discount': '15% OFF',
'valid_until': next_6_months.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'Book Now',
'code': 'FAMILY15'
},
{
'title': 'Business Traveler',
'description': 'Extended stay discounts for business travelers. Includes high-speed WiFi, workspace, and airport transfer',
'image': 'https://images.unsplash.com/photo-1551882547-ff40c63fe5fa?w=600',
'discount': '10% OFF',
'valid_until': next_3_months.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'Learn More',
'code': 'BUSINESS10'
},
# Expired promotions for testing display logic
{
'title': 'Summer Special',
'description': 'Enjoy 25% off on all room bookings this summer. Limited time offer!',
'image': 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=600',
'discount': '25% OFF',
'valid_until': '2024-08-31',
'valid_until': expired_3_months_ago.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'Book Now'
'button_text': 'Book Now',
'code': 'SUMMER25'
},
{
'title': 'Weekend Getaway',
'description': 'Perfect weekend escape with complimentary breakfast and spa access',
'image': 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=600',
'discount': '30% OFF',
'valid_until': '2024-12-31',
'title': 'New Year Celebration',
'description': 'Ring in the new year with our special celebration package. Includes party access and premium amenities',
'image': 'https://images.unsplash.com/photo-1590490360182-c33d57733427?w=600',
'discount': '35% OFF',
'valid_until': expired_1_month_ago.strftime('%Y-%m-%d'),
'link': '/rooms',
'button_text': 'Learn More'
},
{
'title': 'Honeymoon Package',
'description': 'Romantic getaway with champagne, flowers, and special amenities',
'image': 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=600',
'discount': 'Special Rate',
'valid_until': '2024-12-31',
'link': '/rooms',
'button_text': 'Book Package'
'button_text': 'View Offer',
'code': 'NEWYEAR35'
}
]
promotions_section_title = 'Special Offers'

View File

@@ -0,0 +1,157 @@
import sys
import os
from datetime import datetime, timedelta
# Add parent directory to path to import from src
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy.orm import Session
from src.shared.config.database import SessionLocal
from src.loyalty.models.promotion import Promotion, DiscountType
def seed_promotions(db: Session):
"""Seed promotions that match the homepage promotion codes"""
# Calculate dates relative to current UTC date
# Use UTC consistently to match validation logic
now = datetime.utcnow()
# Start date: Start of today (00:00:00) to ensure promotion is immediately active
start_date = datetime(now.year, now.month, now.day, 0, 0, 0)
# End dates: future dates at end of day (23:59:59)
next_month_date = now + timedelta(days=30)
next_month = datetime(next_month_date.year, next_month_date.month, next_month_date.day, 23, 59, 59)
next_3_months_date = now + timedelta(days=90)
next_3_months = datetime(next_3_months_date.year, next_3_months_date.month, next_3_months_date.day, 23, 59, 59)
next_6_months_date = now + timedelta(days=180)
next_6_months = datetime(next_6_months_date.year, next_6_months_date.month, next_6_months_date.day, 23, 59, 59)
promotions_data = [
{
'code': 'EARLYBIRD20',
'name': 'Early Bird Special',
'description': 'Book 30 days in advance and save 20% on your stay. Perfect for planning ahead!',
'discount_type': DiscountType.percentage,
'discount_value': 20.00,
'start_date': start_date,
'end_date': next_3_months,
'is_active': True,
'min_booking_amount': None,
'max_discount_amount': None,
'usage_limit': None,
},
{
'code': 'WEEKEND30',
'name': 'Weekend Getaway',
'description': 'Perfect weekend escape with complimentary breakfast and spa access. Relax and unwind!',
'discount_type': DiscountType.percentage,
'discount_value': 30.00,
'start_date': start_date,
'end_date': next_month,
'is_active': True,
'min_booking_amount': None,
'max_discount_amount': None,
'usage_limit': None,
},
{
'code': 'LUXURY200',
'name': 'Luxury Suite Package',
'description': 'Experience our premium suites with exclusive amenities, fine dining, and concierge service',
'discount_type': DiscountType.fixed_amount,
'discount_value': 200.00,
'start_date': start_date,
'end_date': next_6_months,
'is_active': True,
'min_booking_amount': None, # No minimum for now
'max_discount_amount': None,
'usage_limit': None,
},
{
'code': 'HONEYMOON25',
'name': 'Honeymoon Special',
'description': 'Romantic getaway with champagne, flowers, special amenities, and complimentary room upgrade',
'discount_type': DiscountType.percentage,
'discount_value': 25.00,
'start_date': start_date,
'end_date': next_3_months,
'is_active': True,
'min_booking_amount': None,
'max_discount_amount': None,
'usage_limit': None,
},
{
'code': 'FAMILY15',
'name': 'Family Fun Package',
'description': 'Perfect for families! Includes family room, kids activities, and complimentary meals for children under 12',
'discount_type': DiscountType.percentage,
'discount_value': 15.00,
'start_date': start_date,
'end_date': next_6_months,
'is_active': True,
'min_booking_amount': None,
'max_discount_amount': None,
'usage_limit': None,
},
{
'code': 'BUSINESS10',
'name': 'Business Traveler',
'description': 'Extended stay discounts for business travelers. Includes high-speed WiFi, workspace, and airport transfer',
'discount_type': DiscountType.percentage,
'discount_value': 10.00,
'start_date': start_date,
'end_date': next_3_months,
'is_active': True,
'min_booking_amount': None,
'max_discount_amount': None,
'usage_limit': None,
},
]
created_count = 0
updated_count = 0
for promo_data in promotions_data:
existing = db.query(Promotion).filter(Promotion.code == promo_data['code']).first()
if existing:
# Update existing promotion
for key, value in promo_data.items():
if key != 'code': # Don't update the code
setattr(existing, key, value)
existing.updated_at = datetime.utcnow()
updated_count += 1
print(f'✓ Updated promotion: {promo_data["code"]}')
else:
# Create new promotion
promotion = Promotion(**promo_data)
db.add(promotion)
created_count += 1
print(f'✓ Created promotion: {promo_data["code"]}')
db.commit()
print(f'\n✓ Promotions seeded: {created_count} created, {updated_count} updated')
def main():
db: Session = SessionLocal()
try:
print('=' * 80)
print('SEEDING PROMOTIONS')
print('=' * 80)
print()
seed_promotions(db)
print('\n' + '=' * 80)
print('✓ All promotions seeded successfully!')
print('=' * 80)
except Exception as e:
db.rollback()
print(f'\n✗ Error seeding promotions: {e}')
import traceback
traceback.print_exc()
raise
finally:
db.close()
if __name__ == '__main__':
main()

View File

@@ -12,6 +12,7 @@ from ..models.guest_request import GuestRequest, RequestType, RequestStatus, Req
from ...bookings.models.booking import Booking, BookingStatus
from ...rooms.models.room import Room
from pydantic import BaseModel
from ...shared.utils.sanitization import sanitize_text
logger = get_logger(__name__)
router = APIRouter(prefix='/guest-requests', tags=['guest-requests'])
@@ -163,18 +164,135 @@ async def create_guest_request(
if booking.room_id != request_data.room_id:
raise HTTPException(status_code=400, detail='Room ID does not match booking')
# Sanitize user input to prevent XSS
sanitized_title = sanitize_text(request_data.title)
sanitized_description = sanitize_text(request_data.description) if request_data.description else None
sanitized_guest_notes = sanitize_text(request_data.guest_notes) if request_data.guest_notes else None
guest_request = GuestRequest(
booking_id=request_data.booking_id,
room_id=request_data.room_id,
user_id=current_user.id,
request_type=RequestType(request_data.request_type),
priority=RequestPriority(request_data.priority),
title=request_data.title,
description=request_data.description,
guest_notes=request_data.guest_notes,
title=sanitized_title,
description=sanitized_description,
guest_notes=sanitized_guest_notes,
)
db.add(guest_request)
db.flush() # Flush to get the ID for task creation
# Auto-create housekeeping task for request types that require housekeeping
request_type = RequestType(request_data.request_type)
task_types_requiring_housekeeping = {
RequestType.extra_towels,
RequestType.extra_pillows,
RequestType.room_cleaning,
RequestType.turndown_service,
RequestType.amenities,
}
if request_type in task_types_requiring_housekeeping:
try:
from ...hotel_services.models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
from ...rooms.models.room import Room
# Determine housekeeping task type based on request type
task_type_map = {
RequestType.room_cleaning: HousekeepingType.stayover,
RequestType.turndown_service: HousekeepingType.turndown,
RequestType.extra_towels: HousekeepingType.stayover,
RequestType.extra_pillows: HousekeepingType.stayover,
RequestType.amenities: HousekeepingType.stayover,
}
housekeeping_task_type = task_type_map.get(request_type, HousekeepingType.stayover)
# Create default checklist based on request type
checklist_items = []
if request_type == RequestType.room_cleaning:
checklist_items = [
{'item': 'Room cleaned', 'completed': False, 'notes': ''},
{'item': 'Bathroom cleaned', 'completed': False, 'notes': ''},
{'item': 'Trash emptied', 'completed': False, 'notes': ''},
{'item': 'Beds made', 'completed': False, 'notes': ''},
]
elif request_type == RequestType.turndown_service:
checklist_items = [
{'item': 'Beds turned down', 'completed': False, 'notes': ''},
{'item': 'Curtains closed', 'completed': False, 'notes': ''},
{'item': 'Lights dimmed', 'completed': False, 'notes': ''},
{'item': 'Amenities refreshed', 'completed': False, 'notes': ''},
]
elif request_type in [RequestType.extra_towels, RequestType.extra_pillows, RequestType.amenities]:
item_name = 'Extra towels' if request_type == RequestType.extra_towels else \
'Extra pillows' if request_type == RequestType.extra_pillows else 'Amenities'
checklist_items = [
{'item': f'{item_name} delivered', 'completed': False, 'notes': request_data.description or ''},
]
# Check if a similar task already exists for this room
existing_task = db.query(HousekeepingTask).filter(
and_(
HousekeepingTask.room_id == request_data.room_id,
HousekeepingTask.task_type == housekeeping_task_type,
HousekeepingTask.status.in_([HousekeepingStatus.pending, HousekeepingStatus.in_progress]),
HousekeepingTask.booking_id == request_data.booking_id
)
).first()
if not existing_task:
# Create housekeeping task
housekeeping_task = HousekeepingTask(
room_id=request_data.room_id,
booking_id=request_data.booking_id,
task_type=housekeeping_task_type,
status=HousekeepingStatus.pending,
scheduled_time=datetime.utcnow(), # Schedule immediately for guest requests
created_by=None, # Created by system/guest request
checklist_items=checklist_items,
notes=f'Auto-created from guest request: {request_data.title}. Guest notes: {request_data.guest_notes or "None"}',
estimated_duration_minutes=15 if request_type in [RequestType.extra_towels, RequestType.extra_pillows, RequestType.amenities] else 30
)
db.add(housekeeping_task)
db.flush()
# Link guest request to housekeeping task via notes
guest_request.staff_notes = f'Auto-created housekeeping task #{housekeeping_task.id}'
# Send notification to housekeeping users
try:
from ...notifications.routes.notification_routes import notification_manager
room = db.query(Room).filter(Room.id == request_data.room_id).first()
task_data_notification = {
'id': housekeeping_task.id,
'room_id': housekeeping_task.room_id,
'room_number': room.room_number if room else None,
'task_type': housekeeping_task.task_type.value,
'status': housekeeping_task.status.value,
'scheduled_time': housekeeping_task.scheduled_time.isoformat() if housekeeping_task.scheduled_time else None,
'guest_request_id': guest_request.id,
'guest_request_title': request_data.title,
'created_at': housekeeping_task.created_at.isoformat() if housekeeping_task.created_at else None
}
notification_data = {
'type': 'housekeeping_task_available',
'data': task_data_notification
}
# Send notification to all housekeeping users
await notification_manager.send_to_role('housekeeping', notification_data)
except Exception as e:
logger.error(f'Error sending housekeeping notification for guest request: {str(e)}', exc_info=True)
logger.info(f'Auto-created housekeeping task {housekeeping_task.id} for guest request {guest_request.id} (type: {request_type.value})')
except Exception as e:
# Don't fail guest request creation if task creation fails
logger.error(f'Error auto-creating housekeeping task for guest request: {str(e)}', exc_info=True)
db.commit()
db.refresh(guest_request)
@@ -378,7 +496,8 @@ async def fulfill_request(
request.fulfilled_at = datetime.utcnow()
if staff_notes:
request.staff_notes = (request.staff_notes or '') + f'\n{staff_notes}' if request.staff_notes else staff_notes
sanitized_notes = sanitize_text(staff_notes)
request.staff_notes = (request.staff_notes or '') + f'\n{sanitized_notes}' if request.staff_notes else sanitized_notes
if request.started_at:
delta = datetime.utcnow() - request.started_at

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Boolean, Text, Enum
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Boolean, Text, Enum, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
@@ -18,6 +18,30 @@ class Promotion(Base):
discount_value = Column(Numeric(10, 2), nullable=False)
min_booking_amount = Column(Numeric(10, 2), nullable=True)
max_discount_amount = Column(Numeric(10, 2), nullable=True)
min_stay_days = Column(Integer, nullable=True, comment="Minimum number of nights required for booking")
max_stay_days = Column(Integer, nullable=True, comment="Maximum number of nights allowed for booking")
advance_booking_days = Column(Integer, nullable=True, comment="Minimum days in advance the booking must be made")
max_advance_booking_days = Column(Integer, nullable=True, comment="Maximum days in advance the booking can be made")
# Day of week restrictions (0=Monday, 6=Sunday, stored as JSON array like [0,1,2] for Mon-Wed)
allowed_check_in_days = Column(JSON, nullable=True, comment="Allowed check-in days of week (0-6, Mon-Sun)")
allowed_check_out_days = Column(JSON, nullable=True, comment="Allowed check-out days of week (0-6, Mon-Sun)")
# Room type restrictions (stored as JSON array of room type IDs or names)
allowed_room_type_ids = Column(JSON, nullable=True, comment="Allowed room type IDs (JSON array)")
excluded_room_type_ids = Column(JSON, nullable=True, comment="Excluded room type IDs (JSON array)")
# Guest count restrictions
min_guests = Column(Integer, nullable=True, comment="Minimum number of guests required")
max_guests = Column(Integer, nullable=True, comment="Maximum number of guests allowed")
# Customer type restrictions
first_time_customer_only = Column(Boolean, nullable=False, default=False, comment="Only for first-time customers")
repeat_customer_only = Column(Boolean, nullable=False, default=False, comment="Only for returning customers")
# Blackout dates (stored as JSON array of date strings)
blackout_dates = Column(JSON, nullable=True, comment="Blackout dates when promotion doesn't apply (JSON array of YYYY-MM-DD)")
start_date = Column(DateTime, nullable=False)
end_date = Column(DateTime, nullable=False)
usage_limit = Column(Integer, nullable=True)

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import or_
from typing import Optional
from datetime import datetime
from datetime import datetime, timedelta
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import get_current_user, authorize_roles
@@ -17,6 +17,40 @@ from ..schemas.promotion import (
logger = get_logger(__name__)
router = APIRouter(prefix='/promotions', tags=['promotions'])
def serialize_promotion(promo: Promotion) -> dict:
"""Helper function to serialize promotion object with all fields"""
return {
'id': promo.id,
'code': promo.code,
'name': promo.name,
'description': promo.description,
'discount_type': promo.discount_type.value if isinstance(promo.discount_type, DiscountType) else promo.discount_type,
'discount_value': float(promo.discount_value) if promo.discount_value else 0.0,
'min_booking_amount': float(promo.min_booking_amount) if promo.min_booking_amount else None,
'max_discount_amount': float(promo.max_discount_amount) if promo.max_discount_amount else None,
'min_stay_days': promo.min_stay_days,
'max_stay_days': promo.max_stay_days,
'advance_booking_days': promo.advance_booking_days,
'max_advance_booking_days': promo.max_advance_booking_days,
'allowed_check_in_days': promo.allowed_check_in_days,
'allowed_check_out_days': promo.allowed_check_out_days,
'allowed_room_type_ids': promo.allowed_room_type_ids,
'excluded_room_type_ids': promo.excluded_room_type_ids,
'min_guests': promo.min_guests,
'max_guests': promo.max_guests,
'first_time_customer_only': promo.first_time_customer_only,
'repeat_customer_only': promo.repeat_customer_only,
'blackout_dates': promo.blackout_dates,
'start_date': promo.start_date.isoformat() if promo.start_date else None,
'end_date': promo.end_date.isoformat() if promo.end_date else None,
'usage_limit': promo.usage_limit,
'used_count': promo.used_count,
'is_active': promo.is_active,
'status': 'active' if promo.is_active else 'inactive',
'created_at': promo.created_at.isoformat() if promo.created_at else None,
'updated_at': promo.updated_at.isoformat() if promo.updated_at else None,
}
@router.get('/')
async def get_promotions(search: Optional[str]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), type: Optional[str]=Query(None), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), db: Session=Depends(get_db)):
try:
@@ -36,7 +70,7 @@ async def get_promotions(search: Optional[str]=Query(None), status_filter: Optio
promotions = query.order_by(Promotion.created_at.desc()).offset(offset).limit(limit).all()
result = []
for promo in promotions:
promo_dict = {'id': promo.id, 'code': promo.code, 'name': promo.name, 'description': promo.description, 'discount_type': promo.discount_type.value if isinstance(promo.discount_type, DiscountType) else promo.discount_type, 'discount_value': float(promo.discount_value) if promo.discount_value else 0.0, 'min_booking_amount': float(promo.min_booking_amount) if promo.min_booking_amount else None, 'max_discount_amount': float(promo.max_discount_amount) if promo.max_discount_amount else None, 'start_date': promo.start_date.isoformat() if promo.start_date else None, 'end_date': promo.end_date.isoformat() if promo.end_date else None, 'usage_limit': promo.usage_limit, 'used_count': promo.used_count, 'is_active': promo.is_active, 'created_at': promo.created_at.isoformat() if promo.created_at else None}
promo_dict = serialize_promotion(promo)
result.append(promo_dict)
return {'status': 'success', 'data': {'promotions': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}}
except Exception as e:
@@ -50,7 +84,7 @@ async def get_promotion_by_code(code: str, db: Session=Depends(get_db)):
promotion = db.query(Promotion).filter(Promotion.code == code).first()
if not promotion:
raise HTTPException(status_code=404, detail='Promotion not found')
promo_dict = {'id': promotion.id, 'code': promotion.code, 'name': promotion.name, 'description': promotion.description, 'discount_type': promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type, 'discount_value': float(promotion.discount_value) if promotion.discount_value else 0.0, 'min_booking_amount': float(promotion.min_booking_amount) if promotion.min_booking_amount else None, 'max_discount_amount': float(promotion.max_discount_amount) if promotion.max_discount_amount else None, 'start_date': promotion.start_date.isoformat() if promotion.start_date else None, 'end_date': promotion.end_date.isoformat() if promotion.end_date else None, 'usage_limit': promotion.usage_limit, 'used_count': promotion.used_count, 'is_active': promotion.is_active}
promo_dict = serialize_promotion(promotion)
return {'status': 'success', 'data': {'promotion': promo_dict}}
except HTTPException:
raise
@@ -70,17 +104,171 @@ async def validate_promotion(validation_data: ValidatePromotionRequest, db: Sess
if not promotion.is_active:
raise HTTPException(status_code=400, detail='Promotion is not active')
now = datetime.utcnow()
if promotion.start_date and now < promotion.start_date:
raise HTTPException(status_code=400, detail='Promotion is not valid at this time')
if promotion.end_date and now > promotion.end_date:
raise HTTPException(status_code=400, detail='Promotion is not valid at this time')
if promotion.start_date:
# Compare datetimes directly (both should be timezone-naive UTC)
if now < promotion.start_date:
logger.warning(f'Promotion {code} not started yet. Now: {now}, Start: {promotion.start_date}')
raise HTTPException(status_code=400, detail=f'Promotion is not valid yet. Valid from {promotion.start_date.strftime("%Y-%m-%d %H:%M:%S")} UTC')
if promotion.end_date:
# Compare datetimes directly (both should be timezone-naive UTC)
if now > promotion.end_date:
logger.warning(f'Promotion {code} expired. Now: {now}, End: {promotion.end_date}')
raise HTTPException(status_code=400, detail=f'Promotion has expired. Valid until {promotion.end_date.strftime("%Y-%m-%d %H:%M:%S")} UTC')
if promotion.usage_limit and promotion.used_count >= promotion.usage_limit:
raise HTTPException(status_code=400, detail='Promotion usage limit reached')
if promotion.min_booking_amount and booking_amount < float(promotion.min_booking_amount):
raise HTTPException(status_code=400, detail=f'Minimum booking amount is {promotion.min_booking_amount}')
# Check booking conditions if dates are provided
if validation_data.check_in_date and validation_data.check_out_date:
try:
check_in = datetime.strptime(validation_data.check_in_date, '%Y-%m-%d').date()
check_out = datetime.strptime(validation_data.check_out_date, '%Y-%m-%d').date()
today = datetime.utcnow().date()
# Check minimum stay days
if promotion.min_stay_days:
stay_days = (check_out - check_in).days
if stay_days < promotion.min_stay_days:
raise HTTPException(
status_code=400,
detail=f'This promotion requires a minimum stay of {promotion.min_stay_days} night{"s" if promotion.min_stay_days > 1 else ""}. Your booking is for {stay_days} night{"s" if stay_days != 1 else ""}.'
)
# Check maximum stay days
if promotion.max_stay_days:
stay_days = (check_out - check_in).days
if stay_days > promotion.max_stay_days:
raise HTTPException(
status_code=400,
detail=f'This promotion allows a maximum stay of {promotion.max_stay_days} night{"s" if promotion.max_stay_days > 1 else ""}. Your booking is for {stay_days} night{"s" if stay_days != 1 else ""}.'
)
# Check advance booking requirement
if promotion.advance_booking_days:
days_until_checkin = (check_in - today).days
if days_until_checkin < promotion.advance_booking_days:
raise HTTPException(
status_code=400,
detail=f'This promotion requires booking at least {promotion.advance_booking_days} day{"s" if promotion.advance_booking_days > 1 else ""} in advance. Check-in is in {days_until_checkin} day{"s" if days_until_checkin != 1 else ""}.'
)
# Check maximum advance booking
if promotion.max_advance_booking_days:
days_until_checkin = (check_in - today).days
if days_until_checkin > promotion.max_advance_booking_days:
raise HTTPException(
status_code=400,
detail=f'This promotion allows booking up to {promotion.max_advance_booking_days} day{"s" if promotion.max_advance_booking_days > 1 else ""} in advance. Check-in is in {days_until_checkin} day{"s" if days_until_checkin != 1 else ""}.'
)
# Check day of week restrictions for check-in
if promotion.allowed_check_in_days:
check_in_weekday = check_in.weekday() # 0=Monday, 6=Sunday
if check_in_weekday not in promotion.allowed_check_in_days:
days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
allowed_days = ', '.join([days[d] for d in promotion.allowed_check_in_days])
raise HTTPException(
status_code=400,
detail=f'This promotion is only valid for check-in on: {allowed_days}'
)
# Check day of week restrictions for check-out
if promotion.allowed_check_out_days:
check_out_weekday = check_out.weekday()
if check_out_weekday not in promotion.allowed_check_out_days:
days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
allowed_days = ', '.join([days[d] for d in promotion.allowed_check_out_days])
raise HTTPException(
status_code=400,
detail=f'This promotion is only valid for check-out on: {allowed_days}'
)
# Check blackout dates
if promotion.blackout_dates:
check_in_str = check_in.strftime('%Y-%m-%d')
check_out_str = check_out.strftime('%Y-%m-%d')
# Check if check-in or check-out falls on a blackout date
if check_in_str in promotion.blackout_dates:
raise HTTPException(
status_code=400,
detail=f'This promotion is not valid for check-in on {check_in_str} (blackout date)'
)
if check_out_str in promotion.blackout_dates:
raise HTTPException(
status_code=400,
detail=f'This promotion is not valid for check-out on {check_out_str} (blackout date)'
)
# Check if any date in the stay range is a blackout date
current_date = check_in
while current_date < check_out:
if current_date.strftime('%Y-%m-%d') in promotion.blackout_dates:
raise HTTPException(
status_code=400,
detail=f'This promotion is not valid during your stay period (includes blackout date: {current_date.strftime("%Y-%m-%d")})'
)
current_date += timedelta(days=1)
except ValueError as e:
logger.warning(f'Invalid date format in promotion validation: {e}')
# Continue validation even if date parsing fails (backward compatibility)
# Check room type restrictions
if validation_data.room_type_id is not None:
if promotion.allowed_room_type_ids and validation_data.room_type_id not in promotion.allowed_room_type_ids:
raise HTTPException(
status_code=400,
detail='This promotion is not valid for the selected room type'
)
if promotion.excluded_room_type_ids and validation_data.room_type_id in promotion.excluded_room_type_ids:
raise HTTPException(
status_code=400,
detail='This promotion is not valid for the selected room type'
)
# Check guest count restrictions
if validation_data.guest_count is not None:
if promotion.min_guests and validation_data.guest_count < promotion.min_guests:
raise HTTPException(
status_code=400,
detail=f'This promotion requires a minimum of {promotion.min_guests} guest{"s" if promotion.min_guests > 1 else ""}'
)
if promotion.max_guests and validation_data.guest_count > promotion.max_guests:
raise HTTPException(
status_code=400,
detail=f'This promotion allows a maximum of {promotion.max_guests} guest{"s" if promotion.max_guests > 1 else ""}'
)
# Check customer type restrictions
if promotion.first_time_customer_only:
if validation_data.is_first_time_customer is None:
raise HTTPException(
status_code=400,
detail='This promotion is only available to first-time customers. Please log in to verify your customer status.'
)
if not validation_data.is_first_time_customer:
raise HTTPException(
status_code=400,
detail='This promotion is only available to first-time customers'
)
if promotion.repeat_customer_only:
if validation_data.is_first_time_customer is None:
raise HTTPException(
status_code=400,
detail='This promotion is only available to returning customers. Please log in to verify your customer status.'
)
if validation_data.is_first_time_customer:
raise HTTPException(
status_code=400,
detail='This promotion is only available to returning customers'
)
discount_amount = promotion.calculate_discount(booking_amount)
final_amount = booking_amount - discount_amount
return {'success': True, 'status': 'success', 'data': {'promotion': {'id': promotion.id, 'code': promotion.code, 'name': promotion.name, 'description': promotion.description, 'discount_type': promotion.discount_type.value if hasattr(promotion.discount_type, 'value') else str(promotion.discount_type), 'discount_value': float(promotion.discount_value) if promotion.discount_value else 0, 'min_booking_amount': float(promotion.min_booking_amount) if promotion.min_booking_amount else None, 'max_discount_amount': float(promotion.max_discount_amount) if promotion.max_discount_amount else None, 'start_date': promotion.start_date.isoformat() if promotion.start_date else None, 'end_date': promotion.end_date.isoformat() if promotion.end_date else None, 'usage_limit': promotion.usage_limit, 'used_count': promotion.used_count, 'status': 'active' if promotion.is_active else 'inactive'}, 'discount': discount_amount, 'original_amount': booking_amount, 'discount_amount': discount_amount, 'final_amount': final_amount}, 'message': 'Promotion validated successfully'}
promo_dict = serialize_promotion(promotion)
return {'success': True, 'status': 'success', 'data': {'promotion': promo_dict, 'discount': discount_amount, 'original_amount': booking_amount, 'discount_amount': discount_amount, 'final_amount': final_amount}, 'message': 'Promotion validated successfully'}
except HTTPException:
raise
except Exception as e:
@@ -105,6 +293,19 @@ async def create_promotion(promotion_data: CreatePromotionRequest, current_user:
discount_value=discount_value,
min_booking_amount=promotion_data.min_booking_amount,
max_discount_amount=promotion_data.max_discount_amount,
min_stay_days=promotion_data.min_stay_days,
max_stay_days=promotion_data.max_stay_days,
advance_booking_days=promotion_data.advance_booking_days,
max_advance_booking_days=promotion_data.max_advance_booking_days,
allowed_check_in_days=promotion_data.allowed_check_in_days,
allowed_check_out_days=promotion_data.allowed_check_out_days,
allowed_room_type_ids=promotion_data.allowed_room_type_ids,
excluded_room_type_ids=promotion_data.excluded_room_type_ids,
min_guests=promotion_data.min_guests,
max_guests=promotion_data.max_guests,
first_time_customer_only=promotion_data.first_time_customer_only or False,
repeat_customer_only=promotion_data.repeat_customer_only or False,
blackout_dates=promotion_data.blackout_dates,
start_date=datetime.fromisoformat(promotion_data.start_date.replace('Z', '+00:00')) if promotion_data.start_date else None,
end_date=datetime.fromisoformat(promotion_data.end_date.replace('Z', '+00:00')) if promotion_data.end_date else None,
usage_limit=promotion_data.usage_limit,
@@ -151,6 +352,32 @@ async def update_promotion(id: int, promotion_data: UpdatePromotionRequest, curr
promotion.min_booking_amount = promotion_data.min_booking_amount
if promotion_data.max_discount_amount is not None:
promotion.max_discount_amount = promotion_data.max_discount_amount
if promotion_data.min_stay_days is not None:
promotion.min_stay_days = promotion_data.min_stay_days
if promotion_data.max_stay_days is not None:
promotion.max_stay_days = promotion_data.max_stay_days
if promotion_data.advance_booking_days is not None:
promotion.advance_booking_days = promotion_data.advance_booking_days
if promotion_data.max_advance_booking_days is not None:
promotion.max_advance_booking_days = promotion_data.max_advance_booking_days
if promotion_data.allowed_check_in_days is not None:
promotion.allowed_check_in_days = promotion_data.allowed_check_in_days
if promotion_data.allowed_check_out_days is not None:
promotion.allowed_check_out_days = promotion_data.allowed_check_out_days
if promotion_data.allowed_room_type_ids is not None:
promotion.allowed_room_type_ids = promotion_data.allowed_room_type_ids
if promotion_data.excluded_room_type_ids is not None:
promotion.excluded_room_type_ids = promotion_data.excluded_room_type_ids
if promotion_data.min_guests is not None:
promotion.min_guests = promotion_data.min_guests
if promotion_data.max_guests is not None:
promotion.max_guests = promotion_data.max_guests
if promotion_data.first_time_customer_only is not None:
promotion.first_time_customer_only = promotion_data.first_time_customer_only
if promotion_data.repeat_customer_only is not None:
promotion.repeat_customer_only = promotion_data.repeat_customer_only
if promotion_data.blackout_dates is not None:
promotion.blackout_dates = promotion_data.blackout_dates
if promotion_data.start_date is not None:
promotion.start_date = datetime.fromisoformat(promotion_data.start_date.replace('Z', '+00:00')) if promotion_data.start_date else None
if promotion_data.end_date is not None:

View File

@@ -2,7 +2,7 @@
Pydantic schemas for promotion-related requests and responses.
"""
from pydantic import BaseModel, Field, field_validator
from typing import Optional
from typing import Optional, List
from datetime import datetime
@@ -11,6 +11,11 @@ class ValidatePromotionRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=50, description="Promotion code")
booking_value: Optional[float] = Field(None, ge=0, description="Booking value/amount")
booking_amount: Optional[float] = Field(None, ge=0, description="Booking amount (alias for booking_value)")
check_in_date: Optional[str] = Field(None, description="Check-in date (ISO format YYYY-MM-DD)")
check_out_date: Optional[str] = Field(None, description="Check-out date (ISO format YYYY-MM-DD)")
room_type_id: Optional[int] = Field(None, description="Room type ID for the booking")
guest_count: Optional[int] = Field(None, ge=1, description="Number of guests")
is_first_time_customer: Optional[bool] = Field(None, description="Whether customer is booking for the first time")
@field_validator('code')
@classmethod
@@ -24,7 +29,9 @@ class ValidatePromotionRequest(BaseModel):
"json_schema_extra": {
"example": {
"code": "SUMMER2024",
"booking_value": 500.00
"booking_value": 500.00,
"check_in_date": "2024-06-15",
"check_out_date": "2024-06-22"
}
}
}
@@ -39,6 +46,30 @@ class CreatePromotionRequest(BaseModel):
discount_value: float = Field(..., gt=0, description="Discount value")
min_booking_amount: Optional[float] = Field(None, ge=0)
max_discount_amount: Optional[float] = Field(None, ge=0)
min_stay_days: Optional[int] = Field(None, ge=1, description="Minimum number of nights required for booking")
max_stay_days: Optional[int] = Field(None, ge=1, description="Maximum number of nights allowed for booking")
advance_booking_days: Optional[int] = Field(None, ge=0, description="Minimum days in advance the booking must be made")
max_advance_booking_days: Optional[int] = Field(None, ge=0, description="Maximum days in advance the booking can be made")
# Day of week restrictions (0=Monday, 6=Sunday)
allowed_check_in_days: Optional[List[int]] = Field(None, description="Allowed check-in days of week (0-6, Mon-Sun)")
allowed_check_out_days: Optional[List[int]] = Field(None, description="Allowed check-out days of week (0-6, Mon-Sun)")
# Room type restrictions
allowed_room_type_ids: Optional[List[int]] = Field(None, description="Allowed room type IDs")
excluded_room_type_ids: Optional[List[int]] = Field(None, description="Excluded room type IDs")
# Guest count restrictions
min_guests: Optional[int] = Field(None, ge=1, description="Minimum number of guests required")
max_guests: Optional[int] = Field(None, ge=1, description="Maximum number of guests allowed")
# Customer type restrictions
first_time_customer_only: Optional[bool] = Field(False, description="Only for first-time customers")
repeat_customer_only: Optional[bool] = Field(False, description="Only for returning customers")
# Blackout dates (array of date strings YYYY-MM-DD)
blackout_dates: Optional[List[str]] = Field(None, description="Blackout dates when promotion doesn't apply")
start_date: Optional[str] = Field(None, description="Start date (ISO format)")
end_date: Optional[str] = Field(None, description="End date (ISO format)")
usage_limit: Optional[int] = Field(None, ge=1)
@@ -97,6 +128,25 @@ class UpdatePromotionRequest(BaseModel):
discount_value: Optional[float] = Field(None, gt=0)
min_booking_amount: Optional[float] = Field(None, ge=0)
max_discount_amount: Optional[float] = Field(None, ge=0)
min_stay_days: Optional[int] = Field(None, ge=1)
max_stay_days: Optional[int] = Field(None, ge=1)
advance_booking_days: Optional[int] = Field(None, ge=0)
max_advance_booking_days: Optional[int] = Field(None, ge=0)
allowed_check_in_days: Optional[List[int]] = None
allowed_check_out_days: Optional[List[int]] = None
allowed_room_type_ids: Optional[List[int]] = None
excluded_room_type_ids: Optional[List[int]] = None
min_guests: Optional[int] = Field(None, ge=1)
max_guests: Optional[int] = Field(None, ge=1)
first_time_customer_only: Optional[bool] = None
repeat_customer_only: Optional[bool] = None
blackout_dates: Optional[List[str]] = None
start_date: Optional[str] = None
end_date: Optional[str] = None
usage_limit: Optional[int] = Field(None, ge=1)

View File

@@ -12,6 +12,7 @@ import json
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import authorize_roles
from ...security.middleware.step_up_auth import authorize_financial_access
from ...auth.models.user import User
from ..models.financial_audit_trail import FinancialAuditTrail, FinancialActionType
from ..services.financial_audit_service import financial_audit_service
@@ -33,7 +34,7 @@ async def get_financial_audit_trail(
end_date: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(100, ge=1, le=1000),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get financial audit trail records with filters. Requires step-up authentication."""
@@ -205,7 +206,7 @@ async def get_financial_audit_trail(
@router.get('/{record_id}')
async def get_audit_record(
record_id: int,
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get a specific audit trail record."""
@@ -259,7 +260,7 @@ async def export_audit_trail(
user_id: Optional[int] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Export financial audit trail to CSV or JSON. Requires step-up authentication."""
@@ -497,7 +498,7 @@ async def cleanup_old_audit_records(
@router.get('/retention/stats')
async def get_retention_stats(
retention_days: int = Query(2555, ge=365, le=3650),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get statistics about audit trail retention."""

View File

@@ -8,6 +8,7 @@ import io
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import authorize_roles
from ...security.middleware.step_up_auth import authorize_financial_access
from ...auth.models.user import User
from ..models.payment import Payment, PaymentStatus, PaymentMethod
from ..models.invoice import Invoice, InvoiceStatus
@@ -27,7 +28,7 @@ router = APIRouter(prefix='/financial', tags=['financial'])
async def get_profit_loss_report(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Generate Profit & Loss statement."""
@@ -240,7 +241,7 @@ async def get_profit_loss_report(
@router.get('/balance-sheet')
async def get_balance_sheet(
as_of_date: Optional[str] = Query(None),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Generate Balance Sheet statement."""
@@ -413,7 +414,7 @@ async def get_tax_report(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
format: Optional[str] = Query('json', regex='^(json|csv)$'),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Generate tax report with export capability. Requires step-up authentication for exports."""
@@ -545,7 +546,7 @@ async def get_payment_reconciliation(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
include_exceptions: bool = Query(True),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Generate payment reconciliation report with exception integration."""
@@ -643,7 +644,7 @@ async def get_payment_reconciliation(
async def get_refund_history(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get refund history and statistics."""

View File

@@ -8,6 +8,7 @@ from datetime import datetime
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import authorize_roles, get_current_user
from ...security.middleware.step_up_auth import authorize_financial_access
from ...auth.models.user import User
from ..services.gl_service import gl_service
from ..models.fiscal_period import FiscalPeriod, PeriodStatus
@@ -23,7 +24,7 @@ router = APIRouter(prefix='/financial/gl', tags=['general-ledger'])
async def get_trial_balance(
period_id: Optional[int] = Query(None),
as_of_date: Optional[str] = Query(None),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get trial balance for a period or as of a date."""
@@ -42,7 +43,7 @@ async def get_trial_balance(
@router.get('/periods')
async def get_fiscal_periods(
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get all fiscal periods."""
@@ -156,7 +157,7 @@ async def close_fiscal_period(
@router.get('/accounts')
async def get_chart_of_accounts(
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get chart of accounts."""
@@ -188,7 +189,7 @@ async def get_journal_entries(
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=100),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get journal entries with pagination."""

View File

@@ -5,6 +5,7 @@ from datetime import datetime
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import get_current_user, authorize_roles
from ...security.middleware.step_up_auth import authorize_financial_access
from ...auth.models.user import User
from ..models.invoice import Invoice, InvoiceStatus
from ...bookings.models.booking import Booking
@@ -130,7 +131,7 @@ async def create_invoice(request: Request, invoice_data: CreateInvoiceRequest, c
raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}')
async def update_invoice(request: Request, id: int, invoice_data: UpdateInvoiceRequest, current_user: User=Depends(authorize_roles('admin', 'accountant')), db: Session=Depends(get_db)):
async def update_invoice(request: Request, id: int, invoice_data: UpdateInvoiceRequest, current_user: User=Depends(authorize_financial_access('admin', 'accountant')), db: Session=Depends(get_db)):
try:
invoice = db.query(Invoice).filter(Invoice.id == id).first()
if not invoice:
@@ -171,7 +172,7 @@ async def update_invoice(request: Request, id: int, invoice_data: UpdateInvoiceR
raise HTTPException(status_code=500, detail=str(e))
@router.post('/{id}/mark-paid')
async def mark_invoice_as_paid(request: Request, id: int, payment_data: MarkInvoicePaidRequest, current_user: User=Depends(authorize_roles('admin', 'accountant')), db: Session=Depends(get_db)):
async def mark_invoice_as_paid(request: Request, id: int, payment_data: MarkInvoicePaidRequest, current_user: User=Depends(authorize_financial_access('admin', 'accountant')), db: Session=Depends(get_db)):
try:
request_id = get_request_id(request)
amount = payment_data.amount

View File

@@ -8,6 +8,7 @@ from datetime import datetime
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import authorize_roles, get_current_user
from ...security.middleware.step_up_auth import authorize_financial_access
from ...auth.models.user import User
from ..services.reconciliation_service import reconciliation_service
from ..models.reconciliation_exception import ExceptionStatus, ExceptionType
@@ -21,7 +22,7 @@ router = APIRouter(prefix='/financial/reconciliation', tags=['reconciliation'])
async def run_reconciliation(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Run reconciliation and detect exceptions."""
@@ -55,7 +56,7 @@ async def get_reconciliation_exceptions(
severity: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=100),
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get reconciliation exceptions with filters."""
@@ -96,7 +97,7 @@ async def get_reconciliation_exceptions(
async def assign_exception(
exception_id: int,
assign_data: dict,
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Assign an exception to a user."""
@@ -130,7 +131,7 @@ async def assign_exception(
async def resolve_exception(
exception_id: int,
resolve_data: dict,
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Resolve an exception."""
@@ -165,7 +166,7 @@ async def resolve_exception(
async def add_exception_comment(
exception_id: int,
comment_data: dict,
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Add a comment to an exception."""
@@ -198,7 +199,7 @@ async def add_exception_comment(
@router.get('/exceptions/stats')
async def get_exception_stats(
current_user: User = Depends(authorize_roles('admin', 'accountant')),
current_user: User = Depends(authorize_financial_access('admin', 'accountant')),
db: Session = Depends(get_db)
):
"""Get statistics about reconciliation exceptions."""

View File

@@ -9,6 +9,7 @@ import hashlib
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import get_current_user, authorize_roles
from ...shared.utils.sanitization import sanitize_text
from ...auth.models.user import User
from ...auth.models.role import Role
from ..models.room import Room, RoomStatus
@@ -246,12 +247,17 @@ async def create_maintenance_record(
if maintenance_data.get('block_end'):
block_end = datetime.fromisoformat(maintenance_data['block_end'].replace('Z', '+00:00'))
# Sanitize user input
sanitized_title = sanitize_text(maintenance_data.get('title', 'Maintenance'))
sanitized_description = sanitize_text(maintenance_data.get('description')) if maintenance_data.get('description') else None
sanitized_notes = sanitize_text(maintenance_data.get('notes')) if maintenance_data.get('notes') else None
maintenance = RoomMaintenance(
room_id=maintenance_data['room_id'],
maintenance_type=MaintenanceType(maintenance_data.get('maintenance_type', 'preventive')),
status=MaintenanceStatus(maintenance_data.get('status', 'scheduled')),
title=maintenance_data.get('title', 'Maintenance'),
description=maintenance_data.get('description'),
title=sanitized_title,
description=sanitized_description,
scheduled_start=scheduled_start,
scheduled_end=scheduled_end,
assigned_to=maintenance_data.get('assigned_to'),
@@ -261,7 +267,7 @@ async def create_maintenance_record(
block_start=block_start,
block_end=block_end,
priority=maintenance_data.get('priority', 'medium'),
notes=maintenance_data.get('notes')
notes=sanitized_notes
)
# Update room status if blocking and maintenance is active
@@ -362,7 +368,7 @@ async def update_maintenance_record(
if 'actual_end' in maintenance_data:
maintenance.actual_end = datetime.fromisoformat(maintenance_data['actual_end'].replace('Z', '+00:00'))
if 'completion_notes' in maintenance_data:
maintenance.completion_notes = maintenance_data['completion_notes']
maintenance.completion_notes = sanitize_text(maintenance_data['completion_notes']) if maintenance_data['completion_notes'] else None
if 'actual_cost' in maintenance_data:
maintenance.actual_cost = maintenance_data['actual_cost']
@@ -604,6 +610,9 @@ async def create_housekeeping_task(
if is_housekeeping and not assigned_to:
assigned_to = current_user.id
# Sanitize user input
sanitized_notes = sanitize_text(task_data.get('notes')) if task_data.get('notes') else None
task = HousekeepingTask(
room_id=task_data['room_id'],
booking_id=task_data.get('booking_id'),
@@ -613,7 +622,7 @@ async def create_housekeeping_task(
assigned_to=assigned_to,
created_by=current_user.id,
checklist_items=task_data.get('checklist_items', []),
notes=task_data.get('notes'),
notes=sanitized_notes,
estimated_duration_minutes=task_data.get('estimated_duration_minutes')
)
@@ -774,16 +783,16 @@ async def update_housekeeping_task(
if 'checklist_items' in task_data:
task.checklist_items = task_data['checklist_items']
if 'notes' in task_data:
task.notes = task_data['notes']
task.notes = sanitize_text(task_data['notes']) if task_data['notes'] else None
if 'issues_found' in task_data:
task.issues_found = task_data['issues_found']
task.issues_found = sanitize_text(task_data['issues_found']) if task_data['issues_found'] else None
if 'quality_score' in task_data:
task.quality_score = task_data['quality_score']
if 'inspected_by' in task_data:
task.inspected_by = task_data['inspected_by']
task.inspected_at = datetime.utcnow()
if 'inspection_notes' in task_data:
task.inspection_notes = task_data['inspection_notes']
task.inspection_notes = sanitize_text(task_data['inspection_notes']) if task_data['inspection_notes'] else None
if 'photos' in task_data:
task.photos = task_data['photos']
@@ -930,11 +939,11 @@ async def report_maintenance_issue_from_task(
if not room:
raise HTTPException(status_code=404, detail='Room not found')
# Create maintenance record
title = issue_data.get('title', f'Issue reported from Room {room.room_number}')
description = issue_data.get('description', '')
# Create maintenance record - sanitize user input
title = sanitize_text(issue_data.get('title', f'Issue reported from Room {room.room_number}'))
description = sanitize_text(issue_data.get('description', ''))
if task.notes:
description = f"Reported from housekeeping task.\n\nTask Notes: {task.notes}\n\nIssue Description: {description}".strip()
description = f"Reported from housekeeping task.\n\nTask Notes: {sanitize_text(task.notes)}\n\nIssue Description: {description}".strip()
else:
description = f"Reported from housekeeping task.\n\nIssue Description: {description}".strip()
@@ -949,7 +958,7 @@ async def report_maintenance_issue_from_task(
reported_by=current_user.id,
priority=issue_data.get('priority', 'high'),
blocks_room=issue_data.get('blocks_room', True),
notes=issue_data.get('notes', f'Reported from housekeeping task #{task_id}')
notes=sanitize_text(issue_data.get('notes', f'Reported from housekeeping task #{task_id}'))
)
# Update room status if blocking
@@ -1168,15 +1177,15 @@ async def update_room_inspection(
if 'overall_score' in inspection_data:
inspection.overall_score = inspection_data['overall_score']
if 'overall_notes' in inspection_data:
inspection.overall_notes = inspection_data['overall_notes']
inspection.overall_notes = sanitize_text(inspection_data['overall_notes']) if inspection_data['overall_notes'] else None
if 'issues_found' in inspection_data:
inspection.issues_found = inspection_data['issues_found']
inspection.issues_found = sanitize_text(inspection_data['issues_found']) if inspection_data['issues_found'] else None
if 'photos' in inspection_data:
inspection.photos = inspection_data['photos']
if 'requires_followup' in inspection_data:
inspection.requires_followup = inspection_data['requires_followup']
if 'followup_notes' in inspection_data:
inspection.followup_notes = inspection_data['followup_notes']
inspection.followup_notes = sanitize_text(inspection_data['followup_notes']) if inspection_data['followup_notes'] else None
if 'maintenance_request_id' in inspection_data:
inspection.maintenance_request_id = inspection_data['maintenance_request_id']

View File

@@ -124,6 +124,8 @@ def get_current_user(
)
# SECURITY: Check MFA for accountant/admin roles (warn but allow access for MFA setup)
# Note: MFA enforcement for financial endpoints is handled by route-level dependencies
# This check only logs warnings to allow users to access MFA setup pages
try:
from ...payments.services.accountant_security_service import accountant_security_service
from ...shared.utils.role_helpers import is_accountant, is_admin
@@ -135,7 +137,7 @@ def get_current_user(
is_enforced, reason = accountant_security_service.is_mfa_enforced(user, db)
if not is_enforced and reason:
# Log warning but allow access so user can set up MFA
# Individual routes can enforce MFA for sensitive operations
# Individual routes enforce MFA for sensitive operations using enforce_mfa_for_accountants()
logger.warning(
f'User {user.id} ({user.email}) accessed system without MFA enabled. '
f'MFA is required for {user.role.name if user.role else "unknown"} role. '

View File

@@ -0,0 +1,110 @@
"""
Permission-based dependencies for route authorization.
These provide granular permission checks instead of just role checks.
"""
from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Callable
from ...shared.config.database import get_db
from ...security.middleware.auth import get_current_user
from ...auth.models.user import User
from ...shared.utils import role_helpers
from ...shared.config.logging_config import get_logger
logger = get_logger(__name__)
def require_permission(permission_check_func: Callable[[User, Session], bool], permission_name: str = None):
"""
Create a dependency that requires a specific permission.
Args:
permission_check_func: Function that takes (user, db) and returns bool
permission_name: Optional name for the permission (for error messages)
"""
def permission_checker(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> User:
if not permission_check_func(current_user, db):
perm_name = permission_name or permission_check_func.__name__
logger.warning(
f'User {current_user.id} ({current_user.email}) attempted to access resource '
f'requiring permission: {perm_name}'
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Permission denied: {perm_name} required'
)
return current_user
return permission_checker
# Pre-built permission dependencies for common use cases
def require_invoice_creation():
"""Require permission to create invoices."""
return require_permission(
role_helpers.can_create_invoices,
'create_invoices'
)
def require_user_management():
"""Require permission to manage users (admin only)."""
return require_permission(
role_helpers.can_manage_users,
'manage_users'
)
def require_housekeeping_task_management():
"""Require permission to manage housekeeping tasks."""
return require_permission(
role_helpers.can_manage_housekeeping_tasks,
'manage_housekeeping_tasks'
)
def require_housekeeping_task_assignment():
"""Require permission to assign tasks to other users."""
return require_permission(
role_helpers.can_assign_housekeeping_tasks,
'assign_housekeeping_tasks'
)
def require_room_management():
"""Require permission to manage rooms."""
return require_permission(
role_helpers.can_manage_rooms,
'manage_rooms'
)
def require_booking_management():
"""Require permission to manage bookings."""
return require_permission(
role_helpers.can_manage_bookings,
'manage_bookings'
)
def require_booking_price_modification():
"""Require permission to modify booking prices (admin only)."""
return require_permission(
role_helpers.can_modify_booking_prices,
'modify_booking_prices'
)
def require_financial_report_access():
"""Require permission to view financial reports."""
def check_financial_access(user: User, db: Session) -> bool:
return role_helpers.user_has_permission(user, db, "financial.view_reports")
return require_permission(
check_financial_access,
'view_financial_reports'
)

View File

@@ -63,20 +63,30 @@ def require_step_up_auth(
def enforce_mfa_for_accountants():
"""
Dependency to enforce MFA for accountant/admin roles.
This blocks access if MFA is required but not enabled.
"""
async def mfa_enforcer(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> User:
# Check if MFA is required
# Only enforce for accountant/admin roles
if not (is_accountant(current_user, db) or is_admin(current_user, db)):
return current_user # Regular users don't need MFA
# Check if MFA is required and enforced
is_enforced, reason = accountant_security_service.is_mfa_enforced(current_user, db)
if not is_enforced and reason:
# CRITICAL: Block access instead of just logging warning
logger.warning(
f'User {current_user.id} ({current_user.email}) attempted to access financial data without MFA. '
f'Access denied. Reason: {reason}'
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'error': 'mfa_required',
'message': reason,
'message': reason or 'Multi-factor authentication is required for this role. Please enable MFA to access financial data.',
'requires_mfa_setup': True
}
)
@@ -85,3 +95,62 @@ def enforce_mfa_for_accountants():
return mfa_enforcer
def authorize_financial_access(*allowed_roles: str):
"""
Combined dependency that enforces both role authorization AND MFA for financial endpoints.
Use this for all financial routes that require accountant/admin access.
"""
def financial_access_checker(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> User:
from ...shared.utils.role_helpers import get_user_role_name
from ...auth.models.role import Role
# First check role authorization
if hasattr(current_user, 'role') and current_user.role is not None:
user_role_name = current_user.role.name
else:
role = db.query(Role).filter(Role.id == current_user.role_id).first()
if not role:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='User role not found')
user_role_name = role.name
# Check if user has required role
if user_role_name not in allowed_roles:
# Normalise accountant_* → accountant for role checks
if (
user_role_name.startswith("accountant_")
and "accountant" in allowed_roles
):
pass # allowed
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='You do not have permission to access this resource'
)
# Then enforce MFA for accountant/admin roles
if is_accountant(current_user, db) or is_admin(current_user, db):
is_enforced, reason = accountant_security_service.is_mfa_enforced(current_user, db)
if not is_enforced and reason:
# CRITICAL: Block access instead of just logging warning
logger.warning(
f'User {current_user.id} ({current_user.email}) attempted to access financial data without MFA. '
f'Access denied. Reason: {reason}'
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'error': 'mfa_required',
'message': reason or 'Multi-factor authentication is required for this role. Please enable MFA to access financial data.',
'requires_mfa_setup': True
}
)
return current_user
return financial_access_checker

View File

@@ -168,6 +168,20 @@ ROLE_PERMISSIONS = {
"financial.view_invoices",
"financial.manage_invoices",
"financial.view_payments",
"housekeeping.view_tasks",
"housekeeping.manage_tasks",
"housekeeping.view_rooms",
},
# Housekeeping: room cleaning and task management
"housekeeping": {
"housekeeping.view_tasks", # View assigned tasks and unassigned tasks
"housekeeping.manage_tasks", # Update, start, complete assigned tasks
"housekeeping.create_tasks", # Create tasks (self-assign)
"housekeeping.view_rooms", # View rooms they need to clean
"housekeeping.upload_photos", # Upload photos for tasks
"housekeeping.report_issues", # Report maintenance issues from tasks
"housekeeping.view_inspections", # View assigned inspections
"housekeeping.manage_inspections", # Complete assigned inspections
},
}
@@ -234,3 +248,44 @@ def can_manage_users(user: User, db: Session) -> bool:
return is_admin(user, db)
def can_manage_housekeeping_tasks(user: User, db: Session) -> bool:
"""Check if user can manage housekeeping tasks."""
role_name = get_user_role_name(user, db)
return role_name in {"admin", "staff", "housekeeping"}
def can_view_all_housekeeping_tasks(user: User, db: Session) -> bool:
"""Check if user can view all housekeeping tasks (not just assigned)."""
role_name = get_user_role_name(user, db)
return role_name in {"admin", "staff"}
def can_assign_housekeeping_tasks(user: User, db: Session) -> bool:
"""Check if user can assign tasks to other users."""
role_name = get_user_role_name(user, db)
return role_name in {"admin", "staff"}
def can_manage_rooms(user: User, db: Session) -> bool:
"""Check if user can manage rooms."""
role_name = get_user_role_name(user, db)
return role_name in {"admin", "staff"}
def can_manage_bookings(user: User, db: Session) -> bool:
"""Check if user can manage bookings."""
role_name = get_user_role_name(user, db)
return role_name in {"admin", "staff"}
def can_access_all_rooms(user: User, db: Session) -> bool:
"""Check if user can access all rooms."""
role_name = get_user_role_name(user, db)
return role_name in {"admin", "staff", "housekeeping"}
def can_modify_booking_prices(user: User, db: Session) -> bool:
"""Check if user can modify booking prices (admin only)."""
return is_admin(user, db)

View File

@@ -127,6 +127,7 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
const checkInDate = watch('checkInDate');
const checkOutDate = watch('checkOutDate');
const guestCount = watch('guestCount');
const paymentMethodForm = watch('paymentMethod');
useEffect(() => {
@@ -134,6 +135,26 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
fetchRoomDetails(roomId);
fetchServices();
fetchBookedDates(roomId);
// Auto-fill promotion code from sessionStorage or URL
try {
const storedPromotion = sessionStorage.getItem('activePromotion');
if (storedPromotion) {
const promo = JSON.parse(storedPromotion);
if (promo.code && !promotionCode) {
setPromotionCode(promo.code.toUpperCase());
}
}
} catch (error) {
console.warn('Failed to read promotion from sessionStorage:', error);
}
// Check URL params as fallback
const urlParams = new URLSearchParams(window.location.search);
const urlPromoCode = urlParams.get('promo');
if (urlPromoCode && !promotionCode) {
setPromotionCode(urlPromoCode.toUpperCase());
}
}
}, [isOpen, roomId]);
@@ -251,16 +272,29 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
setPromotionError('Please enter a promotion code');
return;
}
if (subtotal === 0) {
if (subtotal === 0 || !checkInDate || !checkOutDate) {
setPromotionError('Please select dates first');
return;
}
try {
setValidatingPromotion(true);
setPromotionError(null);
// Format dates as YYYY-MM-DD for backend
const checkInDateStr = formatDateLocal(checkInDate);
const checkOutDateStr = formatDateLocal(checkOutDate);
// Get room type ID from room object
const roomTypeId = room?.room_type_id || room?.room_type?.id || undefined;
const response = await promotionService.validatePromotion(
promotionCode.toUpperCase().trim(),
subtotal
subtotal,
checkInDateStr,
checkOutDateStr,
roomTypeId,
guestCount || undefined,
undefined // is_first_time_customer - will be determined by backend based on user bookings
);
if (response.success && response.data) {
setSelectedPromotion(response.data.promotion);

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
ArrowRight,
AlertCircle,
@@ -8,6 +8,7 @@ import {
ChevronRight,
ZoomIn,
} from 'lucide-react';
import { toast } from 'react-toastify';
import * as LucideIcons from 'lucide-react';
import {
BannerCarousel,
@@ -29,6 +30,7 @@ import type { PageContent } from '../services/pageContentService';
import type { Service } from '../../hotel_services/services/serviceService';
const HomePage: React.FC = () => {
const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency();
const [banners, setBanners] = useState<Banner[]>([]);
const [featuredRooms, setFeaturedRooms] = useState<Room[]>([]);
@@ -41,7 +43,7 @@ const HomePage: React.FC = () => {
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
const [, setIsLoadingContent] = useState(true);
const [isLoadingServices, setIsLoadingServices] = useState(true);
const [, setIsLoadingServices] = useState(true);
const [isLoadingBlog, setIsLoadingBlog] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiError, setApiError] = useState(false);
@@ -49,6 +51,7 @@ const HomePage: React.FC = () => {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const [lightboxImages, setLightboxImages] = useState<string[]>([]);
const [clickedPromotion, setClickedPromotion] = useState<number | null>(null);
// Prevent body scroll when API error modal is shown
useEffect(() => {
@@ -104,6 +107,100 @@ const HomePage: React.FC = () => {
return Array.from(roomMap.values());
}, [featuredRooms, newestRooms]);
// Enterprise-grade promotion click handler
const handlePromotionClick = useCallback((promo: any, index: number, e?: React.MouseEvent) => {
// Prevent default if event is provided (for button clicks)
if (e) {
e.preventDefault();
e.stopPropagation();
}
try {
// Validate promotion data
if (!promo) {
toast.error('Invalid promotion. Please try again.');
return;
}
// Check if promotion is still valid
if (promo.valid_until) {
const validUntil = new Date(promo.valid_until);
const now = new Date();
if (validUntil < now) {
toast.warning('This promotion has expired.');
return;
}
}
// Set loading state for this specific promotion
setClickedPromotion(index);
// Store promotion data in sessionStorage for booking flow
const promotionData = {
id: promo.code || `promo-${index}`,
title: promo.title,
description: promo.description,
discount: promo.discount,
code: promo.code || promo.title?.toUpperCase().replace(/\s+/g, '') || '',
valid_until: promo.valid_until,
link: promo.link,
clicked_at: new Date().toISOString(),
};
try {
sessionStorage.setItem('activePromotion', JSON.stringify(promotionData));
} catch (storageError) {
console.warn('Failed to store promotion data:', storageError);
// Continue even if storage fails
}
// Analytics tracking (enterprise-grade)
try {
if (window.gtag) {
window.gtag('event', 'promotion_click', {
promotion_title: promo.title,
promotion_code: promotionData.code,
promotion_discount: promo.discount,
});
}
} catch (analyticsError) {
console.warn('Analytics tracking failed:', analyticsError);
}
// Determine navigation target
let targetPath = promo.link || '/rooms';
// Add promotion code to URL for visibility
if (targetPath.startsWith('/rooms') || (!targetPath.startsWith('http') && targetPath.startsWith('/'))) {
const url = new URL(targetPath, window.location.origin);
if (promotionData.code) {
url.searchParams.set('promo', promotionData.code);
}
targetPath = url.pathname + url.search;
}
// Show success message
toast.success(`🎉 Promotion "${promo.title}" activated! The code will be applied when you book.`, {
autoClose: 4000,
});
// Navigate after a brief delay for UX (allows loading state to show)
setTimeout(() => {
navigate(targetPath);
// Reset loading state after navigation
setTimeout(() => {
setClickedPromotion(null);
}, 500);
}, 150);
} catch (error: any) {
console.error('Error handling promotion click:', error);
toast.error('An error occurred. Please try again.');
setClickedPromotion(null);
}
}, [navigate]);
useEffect(() => {
fetchServices();
@@ -1337,62 +1434,6 @@ const HomePage: React.FC = () => {
</section>
)}
{/* Trust Badges Section */}
{(pageContent?.sections_enabled?.trust_badges !== false) && pageContent?.trust_badges_enabled && pageContent?.trust_badges && Array.isArray(pageContent.trust_badges) && pageContent.trust_badges.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
<div className="h-0.5 w-16 md:w-20 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
</div>
{pageContent.trust_badges_section_title && (
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-3 md:mb-4 px-4">
{pageContent.trust_badges_section_title}
</h2>
)}
{pageContent.trust_badges_section_subtitle && (
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto mt-3 md:mt-4 px-4 leading-relaxed">
{pageContent.trust_badges_section_subtitle}
</p>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6 md:gap-8 px-4">
{pageContent.trust_badges.map((badge, index) => (
<div key={index} className="flex flex-col items-center text-center group">
{badge.link ? (
<a href={badge.link} target="_blank" rel="noopener noreferrer" className="w-full">
<div className="w-24 h-24 md:w-32 md:h-32 mx-auto mb-4 rounded-lg overflow-hidden bg-white p-4 shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-200 hover:border-[#d4af37]/50 group-hover:scale-105">
{badge.logo && (
<img src={badge.logo} alt={badge.name} className="w-full h-full object-contain" />
)}
</div>
{badge.name && (
<h4 className="text-sm md:text-base font-semibold text-gray-900 mt-2">{badge.name}</h4>
)}
{badge.description && (
<p className="text-xs md:text-sm text-gray-600 mt-1">{badge.description}</p>
)}
</a>
) : (
<>
<div className="w-24 h-24 md:w-32 md:h-32 mx-auto mb-4 rounded-lg overflow-hidden bg-white p-4 shadow-lg border border-gray-200">
{badge.logo && (
<img src={badge.logo} alt={badge.name} className="w-full h-full object-contain" />
)}
</div>
{badge.name && (
<h4 className="text-sm md:text-base font-semibold text-gray-900 mt-2">{badge.name}</h4>
)}
{badge.description && (
<p className="text-xs md:text-sm text-gray-600 mt-1">{badge.description}</p>
)}
</>
)}
</div>
))}
</div>
</section>
)}
{/* Promotions Section */}
{(pageContent?.sections_enabled?.promotions !== false) && pageContent?.promotions_enabled && pageContent?.promotions && Array.isArray(pageContent.promotions) && pageContent.promotions.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
@@ -1412,38 +1453,131 @@ const HomePage: React.FC = () => {
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 px-4">
{pageContent.promotions.map((promo, index) => (
<div key={index} className="relative bg-white rounded-xl shadow-xl overflow-hidden border border-gray-200 hover:shadow-2xl transition-all duration-300 group">
{pageContent.promotions.map((promo, index) => {
const isClicked = clickedPromotion === index;
const now = new Date();
now.setHours(0, 0, 0, 0);
const validUntilDate = promo.valid_until ? new Date(promo.valid_until) : null;
if (validUntilDate) {
validUntilDate.setHours(23, 59, 59, 999);
}
const isValid = validUntilDate ? validUntilDate >= now : true;
const handleCardClick = () => {
if (!isValid) {
toast.warning('This promotion has expired.');
return;
}
if (!isClicked) {
handlePromotionClick(promo, index);
}
};
return (
<div
key={index}
onClick={handleCardClick}
className={`relative bg-white rounded-xl shadow-xl overflow-hidden border transition-all duration-300 group ${
isValid
? 'border-gray-200 hover:shadow-2xl hover:-translate-y-1 cursor-pointer'
: 'border-gray-300 opacity-75 cursor-not-allowed'
} ${
isClicked ? 'opacity-75 pointer-events-none' : ''
}`}
role="button"
tabIndex={isValid ? 0 : -1}
onKeyDown={(e) => {
if (isValid && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleCardClick();
}
}}
aria-label={`${isValid ? 'View' : 'Expired'} promotion: ${promo.title}`}
aria-disabled={!isValid}
>
{promo.image && (
<div className="relative h-48 overflow-hidden">
<img src={promo.image} alt={promo.title} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
<img
src={promo.image}
alt={promo.title || 'Promotion'}
className={`w-full h-full object-cover transition-transform duration-500 ${
isValid ? 'group-hover:scale-110' : 'grayscale'
}`}
loading="lazy"
/>
{promo.discount && (
<div className="absolute top-4 right-4 bg-red-600 text-white px-4 py-2 rounded-full font-bold text-lg shadow-lg">
<div className={`absolute top-4 right-4 px-4 py-2 rounded-full font-bold text-lg shadow-lg z-10 ${
isValid ? 'bg-red-600 text-white' : 'bg-gray-500 text-white'
}`}>
{promo.discount}
</div>
)}
{!isValid && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center z-20">
<span className="bg-red-600 text-white px-6 py-3 rounded-lg font-bold text-base shadow-xl">
EXPIRED
</span>
</div>
)}
</div>
)}
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 mb-2">{promo.title}</h3>
<div className="flex items-start justify-between mb-2">
<h3 className={`text-xl font-bold mb-2 transition-colors ${
isValid ? 'text-gray-900 group-hover:text-[#d4af37]' : 'text-gray-500'
}`}>
{promo.title}
</h3>
{!isValid && (
<span className="ml-2 px-2 py-1 bg-gray-200 text-gray-600 text-xs font-semibold rounded">
Expired
</span>
)}
</div>
{promo.description && (
<p className="text-gray-600 mb-4">{promo.description}</p>
<p className={`mb-4 line-clamp-3 ${isValid ? 'text-gray-600' : 'text-gray-400'}`}>
{promo.description}
</p>
)}
{promo.valid_until && (
<p className="text-sm text-gray-500 mb-4">Valid until: {new Date(promo.valid_until).toLocaleDateString()}</p>
<p className={`text-sm mb-4 ${
isValid ? 'text-gray-500' : 'text-red-500 font-medium'
}`}>
{isValid ? 'Valid until:' : 'Expired on:'} {new Date(promo.valid_until).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</p>
)}
{promo.link && (
<Link
to={promo.link}
className="inline-flex items-center gap-2 px-6 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-semibold hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300"
<div
onClick={(e) => {
e.stopPropagation();
if (!isValid) {
toast.warning('This promotion has expired.');
return;
}
handlePromotionClick(promo, index, e);
}}
className={`inline-flex items-center gap-2 px-6 py-2 rounded-lg font-semibold transition-all duration-300 ${
isValid
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37] cursor-pointer group-hover:shadow-lg'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
role="button"
tabIndex={-1}
aria-disabled={!isValid}
>
{promo.button_text || 'Learn More'}
<ArrowRight className="w-4 h-4" />
</Link>
<span>{isClicked ? 'Loading...' : (promo.button_text || (isValid ? 'View Offer' : 'Expired'))}</span>
{!isClicked && isValid && <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />}
{isClicked && (
<div className="w-4 h-4 border-2 border-[#0f0f0f] border-t-transparent rounded-full animate-spin" />
)}
</div>
</div>
))}
</div>
);
})}
</div>
</section>
)}

View File

@@ -9,6 +9,19 @@ export interface Promotion {
discount_value: number;
min_booking_amount?: number;
max_discount_amount?: number;
min_stay_days?: number;
max_stay_days?: number;
advance_booking_days?: number;
max_advance_booking_days?: number;
allowed_check_in_days?: number[];
allowed_check_out_days?: number[];
allowed_room_type_ids?: number[];
excluded_room_type_ids?: number[];
min_guests?: number;
max_guests?: number;
first_time_customer_only?: boolean;
repeat_customer_only?: boolean;
blackout_dates?: string[];
start_date: string;
end_date: string;
usage_limit?: number;
@@ -41,6 +54,19 @@ export interface CreatePromotionData {
discount_value: number;
min_booking_amount?: number;
max_discount_amount?: number;
min_stay_days?: number;
max_stay_days?: number;
advance_booking_days?: number;
max_advance_booking_days?: number;
allowed_check_in_days?: number[];
allowed_check_out_days?: number[];
allowed_room_type_ids?: number[];
excluded_room_type_ids?: number[];
min_guests?: number;
max_guests?: number;
first_time_customer_only?: boolean;
repeat_customer_only?: boolean;
blackout_dates?: string[];
start_date: string;
end_date: string;
usage_limit?: number;
@@ -55,6 +81,19 @@ export interface UpdatePromotionData {
discount_value?: number;
min_booking_amount?: number;
max_discount_amount?: number;
min_stay_days?: number;
max_stay_days?: number;
advance_booking_days?: number;
max_advance_booking_days?: number;
allowed_check_in_days?: number[];
allowed_check_out_days?: number[];
allowed_room_type_ids?: number[];
excluded_room_type_ids?: number[];
min_guests?: number;
max_guests?: number;
first_time_customer_only?: boolean;
repeat_customer_only?: boolean;
blackout_dates?: string[];
start_date?: string;
end_date?: string;
usage_limit?: number;
@@ -106,11 +145,21 @@ export const deletePromotion = async (
export const validatePromotion = async (
code: string,
bookingValue: number
bookingValue: number,
checkInDate?: string,
checkOutDate?: string,
roomTypeId?: number,
guestCount?: number,
isFirstTimeCustomer?: boolean
): Promise<{ success: boolean; data: { promotion: Promotion; discount: number }; message: string }> => {
const response = await apiClient.post('/promotions/validate', {
code,
booking_value: bookingValue,
check_in_date: checkInDate,
check_out_date: checkOutDate,
room_type_id: roomTypeId,
guest_count: guestCount,
is_first_time_customer: isFirstTimeCustomer,
});
return response.data;
};

View File

@@ -22,10 +22,13 @@ import { useAsync } from '../../shared/hooks/useAsync';
import { useNavigate } from 'react-router-dom';
import { logger } from '../../shared/utils/logger';
import { getPaymentStatusColor } from '../../shared/utils/paymentUtils';
import MfaRequiredBanner from '../../shared/components/MfaRequiredBanner';
import { useMfaStatus } from '../../shared/hooks/useMfaStatus';
const AccountantDashboardPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const navigate = useNavigate();
const { requiresMfaButNotEnabled } = useMfaStatus();
const [dateRange, setDateRange] = useState({
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
to: new Date().toISOString().split('T')[0],
@@ -235,6 +238,11 @@ const AccountantDashboardPage: React.FC = () => {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* MFA Required Banner */}
{requiresMfaButNotEnabled && (
<MfaRequiredBanner securityPagePath="/accountant/security" />
)}
{/* Header */}
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 sm:gap-6 mb-6 sm:mb-8 md:mb-10 animate-fade-in">
<div className="w-full lg:w-auto">

View File

@@ -1,13 +1,16 @@
import React, { useState, useEffect } from 'react';
import { Shield, Activity, LogOut, AlertTriangle, CheckCircle2, Clock } from 'lucide-react';
import { toast } from 'react-toastify';
import { useSearchParams } from 'react-router-dom';
import accountantSecurityService, { AccountantSession, AccountantActivityLog, MFAStatus } from '../../features/security/services/accountantSecurityService';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate } from '../../shared/utils/format';
const SecurityManagementPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'sessions' | 'activity' | 'mfa'>('sessions');
const [searchParams] = useSearchParams();
const setupMfa = searchParams.get('setup_mfa') === 'true';
const [activeTab, setActiveTab] = useState<'sessions' | 'activity' | 'mfa'>(setupMfa ? 'mfa' : 'sessions');
const [loading, setLoading] = useState(true);
const [sessions, setSessions] = useState<AccountantSession[]>([]);
const [activityLogs, setActivityLogs] = useState<AccountantActivityLog[]>([]);
@@ -20,6 +23,19 @@ const SecurityManagementPage: React.FC = () => {
else if (activeTab === 'mfa') fetchMFAStatus();
}, [activeTab]);
// Auto-switch to MFA tab if setup_mfa query param is present
useEffect(() => {
if (setupMfa && activeTab !== 'mfa') {
setActiveTab('mfa');
fetchMFAStatus();
// Show toast notification
toast.info('Please enable Multi-Factor Authentication to access financial features.', {
autoClose: 5000,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setupMfa]);
const fetchSessions = async () => {
try {
setLoading(true);

View File

@@ -24,8 +24,14 @@ import invoiceService, { Invoice } from '../../features/payments/services/invoic
import paymentService from '../../features/payments/services/paymentService';
import type { Payment } from '../../features/payments/services/paymentService';
import promotionService, { Promotion } from '../../features/loyalty/services/promotionService';
import { getRoomTypes } from '../../features/rooms/services/roomService';
import { formatDate } from '../../shared/utils/format';
interface RoomType {
id: number;
name: string;
}
type BusinessTab = 'overview' | 'invoices' | 'payments' | 'promotions';
const BusinessDashboardPage: React.FC = () => {
@@ -83,12 +89,27 @@ const BusinessDashboardPage: React.FC = () => {
discount_value: 0,
min_booking_amount: 0,
max_discount_amount: 0,
min_stay_days: 0,
max_stay_days: 0,
advance_booking_days: 0,
max_advance_booking_days: 0,
allowed_check_in_days: [] as number[],
allowed_check_out_days: [] as number[],
allowed_room_type_ids: [] as number[],
excluded_room_type_ids: [] as number[],
min_guests: 0,
max_guests: 0,
first_time_customer_only: false,
repeat_customer_only: false,
blackout_dates: [] as string[],
start_date: '',
end_date: '',
usage_limit: 0,
status: 'active' as 'active' | 'inactive' | 'expired',
});
const [roomTypes, setRoomTypes] = useState<RoomType[]>([]);
useEffect(() => {
if (activeTab === 'invoices') {
fetchInvoices();
@@ -99,6 +120,21 @@ const BusinessDashboardPage: React.FC = () => {
}
}, [activeTab, invoiceFilters, invoicesCurrentPage, paymentFilters, paymentsCurrentPage, promotionFilters, promotionsCurrentPage]);
useEffect(() => {
fetchRoomTypes();
}, []);
const fetchRoomTypes = async () => {
try {
const response = await getRoomTypes();
if (response.success && response.data.room_types) {
setRoomTypes(response.data.room_types);
}
} catch (error: any) {
console.error('Failed to fetch room types:', error);
}
};
useEffect(() => {
if (activeTab === 'invoices') {
setInvoicesCurrentPage(1);
@@ -280,11 +316,30 @@ const BusinessDashboardPage: React.FC = () => {
const handlePromotionSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Prepare data, converting empty arrays to undefined and 0 values to undefined for optional fields
const submitData: any = {
...promotionFormData,
min_stay_days: promotionFormData.min_stay_days || undefined,
max_stay_days: promotionFormData.max_stay_days || undefined,
advance_booking_days: promotionFormData.advance_booking_days || undefined,
max_advance_booking_days: promotionFormData.max_advance_booking_days || undefined,
min_guests: promotionFormData.min_guests || undefined,
max_guests: promotionFormData.max_guests || undefined,
allowed_check_in_days: promotionFormData.allowed_check_in_days?.length ? promotionFormData.allowed_check_in_days : undefined,
allowed_check_out_days: promotionFormData.allowed_check_out_days?.length ? promotionFormData.allowed_check_out_days : undefined,
allowed_room_type_ids: promotionFormData.allowed_room_type_ids?.length ? promotionFormData.allowed_room_type_ids : undefined,
excluded_room_type_ids: promotionFormData.excluded_room_type_ids?.length ? promotionFormData.excluded_room_type_ids : undefined,
blackout_dates: promotionFormData.blackout_dates?.length ? promotionFormData.blackout_dates : undefined,
min_booking_amount: promotionFormData.min_booking_amount || undefined,
max_discount_amount: promotionFormData.max_discount_amount || undefined,
usage_limit: promotionFormData.usage_limit || undefined,
};
if (editingPromotion) {
await promotionService.updatePromotion(editingPromotion.id, promotionFormData);
await promotionService.updatePromotion(editingPromotion.id, submitData);
toast.success('Promotion updated successfully');
} else {
await promotionService.createPromotion(promotionFormData);
await promotionService.createPromotion(submitData);
toast.success('Promotion added successfully');
}
setShowPromotionModal(false);
@@ -305,6 +360,19 @@ const BusinessDashboardPage: React.FC = () => {
discount_value: promotion.discount_value,
min_booking_amount: promotion.min_booking_amount || 0,
max_discount_amount: promotion.max_discount_amount || 0,
min_stay_days: promotion.min_stay_days || 0,
max_stay_days: promotion.max_stay_days || 0,
advance_booking_days: promotion.advance_booking_days || 0,
max_advance_booking_days: promotion.max_advance_booking_days || 0,
allowed_check_in_days: promotion.allowed_check_in_days || [],
allowed_check_out_days: promotion.allowed_check_out_days || [],
allowed_room_type_ids: promotion.allowed_room_type_ids || [],
excluded_room_type_ids: promotion.excluded_room_type_ids || [],
min_guests: promotion.min_guests || 0,
max_guests: promotion.max_guests || 0,
first_time_customer_only: promotion.first_time_customer_only || false,
repeat_customer_only: promotion.repeat_customer_only || false,
blackout_dates: promotion.blackout_dates || [],
start_date: promotion.start_date?.split('T')[0] || '',
end_date: promotion.end_date?.split('T')[0] || '',
usage_limit: promotion.usage_limit || 0,
@@ -335,6 +403,19 @@ const BusinessDashboardPage: React.FC = () => {
discount_value: 0,
min_booking_amount: 0,
max_discount_amount: 0,
min_stay_days: 0,
max_stay_days: 0,
advance_booking_days: 0,
max_advance_booking_days: 0,
allowed_check_in_days: [],
allowed_check_out_days: [],
allowed_room_type_ids: [],
excluded_room_type_ids: [],
min_guests: 0,
max_guests: 0,
first_time_customer_only: false,
repeat_customer_only: false,
blackout_dates: [],
start_date: '',
end_date: '',
usage_limit: 0,
@@ -1041,35 +1122,36 @@ const BusinessDashboardPage: React.FC = () => {
/>
</div>
)}
</div>
)}
</div>
{}
{showPromotionModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-4">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-gray-200">
{}
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4">
<div className="min-h-full flex items-center justify-center py-4">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-5xl w-full my-4 flex flex-col border border-gray-200" style={{ maxHeight: 'calc(100vh - 2rem)' }}>
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 md:px-8 py-4 sm:py-5 md:py-6 border-b border-slate-700 flex-shrink-0">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold text-purple-100 mb-1">
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-purple-100 mb-1">
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
</h2>
<p className="text-purple-200/80 text-sm font-light">
<p className="text-purple-200/80 text-xs sm:text-sm font-light">
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
</p>
</div>
<button
onClick={() => setShowPromotionModal(false)}
className="w-10 h-10 flex items-center justify-center rounded-xl text-purple-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-purple-400"
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-purple-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-purple-400"
>
<X className="w-6 h-6" />
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
{}
<div className="p-8 overflow-y-auto max-h-[calc(100vh-12rem)] custom-scrollbar">
<form onSubmit={handlePromotionSubmit} className="space-y-6">
<div className="flex-1 overflow-y-auto min-h-0">
<form onSubmit={handlePromotionSubmit} className="p-4 sm:p-6 md:p-8 space-y-4 sm:space-y-5 md:space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
@@ -1169,6 +1251,258 @@ const BusinessDashboardPage: React.FC = () => {
</div>
</div>
{/* Enterprise Booking Conditions Section */}
<div className="border-t-2 border-purple-200 pt-6 mt-6">
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
<div className="h-1 w-8 bg-gradient-to-r from-purple-400 to-purple-600 rounded-full"></div>
Enterprise Booking Conditions
</h3>
<p className="text-sm text-gray-600 mb-6">Configure advanced conditions for when this promotion applies</p>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Minimum Stay (nights)
</label>
<input
type="number"
value={promotionFormData.min_stay_days || ''}
onChange={(e) => setPromotionFormData({ ...promotionFormData, min_stay_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
min="0"
placeholder="0 = no minimum"
/>
<p className="text-xs text-gray-500 mt-1">Minimum number of nights required for booking</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Advance Booking (days)
</label>
<input
type="number"
value={promotionFormData.advance_booking_days || ''}
onChange={(e) => setPromotionFormData({ ...promotionFormData, advance_booking_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
min="0"
placeholder="0 = no requirement"
/>
<p className="text-xs text-gray-500 mt-1">Minimum days in advance the booking must be made</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Maximum Stay (nights)
</label>
<input
type="number"
value={promotionFormData.max_stay_days || ''}
onChange={(e) => setPromotionFormData({ ...promotionFormData, max_stay_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
min="0"
placeholder="0 = no maximum"
/>
<p className="text-xs text-gray-500 mt-1">Maximum number of nights allowed for booking</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Max Advance Booking (days)
</label>
<input
type="number"
value={promotionFormData.max_advance_booking_days || ''}
onChange={(e) => setPromotionFormData({ ...promotionFormData, max_advance_booking_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
min="0"
placeholder="0 = no maximum"
/>
<p className="text-xs text-gray-500 mt-1">Maximum days in advance the booking can be made</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Allowed Check-in Days
</label>
<div className="grid grid-cols-7 gap-2">
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, index) => (
<label key={day} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={promotionFormData.allowed_check_in_days?.includes(index) || false}
onChange={(e) => {
const current = promotionFormData.allowed_check_in_days || [];
if (e.target.checked) {
setPromotionFormData({ ...promotionFormData, allowed_check_in_days: [...current, index] });
} else {
setPromotionFormData({ ...promotionFormData, allowed_check_in_days: current.filter(d => d !== index) });
}
}}
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span className="text-xs text-gray-700">{day}</span>
</label>
))}
</div>
<p className="text-xs text-gray-500 mt-1">Leave empty to allow all days</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Allowed Check-out Days
</label>
<div className="grid grid-cols-7 gap-2">
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, index) => (
<label key={day} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={promotionFormData.allowed_check_out_days?.includes(index) || false}
onChange={(e) => {
const current = promotionFormData.allowed_check_out_days || [];
if (e.target.checked) {
setPromotionFormData({ ...promotionFormData, allowed_check_out_days: [...current, index] });
} else {
setPromotionFormData({ ...promotionFormData, allowed_check_out_days: current.filter(d => d !== index) });
}
}}
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span className="text-xs text-gray-700">{day}</span>
</label>
))}
</div>
<p className="text-xs text-gray-500 mt-1">Leave empty to allow all days</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Allowed Room Types
</label>
<select
multiple
value={promotionFormData.allowed_room_type_ids?.map(String) || []}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions, option => parseInt(option.value));
setPromotionFormData({ ...promotionFormData, allowed_room_type_ids: selected });
}}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
size={4}
>
{roomTypes.map(rt => (
<option key={rt.id} value={rt.id}>{rt.name}</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">Hold Ctrl/Cmd to select multiple. Leave empty to allow all room types.</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Excluded Room Types
</label>
<select
multiple
value={promotionFormData.excluded_room_type_ids?.map(String) || []}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions, option => parseInt(option.value));
setPromotionFormData({ ...promotionFormData, excluded_room_type_ids: selected });
}}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
size={4}
>
{roomTypes.map(rt => (
<option key={rt.id} value={rt.id}>{rt.name}</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">Hold Ctrl/Cmd to select multiple. These room types cannot use this promotion.</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Minimum Guests
</label>
<input
type="number"
value={promotionFormData.min_guests || ''}
onChange={(e) => setPromotionFormData({ ...promotionFormData, min_guests: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
min="1"
placeholder="0 = no minimum"
/>
<p className="text-xs text-gray-500 mt-1">Minimum number of guests required</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Maximum Guests
</label>
<input
type="number"
value={promotionFormData.max_guests || ''}
onChange={(e) => setPromotionFormData({ ...promotionFormData, max_guests: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
min="1"
placeholder="0 = no maximum"
/>
<p className="text-xs text-gray-500 mt-1">Maximum number of guests allowed</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={promotionFormData.first_time_customer_only || false}
onChange={(e) => setPromotionFormData({ ...promotionFormData, first_time_customer_only: e.target.checked, repeat_customer_only: e.target.checked ? false : promotionFormData.repeat_customer_only })}
className="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span className="text-sm font-semibold text-gray-700">First-Time Customer Only</span>
</label>
<p className="text-xs text-gray-500 mt-1 ml-8">Only available to first-time customers</p>
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={promotionFormData.repeat_customer_only || false}
onChange={(e) => setPromotionFormData({ ...promotionFormData, repeat_customer_only: e.target.checked, first_time_customer_only: e.target.checked ? false : promotionFormData.first_time_customer_only })}
className="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span className="text-sm font-semibold text-gray-700">Repeat Customer Only</span>
</label>
<p className="text-xs text-gray-500 mt-1 ml-8">Only available to returning customers</p>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Blackout Dates
</label>
<textarea
value={promotionFormData.blackout_dates?.join('\n') || ''}
onChange={(e) => {
const dates = e.target.value.split('\n').filter(d => d.trim()).map(d => d.trim());
setPromotionFormData({ ...promotionFormData, blackout_dates: dates });
}}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
rows={3}
placeholder="Enter dates (one per line) in YYYY-MM-DD format&#10;Example:&#10;2024-12-25&#10;2024-12-31&#10;2025-01-01"
/>
<p className="text-xs text-gray-500 mt-1">Dates when promotion doesn't apply. One date per line (YYYY-MM-DD format).</p>
</div>
{/* Dates & Status Section */}
<div className="border-t-2 border-purple-200 pt-6 mt-6">
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
<div className="h-1 w-8 bg-gradient-to-r from-purple-400 to-purple-600 rounded-full"></div>
Promotion Period & Status
</h3>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
@@ -1224,7 +1558,7 @@ const BusinessDashboardPage: React.FC = () => {
</div>
</div>
<div className="flex justify-end gap-3 pt-6 border-t border-gray-200">
<div className="sticky bottom-0 bg-white border-t border-gray-200 mt-8 -mx-4 sm:-mx-6 md:-mx-8 px-4 sm:px-6 md:px-8 py-4 flex justify-end gap-3">
<button
type="button"
onClick={() => setShowPromotionModal(false)}
@@ -1246,9 +1580,6 @@ const BusinessDashboardPage: React.FC = () => {
</div>
)}
</div>
)}
</div>
</div>
);
};

View File

@@ -2895,167 +2895,6 @@ const PageContentDashboard: React.FC = () => {
</div>
</div>
{/* Trust Badges Section */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-extrabold text-gray-900">Trust Badges Section</h2>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={homeData.trust_badges_enabled ?? false}
onChange={(e) => setHomeData({ ...homeData, trust_badges_enabled: e.target.checked })}
className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
/>
<span className="text-sm font-semibold text-gray-700">Enable Trust Badges</span>
</label>
</div>
{homeData.trust_badges_enabled && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Title</label>
<input
type="text"
value={homeData.trust_badges_section_title || ''}
onChange={(e) => setHomeData({ ...homeData, trust_badges_section_title: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Trusted & Certified"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Subtitle</label>
<input
type="text"
value={homeData.trust_badges_section_subtitle || ''}
onChange={(e) => setHomeData({ ...homeData, trust_badges_section_subtitle: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Recognized for excellence"
/>
</div>
</div>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Trust Badges</h3>
<button
type="button"
onClick={() => {
const current = Array.isArray(homeData.trust_badges) ? homeData.trust_badges : [];
setHomeData({
...homeData,
trust_badges: [...current, { name: '', logo: '', description: '', link: '' }]
});
}}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-lg font-semibold hover:from-green-600 hover:to-green-700 transition-all"
>
<Plus className="w-4 h-4" />
Add Badge
</button>
</div>
<div className="space-y-4">
{Array.isArray(homeData.trust_badges) && homeData.trust_badges.map((badge, index) => (
<div key={`badge-${index}`} className="p-6 border-2 border-gray-200 rounded-xl space-y-4">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-gray-900">Badge {index + 1}</h4>
<button
type="button"
onClick={() => {
const current = Array.isArray(homeData.trust_badges) ? homeData.trust_badges : [];
const updated = current.filter((_, i) => i !== index);
setHomeData({ ...homeData, trust_badges: updated });
}}
className="text-red-600 hover:text-red-700 p-1"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Name</label>
<input
type="text"
value={badge?.name || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], name: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Certification Name"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Link (Optional)</label>
<input
type="url"
value={badge?.link || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], link: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="https://example.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Description (Optional)</label>
<textarea
value={badge?.description || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], description: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
rows={2}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Brief description"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Logo</label>
<div className="flex gap-2">
<input
type="url"
value={badge?.logo || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], logo: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
className="flex-1 px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Logo URL or upload"
/>
<label className="px-5 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-bold hover:from-green-700 hover:to-green-800 transition-all cursor-pointer flex items-center gap-2">
<Upload className="w-4 h-4" />
Upload
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
await handlePageContentImageUpload(file, (imageUrl) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], logo: imageUrl };
setHomeData({ ...homeData, trust_badges: current });
});
}
}}
className="hidden"
/>
</label>
</div>
</div>
</div>
))}
{(!homeData.trust_badges || homeData.trust_badges.length === 0) && (
<p className="text-gray-500 text-center py-8">No trust badges added yet. Click "Add Badge" to get started.</p>
)}
</div>
</div>
)}
</div>
{/* Promotions Section */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex items-center justify-between mb-6">

View File

@@ -1,12 +1,18 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X, Tag } from 'lucide-react';
import promotionService, { Promotion } from '../../features/loyalty/services/promotionService';
import { getRoomTypes } from '../../features/rooms/services/roomService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { useCurrency } from '../../features/payments/contexts/CurrencyContext';
interface RoomType {
id: number;
name: string;
}
const PromotionManagementPage: React.FC = () => {
const { currency } = useCurrency();
const { formatCurrency } = useFormatCurrency();
@@ -32,12 +38,28 @@ const PromotionManagementPage: React.FC = () => {
discount_value: 0,
min_booking_amount: 0,
max_discount_amount: 0,
min_stay_days: 0,
max_stay_days: 0,
advance_booking_days: 0,
max_advance_booking_days: 0,
allowed_check_in_days: [] as number[],
allowed_check_out_days: [] as number[],
allowed_room_type_ids: [] as number[],
excluded_room_type_ids: [] as number[],
min_guests: 0,
max_guests: 0,
first_time_customer_only: false,
repeat_customer_only: false,
blackout_dates: [] as string[],
start_date: '',
end_date: '',
usage_limit: 0,
status: 'active' as 'active' | 'inactive' | 'expired',
});
const [roomTypes, setRoomTypes] = useState<RoomType[]>([]);
const [loadingRoomTypes, setLoadingRoomTypes] = useState(false);
useEffect(() => {
setCurrentPage(1);
}, [filters]);
@@ -46,6 +68,24 @@ const PromotionManagementPage: React.FC = () => {
fetchPromotions();
}, [filters, currentPage]);
useEffect(() => {
fetchRoomTypes();
}, []);
const fetchRoomTypes = async () => {
try {
setLoadingRoomTypes(true);
const response = await getRoomTypes();
if (response.success && response.data.room_types) {
setRoomTypes(response.data.room_types);
}
} catch (error: any) {
console.error('Failed to fetch room types:', error);
} finally {
setLoadingRoomTypes(false);
}
};
const fetchPromotions = async () => {
try {
setLoading(true);
@@ -69,11 +109,30 @@ const PromotionManagementPage: React.FC = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Prepare data, converting empty arrays to undefined and 0 values to undefined for optional fields
const submitData: any = {
...formData,
min_stay_days: formData.min_stay_days || undefined,
max_stay_days: formData.max_stay_days || undefined,
advance_booking_days: formData.advance_booking_days || undefined,
max_advance_booking_days: formData.max_advance_booking_days || undefined,
min_guests: formData.min_guests || undefined,
max_guests: formData.max_guests || undefined,
allowed_check_in_days: formData.allowed_check_in_days?.length ? formData.allowed_check_in_days : undefined,
allowed_check_out_days: formData.allowed_check_out_days?.length ? formData.allowed_check_out_days : undefined,
allowed_room_type_ids: formData.allowed_room_type_ids?.length ? formData.allowed_room_type_ids : undefined,
excluded_room_type_ids: formData.excluded_room_type_ids?.length ? formData.excluded_room_type_ids : undefined,
blackout_dates: formData.blackout_dates?.length ? formData.blackout_dates : undefined,
min_booking_amount: formData.min_booking_amount || undefined,
max_discount_amount: formData.max_discount_amount || undefined,
usage_limit: formData.usage_limit || undefined,
};
if (editingPromotion) {
await promotionService.updatePromotion(editingPromotion.id, formData);
await promotionService.updatePromotion(editingPromotion.id, submitData);
toast.success('Promotion updated successfully');
} else {
await promotionService.createPromotion(formData);
await promotionService.createPromotion(submitData);
toast.success('Promotion added successfully');
}
setShowModal(false);
@@ -94,6 +153,19 @@ const PromotionManagementPage: React.FC = () => {
discount_value: promotion.discount_value,
min_booking_amount: promotion.min_booking_amount || 0,
max_discount_amount: promotion.max_discount_amount || 0,
min_stay_days: promotion.min_stay_days || 0,
max_stay_days: promotion.max_stay_days || 0,
advance_booking_days: promotion.advance_booking_days || 0,
max_advance_booking_days: promotion.max_advance_booking_days || 0,
allowed_check_in_days: promotion.allowed_check_in_days || [],
allowed_check_out_days: promotion.allowed_check_out_days || [],
allowed_room_type_ids: promotion.allowed_room_type_ids || [],
excluded_room_type_ids: promotion.excluded_room_type_ids || [],
min_guests: promotion.min_guests || 0,
max_guests: promotion.max_guests || 0,
first_time_customer_only: promotion.first_time_customer_only || false,
repeat_customer_only: promotion.repeat_customer_only || false,
blackout_dates: promotion.blackout_dates || [],
start_date: promotion.start_date?.split('T')[0] || '',
end_date: promotion.end_date?.split('T')[0] || '',
usage_limit: promotion.usage_limit || 0,
@@ -124,6 +196,19 @@ const PromotionManagementPage: React.FC = () => {
discount_value: 0,
min_booking_amount: 0,
max_discount_amount: 0,
min_stay_days: 0,
max_stay_days: 0,
advance_booking_days: 0,
max_advance_booking_days: 0,
allowed_check_in_days: [],
allowed_check_out_days: [],
allowed_room_type_ids: [],
excluded_room_type_ids: [],
min_guests: 0,
max_guests: 0,
first_time_customer_only: false,
repeat_customer_only: false,
blackout_dates: [],
start_date: '',
end_date: '',
usage_limit: 0,
@@ -315,32 +400,30 @@ const PromotionManagementPage: React.FC = () => {
{}
{showModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-4 animate-fade-in">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200 animate-scale-in">
{}
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4 animate-fade-in" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
<div className="min-h-full flex items-center justify-center py-4 sm:py-8">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-5xl w-full my-8 flex flex-col border border-slate-200 animate-scale-in" style={{ maxHeight: 'calc(100vh - 2rem)' }} onClick={(e) => e.stopPropagation()}>
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 md:px-8 py-4 sm:py-5 md:py-6 border-b border-slate-700 flex-shrink-0">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold text-amber-100 mb-1">
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-amber-100 mb-1">
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
</h2>
<p className="text-amber-200/80 text-sm font-light">
<p className="text-amber-200/80 text-xs sm:text-sm font-light">
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
</p>
</div>
<button
onClick={() => setShowModal(false)}
className="w-10 h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
<X className="w-6 h-6" />
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
{}
<div className="p-8 overflow-y-auto max-h-[calc(100vh-12rem)] custom-scrollbar">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex-1 overflow-y-auto" style={{ minHeight: 0, maxHeight: 'calc(100vh - 180px)' }}>
<form onSubmit={handleSubmit} className="p-4 sm:p-6 md:p-8 space-y-4 sm:space-y-5 md:space-y-6 pb-8">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
@@ -440,6 +523,258 @@ const PromotionManagementPage: React.FC = () => {
</div>
</div>
{/* Enterprise Booking Conditions Section */}
<div className="border-t-2 border-amber-200 pt-6 mt-6">
<h3 className="text-lg font-bold text-slate-900 mb-4 flex items-center gap-2">
<div className="h-1 w-8 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
Enterprise Booking Conditions
</h3>
<p className="text-sm text-slate-600 mb-6">Configure advanced conditions for when this promotion applies</p>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Minimum Stay (nights)
</label>
<input
type="number"
value={formData.min_stay_days || ''}
onChange={(e) => setFormData({ ...formData, min_stay_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="0"
placeholder="0 = no minimum"
/>
<p className="text-xs text-slate-500 mt-1">Minimum number of nights required for booking</p>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Advance Booking (days)
</label>
<input
type="number"
value={formData.advance_booking_days || ''}
onChange={(e) => setFormData({ ...formData, advance_booking_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="0"
placeholder="0 = no requirement"
/>
<p className="text-xs text-slate-500 mt-1">Minimum days in advance the booking must be made</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Maximum Stay (nights)
</label>
<input
type="number"
value={formData.max_stay_days || ''}
onChange={(e) => setFormData({ ...formData, max_stay_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="0"
placeholder="0 = no maximum"
/>
<p className="text-xs text-slate-500 mt-1">Maximum number of nights allowed for booking</p>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Max Advance Booking (days)
</label>
<input
type="number"
value={formData.max_advance_booking_days || ''}
onChange={(e) => setFormData({ ...formData, max_advance_booking_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="0"
placeholder="0 = no maximum"
/>
<p className="text-xs text-slate-500 mt-1">Maximum days in advance the booking can be made</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Allowed Check-in Days
</label>
<div className="grid grid-cols-7 gap-2">
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, index) => (
<label key={day} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.allowed_check_in_days?.includes(index) || false}
onChange={(e) => {
const current = formData.allowed_check_in_days || [];
if (e.target.checked) {
setFormData({ ...formData, allowed_check_in_days: [...current, index] });
} else {
setFormData({ ...formData, allowed_check_in_days: current.filter(d => d !== index) });
}
}}
className="w-4 h-4 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
/>
<span className="text-xs text-slate-700">{day}</span>
</label>
))}
</div>
<p className="text-xs text-slate-500 mt-1">Leave empty to allow all days</p>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Allowed Check-out Days
</label>
<div className="grid grid-cols-7 gap-2">
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, index) => (
<label key={day} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.allowed_check_out_days?.includes(index) || false}
onChange={(e) => {
const current = formData.allowed_check_out_days || [];
if (e.target.checked) {
setFormData({ ...formData, allowed_check_out_days: [...current, index] });
} else {
setFormData({ ...formData, allowed_check_out_days: current.filter(d => d !== index) });
}
}}
className="w-4 h-4 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
/>
<span className="text-xs text-slate-700">{day}</span>
</label>
))}
</div>
<p className="text-xs text-slate-500 mt-1">Leave empty to allow all days</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Allowed Room Types
</label>
<select
multiple
value={formData.allowed_room_type_ids?.map(String) || []}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions, option => parseInt(option.value));
setFormData({ ...formData, allowed_room_type_ids: selected });
}}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
size={4}
>
{roomTypes.map(rt => (
<option key={rt.id} value={rt.id}>{rt.name}</option>
))}
</select>
<p className="text-xs text-slate-500 mt-1">Hold Ctrl/Cmd to select multiple. Leave empty to allow all room types.</p>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Excluded Room Types
</label>
<select
multiple
value={formData.excluded_room_type_ids?.map(String) || []}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions, option => parseInt(option.value));
setFormData({ ...formData, excluded_room_type_ids: selected });
}}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
size={4}
>
{roomTypes.map(rt => (
<option key={rt.id} value={rt.id}>{rt.name}</option>
))}
</select>
<p className="text-xs text-slate-500 mt-1">Hold Ctrl/Cmd to select multiple. These room types cannot use this promotion.</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Minimum Guests
</label>
<input
type="number"
value={formData.min_guests || ''}
onChange={(e) => setFormData({ ...formData, min_guests: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="1"
placeholder="0 = no minimum"
/>
<p className="text-xs text-slate-500 mt-1">Minimum number of guests required</p>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Maximum Guests
</label>
<input
type="number"
value={formData.max_guests || ''}
onChange={(e) => setFormData({ ...formData, max_guests: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="1"
placeholder="0 = no maximum"
/>
<p className="text-xs text-slate-500 mt-1">Maximum number of guests allowed</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.first_time_customer_only || false}
onChange={(e) => setFormData({ ...formData, first_time_customer_only: e.target.checked, repeat_customer_only: e.target.checked ? false : formData.repeat_customer_only })}
className="w-5 h-5 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
/>
<span className="text-sm font-semibold text-slate-700">First-Time Customer Only</span>
</label>
<p className="text-xs text-slate-500 mt-1 ml-8">Only available to first-time customers</p>
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.repeat_customer_only || false}
onChange={(e) => setFormData({ ...formData, repeat_customer_only: e.target.checked, first_time_customer_only: e.target.checked ? false : formData.first_time_customer_only })}
className="w-5 h-5 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
/>
<span className="text-sm font-semibold text-slate-700">Repeat Customer Only</span>
</label>
<p className="text-xs text-slate-500 mt-1 ml-8">Only available to returning customers</p>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Blackout Dates
</label>
<textarea
value={formData.blackout_dates?.join('\n') || ''}
onChange={(e) => {
const dates = e.target.value.split('\n').filter(d => d.trim()).map(d => d.trim());
setFormData({ ...formData, blackout_dates: dates });
}}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
rows={3}
placeholder="Enter dates (one per line) in YYYY-MM-DD format&#10;Example:&#10;2024-12-25&#10;2024-12-31&#10;2025-01-01"
/>
<p className="text-xs text-slate-500 mt-1">Dates when promotion doesn't apply. One date per line (YYYY-MM-DD format).</p>
</div>
{/* Dates & Status Section */}
<div className="border-t-2 border-amber-200 pt-6 mt-6">
<h3 className="text-lg font-bold text-slate-900 mb-4 flex items-center gap-2">
<div className="h-1 w-8 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
Promotion Period & Status
</h3>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
@@ -495,7 +830,7 @@ const PromotionManagementPage: React.FC = () => {
</div>
</div>
<div className="flex justify-end gap-3 pt-6 border-t border-slate-200">
<div className="sticky bottom-0 bg-white border-t border-slate-200 mt-8 -mx-4 sm:-mx-6 md:-mx-8 px-4 sm:px-6 md:px-8 py-4 flex justify-end gap-3">
<button
type="button"
onClick={() => setShowModal(false)}
@@ -514,7 +849,6 @@ const PromotionManagementPage: React.FC = () => {
</div>
</div>
</div>
</div>
)}
</div>
);

View File

@@ -6,8 +6,9 @@ import RoomFilter from '../../features/rooms/components/RoomFilter';
import RoomCard from '../../features/rooms/components/RoomCard';
import RoomCardSkeleton from '../../features/rooms/components/RoomCardSkeleton';
import Pagination from '../../shared/components/Pagination';
import { ArrowLeft, Hotel, Filter, ChevronDown, ChevronUp } from 'lucide-react';
import { ArrowLeft, Hotel, Filter, ChevronDown, ChevronUp, Tag, X, CheckCircle } from 'lucide-react';
import { logger } from '../../shared/utils/logger';
import { toast } from 'react-toastify';
const RoomListPage: React.FC = () => {
const [searchParams] = useSearchParams();
@@ -23,6 +24,45 @@ const RoomListPage: React.FC = () => {
totalPages: 1,
});
const abortControllerRef = useRef<AbortController | null>(null);
const [activePromotion, setActivePromotion] = useState<any>(null);
const [showPromotionBanner, setShowPromotionBanner] = useState(false);
// Check for active promotion from URL or sessionStorage
useEffect(() => {
const promoCode = searchParams.get('promo');
// Check sessionStorage first (from homepage promotion click)
try {
const storedPromotion = sessionStorage.getItem('activePromotion');
if (storedPromotion) {
const promo = JSON.parse(storedPromotion);
setActivePromotion(promo);
setShowPromotionBanner(true);
return;
}
} catch (error) {
console.warn('Failed to read promotion from sessionStorage:', error);
}
// Check URL params (fallback)
if (promoCode) {
setActivePromotion({
code: promoCode,
title: 'Special Offer',
discount: searchParams.get('discount') || '',
});
setShowPromotionBanner(true);
}
}, [searchParams]);
const handleDismissPromotion = () => {
setShowPromotionBanner(false);
try {
sessionStorage.removeItem('activePromotion');
} catch (error) {
console.warn('Failed to remove promotion from sessionStorage:', error);
}
};
useEffect(() => {
@@ -108,6 +148,44 @@ const RoomListPage: React.FC = () => {
return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
{}
{/* Promotion Banner */}
{showPromotionBanner && activePromotion && (
<div className="w-full bg-gradient-to-r from-[#d4af37]/20 via-[#f5d76e]/15 to-[#d4af37]/20 border-b border-[#d4af37]/30">
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-4">
<div className="flex items-center justify-between gap-4 bg-gradient-to-r from-[#1a1a1a] to-[#0f0f0f] border border-[#d4af37]/40 rounded-lg p-4 backdrop-blur-xl shadow-lg">
<div className="flex items-center gap-3 flex-1">
<div className="p-2 bg-[#d4af37]/20 rounded-lg border border-[#d4af37]/40">
<Tag className="w-5 h-5 text-[#d4af37]" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<CheckCircle className="w-4 h-4 text-green-400" />
<span className="text-sm font-semibold text-[#d4af37]">
Active Promotion: {activePromotion.code || activePromotion.title}
</span>
</div>
{activePromotion.discount && (
<p className="text-xs text-gray-300">
{activePromotion.discount} - {activePromotion.description || 'Valid on bookings'}
</p>
)}
<p className="text-xs text-gray-400 mt-1">
The promotion code will be automatically applied when you book a room
</p>
</div>
</div>
<button
onClick={handleDismissPromotion}
className="p-2 hover:bg-[#d4af37]/10 rounded-lg transition-colors"
aria-label="Dismiss promotion"
>
<X className="w-5 h-5 text-gray-400 hover:text-white" />
</button>
</div>
</div>
</div>
)}
<div className="w-full bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] border-b border-[#d4af37]/10 pt-6 sm:pt-7 md:pt-8">
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-6 sm:py-7 md:py-8 lg:py-10">
{}

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { AlertTriangle, Shield, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/useAuthStore';
interface MfaRequiredBannerProps {
/**
* Role-specific security page path
* e.g., '/accountant/security' or '/admin/security'
*/
securityPagePath: string;
/**
* Optional custom message
*/
message?: string;
/**
* Optional callback when banner is dismissed
*/
onDismiss?: () => void;
}
/**
* MFA Required Banner Component
*
* Displays a warning banner when MFA is required but not enabled.
* Used on dashboards for roles that require MFA for financial access.
*/
const MfaRequiredBanner: React.FC<MfaRequiredBannerProps> = ({
securityPagePath,
message,
onDismiss,
}) => {
const navigate = useNavigate();
const { userInfo } = useAuthStore();
const [dismissed, setDismissed] = React.useState(false);
// Check if MFA is required for this role
const isFinancialRole = userInfo?.role === 'accountant' || userInfo?.role === 'admin';
if (!isFinancialRole || dismissed) {
return null;
}
const handleSetupMfa = () => {
navigate(`${securityPagePath}?setup_mfa=true`);
};
const handleDismiss = () => {
setDismissed(true);
if (onDismiss) {
onDismiss();
}
};
return (
<div className="bg-gradient-to-r from-amber-50 via-yellow-50 to-amber-50 border-l-4 border-amber-500 rounded-lg shadow-md mb-6 p-4 sm:p-5">
<div className="flex items-start gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-amber-100 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 sm:w-6 sm:h-6 text-amber-600" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h3 className="text-base sm:text-lg font-semibold text-amber-900 mb-1 flex items-center gap-2">
<Shield className="w-4 h-4 sm:w-5 sm:h-5" />
Multi-Factor Authentication Required
</h3>
<p className="text-sm sm:text-base text-amber-800 mb-3">
{message ||
'Multi-factor authentication is required for financial access. Please enable MFA to continue using financial features.'}
</p>
<button
onClick={handleSetupMfa}
className="inline-flex items-center gap-2 px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors duration-200 shadow-sm hover:shadow-md"
>
<Shield className="w-4 h-4" />
Enable MFA Now
<ArrowRight className="w-4 h-4" />
</button>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-amber-600 hover:text-amber-800 transition-colors p-1"
aria-label="Dismiss banner"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
);
};
export default MfaRequiredBanner;

View File

@@ -0,0 +1,52 @@
import { useState, useEffect } from 'react';
import accountantSecurityService, { MFAStatus } from '../../features/security/services/accountantSecurityService';
import useAuthStore from '../../store/useAuthStore';
/**
* Hook to fetch and check MFA status for accountant/admin users
*/
export const useMfaStatus = () => {
const { userInfo } = useAuthStore();
const [mfaStatus, setMfaStatus] = useState<MFAStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isFinancialRole = userInfo?.role === 'accountant' || userInfo?.role === 'admin';
useEffect(() => {
if (!isFinancialRole) {
setLoading(false);
return;
}
const fetchMfaStatus = async () => {
try {
setLoading(true);
setError(null);
const response = await accountantSecurityService.getMFAStatus();
setMfaStatus(response.data);
} catch (err: any) {
// Silently fail - MFA status check shouldn't block dashboard
setError(err.response?.data?.detail || 'Failed to load MFA status');
setMfaStatus(null);
} finally {
setLoading(false);
}
};
fetchMfaStatus();
}, [isFinancialRole, userInfo?.role]);
const requiresMfaButNotEnabled = mfaStatus
? (mfaStatus.requires_mfa || mfaStatus.is_enforced) && !mfaStatus.mfa_enabled
: false;
return {
mfaStatus,
loading,
error,
requiresMfaButNotEnabled,
isFinancialRole,
};
};

View File

@@ -0,0 +1,235 @@
import { useMemo } from 'react';
import useAuthStore from '../../store/useAuthStore';
/**
* Permission constants matching backend ROLE_PERMISSIONS
* These are used for frontend UI/UX (showing/hiding elements)
* Backend enforces actual permissions for API access
*/
const ROLE_PERMISSIONS: Record<string, Set<string>> = {
admin: new Set([
'financial.view_reports',
'financial.manage_invoices',
'financial.manage_payments',
'financial.manage_settings',
'financial.high_risk_approve',
'users.manage',
'housekeeping.view_tasks',
'housekeeping.manage_tasks',
'housekeeping.view_rooms',
'bookings.manage',
'rooms.manage',
]),
accountant: new Set([
'financial.view_reports',
'financial.manage_invoices',
'financial.manage_payments',
'financial.manage_settings',
'financial.high_risk_approve',
]),
accountant_readonly: new Set([
'financial.view_reports',
'financial.view_invoices',
'financial.view_payments',
'financial.view_audit_trail',
]),
accountant_operator: new Set([
'financial.view_reports',
'financial.view_invoices',
'financial.view_payments',
'financial.manage_invoices',
'financial.manage_payments',
]),
accountant_approver: new Set([
'financial.view_reports',
'financial.view_invoices',
'financial.view_payments',
'financial.high_risk_approve',
]),
staff: new Set([
'financial.view_invoices',
'financial.manage_invoices',
'financial.view_payments',
'housekeeping.view_tasks',
'housekeeping.manage_tasks',
'housekeeping.view_rooms',
'bookings.manage',
'rooms.manage',
]),
housekeeping: new Set([
'housekeeping.view_tasks',
'housekeeping.manage_tasks',
'housekeeping.create_tasks',
'housekeeping.view_rooms',
'housekeeping.upload_photos',
'housekeeping.report_issues',
'housekeeping.view_inspections',
'housekeeping.manage_inspections',
]),
customer: new Set([
'bookings.view_own',
'bookings.create',
'rooms.view',
'profile.manage',
]),
};
/**
* Get permissions for a role
*/
const getRolePermissions = (roleName: string | undefined | null): Set<string> => {
if (!roleName) return new Set();
return ROLE_PERMISSIONS[roleName] || new Set();
};
/**
* Check if user has a specific permission
*/
const hasPermission = (roleName: string | undefined | null, permission: string): boolean => {
const permissions = getRolePermissions(roleName);
return permissions.has(permission);
};
/**
* Check if user has any of the given permissions
*/
const hasAnyPermission = (
roleName: string | undefined | null,
permissions: string[]
): boolean => {
if (!permissions.length) return true;
const rolePerms = getRolePermissions(roleName);
return permissions.some(perm => rolePerms.has(perm));
};
/**
* Check if user has all of the given permissions
*/
const hasAllPermissions = (
roleName: string | undefined | null,
permissions: string[]
): boolean => {
if (!permissions.length) return true;
const rolePerms = getRolePermissions(roleName);
return permissions.every(perm => rolePerms.has(perm));
};
/**
* Main hook to check permissions
*/
export const usePermissions = () => {
const { userInfo } = useAuthStore();
const roleName = userInfo?.role;
const permissions = useMemo(() => {
return getRolePermissions(roleName);
}, [roleName]);
return {
roleName,
permissions,
hasPermission: (permission: string) => hasPermission(roleName, permission),
hasAnyPermission: (permissions: string[]) => hasAnyPermission(roleName, permissions),
hasAllPermissions: (permissions: string[]) => hasAllPermissions(roleName, permissions),
};
};
/**
* Hook to check if user can create invoices
*/
export const useCanCreateInvoices = (): boolean => {
const { roleName, hasPermission } = usePermissions();
return useMemo(() => {
if (!roleName) return false;
// Admin, staff, accountant, accountant_operator can create invoices
return ['admin', 'staff', 'accountant', 'accountant_operator'].includes(roleName);
}, [roleName]);
};
/**
* Hook to check if user can manage users
*/
export const useCanManageUsers = (): boolean => {
const { roleName } = usePermissions();
return roleName === 'admin';
};
/**
* Hook to check if user can manage housekeeping tasks
*/
export const useCanManageHousekeepingTasks = (): boolean => {
const { hasPermission } = usePermissions();
return hasPermission('housekeeping.manage_tasks');
};
/**
* Hook to check if user can view financial reports
*/
export const useCanViewFinancialReports = (): boolean => {
const { hasPermission } = usePermissions();
return hasPermission('financial.view_reports');
};
/**
* Hook to check if user can manage invoices
*/
export const useCanManageInvoices = (): boolean => {
const { hasPermission } = usePermissions();
return hasPermission('financial.manage_invoices');
};
/**
* Hook to check if user can manage payments
*/
export const useCanManagePayments = (): boolean => {
const { hasPermission } = usePermissions();
return hasPermission('financial.manage_payments');
};
/**
* Hook to check if user can access financial features
*/
export const useCanAccessFinancial = (): boolean => {
const { roleName, hasPermission } = usePermissions();
return useMemo(() => {
if (!roleName) return false;
// Any permission starting with 'financial.' indicates financial access
return ['admin', 'staff', 'accountant', 'accountant_readonly', 'accountant_operator', 'accountant_approver']
.includes(roleName) || hasPermission('financial.view_reports');
}, [roleName, hasPermission]);
};
/**
* Hook to check if user can manage bookings
*/
export const useCanManageBookings = (): boolean => {
const { roleName, hasPermission } = usePermissions();
return useMemo(() => {
if (!roleName) return false;
return ['admin', 'staff'].includes(roleName) || hasPermission('bookings.manage');
}, [roleName, hasPermission]);
};
/**
* Hook to check if user can manage rooms
*/
export const useCanManageRooms = (): boolean => {
const { roleName, hasPermission } = usePermissions();
return useMemo(() => {
if (!roleName) return false;
return ['admin', 'staff'].includes(roleName) || hasPermission('rooms.manage');
}, [roleName, hasPermission]);
};
/**
* Hook to check if user can view all housekeeping tasks (not just assigned)
*/
export const useCanViewAllHousekeepingTasks = (): boolean => {
const { roleName } = usePermissions();
return roleName === 'admin' || roleName === 'staff';
};

View File

@@ -1,4 +1,5 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { toast } from 'react-toastify';
import useAuthStore from '../../store/useAuthStore';
import { logSecurityWarning, logDebug, logWarn } from '../utils/errorReporter';
@@ -257,7 +258,61 @@ apiClient.interceptors.response.use(
let errorMessage = 'You do not have permission to access this resource.';
let shouldRetry = false;
if (rawMessage.includes('CSRF token') || rawMessage.toLowerCase().includes('csrf')) {
// Check for MFA requirement error
const isMfaRequired =
errorData?.error === 'mfa_required' ||
errorData?.requires_mfa_setup === true ||
(typeof errorData === 'object' && errorData?.detail &&
(typeof errorData.detail === 'string' && errorData.detail.includes('Multi-factor authentication is required')) ||
(typeof errorData.detail === 'object' && errorData.detail?.error === 'mfa_required'));
if (isMfaRequired) {
// Get user info to determine redirect path
try {
const userInfoStr = localStorage.getItem('userInfo');
if (userInfoStr) {
const userInfo = JSON.parse(userInfoStr);
const userRole = userInfo?.role;
// Redirect to appropriate security page based on role
if (userRole === 'accountant') {
errorMessage = 'Multi-factor authentication is required for financial access. Redirecting to setup...';
toast.error('Multi-factor authentication is required for financial access. Please enable MFA to continue.');
// Use setTimeout to allow error to be logged before redirect
setTimeout(() => {
window.location.href = '/accountant/security?setup_mfa=true&redirect=' + encodeURIComponent(window.location.pathname);
}, 1000);
return Promise.reject({
...error,
message: errorMessage,
requiresMfaSetup: true,
redirectPath: '/accountant/security?setup_mfa=true',
});
} else if (userRole === 'admin') {
errorMessage = 'Multi-factor authentication is required for financial access. Redirecting to setup...';
toast.error('Multi-factor authentication is required for financial access. Please enable MFA to continue.');
setTimeout(() => {
window.location.href = '/admin/security?setup_mfa=true&redirect=' + encodeURIComponent(window.location.pathname);
}, 1000);
return Promise.reject({
...error,
message: errorMessage,
requiresMfaSetup: true,
redirectPath: '/admin/security?setup_mfa=true',
});
}
}
} catch (e) {
logDebug('Error parsing userInfo for MFA redirect', { error: e instanceof Error ? e.message : String(e) });
}
// Fallback message if we can't determine role
errorMessage = 'Multi-factor authentication is required. Please enable MFA in your security settings.';
} else if (rawMessage.includes('CSRF token') || rawMessage.toLowerCase().includes('csrf')) {
errorMessage = 'Security validation failed. Please refresh the page and try again.';
shouldRetry = true;
} else if (rawMessage.toLowerCase().includes('forbidden') || rawMessage.toLowerCase().includes('permission')) {
@@ -271,6 +326,7 @@ apiClient.interceptors.response.use(
url: originalRequest?.url,
method: originalRequest?.method,
rawMessage: rawMessage.substring(0, 100), // Limit length
isMfaRequired,
});
}

View File

@@ -0,0 +1,176 @@
/**
* Centralized error handling utilities
* Provides consistent error handling and user feedback across the application
*/
import { toast } from 'react-toastify';
import { AxiosError } from 'axios';
import { logger } from './logger';
export interface ErrorDetails {
message: string;
errors?: Array<{ field?: string; message: string }>;
statusCode?: number;
requestId?: string;
}
/**
* Extract error message from various error types
*/
export const extractErrorMessage = (error: any): string => {
// Handle Axios errors
if (error?.response?.data) {
const data = error.response.data;
// Check for standard error response format
if (data.message) {
return data.message;
}
// Check for detail field
if (data.detail) {
return typeof data.detail === 'string'
? data.detail
: data.detail?.message || 'An error occurred';
}
// Check for errors array
if (data.errors && Array.isArray(data.errors) && data.errors.length > 0) {
return data.errors[0].message || data.errors[0].detail || 'Validation error';
}
}
// Handle standard Error objects
if (error instanceof Error) {
return error.message;
}
// Handle string errors
if (typeof error === 'string') {
return error;
}
// Fallback
return 'An unexpected error occurred. Please try again.';
};
/**
* Extract error details for logging/debugging
*/
export const extractErrorDetails = (error: any): ErrorDetails => {
const message = extractErrorMessage(error);
const statusCode = error?.response?.status;
const requestId = error?.response?.headers?.['x-request-id'] || error?.requestId;
let errors: Array<{ field?: string; message: string }> | undefined;
if (error?.response?.data?.errors && Array.isArray(error.response.data.errors)) {
errors = error.response.data.errors.map((err: any) => ({
field: err.field || err.path,
message: err.message || 'Invalid value',
}));
}
return {
message,
errors,
statusCode,
requestId,
};
};
/**
* Handle error with user feedback
* Shows toast notification and optionally logs error
*/
export const handleError = (
error: any,
options?: {
defaultMessage?: string;
showToast?: boolean;
logError?: boolean;
toastOptions?: any;
}
): ErrorDetails => {
const {
defaultMessage = 'An error occurred. Please try again.',
showToast = true,
logError = true,
toastOptions = {},
} = options || {};
const errorDetails = extractErrorDetails(error);
const displayMessage = errorDetails.message || defaultMessage;
// Log error for debugging (only in development or if explicitly enabled)
if (logError && (import.meta.env.DEV || import.meta.env.MODE === 'development')) {
logger.error('Error occurred', {
message: displayMessage,
statusCode: errorDetails.statusCode,
requestId: errorDetails.requestId,
error: error instanceof Error ? error.stack : String(error),
});
}
// Show user-friendly toast notification
if (showToast) {
toast.error(displayMessage, {
autoClose: 5000,
...toastOptions,
});
}
return errorDetails;
};
/**
* Handle error silently (no toast, only logging)
*/
export const handleErrorSilently = (error: any): ErrorDetails => {
return handleError(error, { showToast: false, logError: true });
};
/**
* Handle error with custom message
*/
export const handleErrorWithMessage = (
error: any,
customMessage: string
): ErrorDetails => {
const errorDetails = extractErrorDetails(error);
toast.error(customMessage, {
autoClose: 5000,
});
if (import.meta.env.DEV) {
logger.error('Error occurred', {
customMessage,
originalError: errorDetails.message,
statusCode: errorDetails.statusCode,
requestId: errorDetails.requestId,
});
}
return errorDetails;
};
/**
* Handle validation errors specifically
*/
export const handleValidationError = (error: any): ErrorDetails => {
const errorDetails = extractErrorDetails(error);
if (errorDetails.errors && errorDetails.errors.length > 0) {
// Show first validation error
toast.error(errorDetails.errors[0].message || 'Please check your input and try again.', {
autoClose: 5000,
});
} else {
toast.error(errorDetails.message || 'Please check your input and try again.', {
autoClose: 5000,
});
}
return errorDetails;
};

View File

@@ -0,0 +1,144 @@
/**
* Permission helper utilities
* Mirror backend permission structure for frontend UI/UX
*
* NOTE: These are for UI purposes only. Backend enforces actual permissions.
*/
export type Permission =
// Financial permissions
| 'financial.view_reports'
| 'financial.manage_invoices'
| 'financial.manage_payments'
| 'financial.manage_settings'
| 'financial.high_risk_approve'
| 'financial.view_invoices'
| 'financial.view_payments'
| 'financial.view_audit_trail'
// User management permissions
| 'users.manage'
// Housekeeping permissions
| 'housekeeping.view_tasks'
| 'housekeeping.manage_tasks'
| 'housekeeping.create_tasks'
| 'housekeeping.view_rooms'
| 'housekeeping.upload_photos'
| 'housekeeping.report_issues'
| 'housekeeping.view_inspections'
| 'housekeeping.manage_inspections'
// Booking permissions
| 'bookings.manage'
| 'bookings.view_own'
| 'bookings.create'
// Room permissions
| 'rooms.manage'
| 'rooms.view'
// Profile permissions
| 'profile.manage';
/**
* Check if a role has a specific permission
* This is a pure function that can be used outside of React components
*/
export const roleHasPermission = (
roleName: string | undefined | null,
permission: Permission
): boolean => {
if (!roleName) return false;
// Import permissions from hook (circular dependency issue if we import directly)
// Instead, we'll define a simplified check here
const rolePermissions: Record<string, Permission[]> = {
admin: [
'financial.view_reports',
'financial.manage_invoices',
'financial.manage_payments',
'financial.manage_settings',
'financial.high_risk_approve',
'users.manage',
'housekeeping.view_tasks',
'housekeeping.manage_tasks',
'housekeeping.view_rooms',
'bookings.manage',
'rooms.manage',
],
accountant: [
'financial.view_reports',
'financial.manage_invoices',
'financial.manage_payments',
'financial.manage_settings',
'financial.high_risk_approve',
],
accountant_readonly: [
'financial.view_reports',
'financial.view_invoices',
'financial.view_payments',
'financial.view_audit_trail',
],
accountant_operator: [
'financial.view_reports',
'financial.view_invoices',
'financial.view_payments',
'financial.manage_invoices',
'financial.manage_payments',
],
accountant_approver: [
'financial.view_reports',
'financial.view_invoices',
'financial.view_payments',
'financial.high_risk_approve',
],
staff: [
'financial.view_invoices',
'financial.manage_invoices',
'financial.view_payments',
'housekeeping.view_tasks',
'housekeeping.manage_tasks',
'housekeeping.view_rooms',
'bookings.manage',
'rooms.manage',
],
housekeeping: [
'housekeeping.view_tasks',
'housekeeping.manage_tasks',
'housekeeping.create_tasks',
'housekeeping.view_rooms',
'housekeeping.upload_photos',
'housekeeping.report_issues',
'housekeeping.view_inspections',
'housekeeping.manage_inspections',
],
customer: [
'bookings.view_own',
'bookings.create',
'rooms.view',
'profile.manage',
],
};
const permissions = rolePermissions[roleName] || [];
return permissions.includes(permission);
};
/**
* Check if role is in accountant family
*/
export const isAccountantRole = (roleName: string | undefined | null): boolean => {
if (!roleName) return false;
return roleName === 'accountant' || roleName.startsWith('accountant_');
};
/**
* Check if role can access financial features
*/
export const canAccessFinancial = (roleName: string | undefined | null): boolean => {
if (!roleName) return false;
return ['admin', 'staff', 'accountant', 'accountant_readonly', 'accountant_operator', 'accountant_approver']
.includes(roleName);
};

View File

@@ -0,0 +1,131 @@
/**
* Input sanitization utilities using DOMPurify
* Prevents XSS attacks by sanitizing user-generated content
*/
// DOMPurify needs to run in browser environment
let DOMPurify: any = null;
if (typeof window !== 'undefined') {
DOMPurify = require('dompurify');
}
/**
* Configuration for DOMPurify
*/
const PURIFY_CONFIG = {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'u', 'b', 'i', 's', 'strike',
'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'pre', 'code', 'hr', 'div', 'span',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
],
ALLOWED_ATTR: [
'href', 'title', 'target', 'rel',
'class',
'colspan', 'rowspan',
],
ALLOW_DATA_ATTR: false,
};
/**
* Sanitize HTML content to prevent XSS attacks
*
* @param content - HTML content to sanitize
* @param strip - If true, remove disallowed tags instead of escaping them
* @returns Sanitized HTML string
*/
export const sanitizeHTML = (content: string | null | undefined, strip: boolean = false): string => {
if (!content) return '';
if (typeof content !== 'string') {
content = String(content);
}
// Skip sanitization in SSR or if DOMPurify not available
if (!DOMPurify || typeof window === 'undefined') {
// Fallback: basic HTML entity escaping
return content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// Sanitize HTML
return DOMPurify.sanitize(content, {
...PURIFY_CONFIG,
KEEP_CONTENT: !strip,
});
};
/**
* Strip all HTML tags from content, leaving only plain text
*
* @param content - Content to strip HTML from
* @returns Plain text string
*/
export const sanitizeText = (content: string | null | undefined): string => {
if (!content) return '';
if (typeof content !== 'string') {
content = String(content);
}
// Skip sanitization in SSR or if DOMPurify not available
if (!DOMPurify || typeof window === 'undefined') {
// Fallback: basic HTML entity escaping
return content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// Strip all HTML tags
return DOMPurify.sanitize(content, {
ALLOWED_TAGS: [],
KEEP_CONTENT: true,
});
};
/**
* Escape HTML entities for safe display
*
* @param text - Text to escape
* @returns Escaped text safe for HTML display
*/
export const escapeHTML = (text: string | null | undefined): string => {
if (!text) return '';
if (typeof text !== 'string') {
text = String(text);
}
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
/**
* Sanitize user input for display in React components
* This removes any potentially dangerous content
*
* @param input - User input to sanitize
* @returns Sanitized string safe for display
*/
export const sanitizeUserInput = (input: string | null | undefined): string => {
return sanitizeText(input);
};
/**
* Sanitize rich text content (allows safe HTML)
*
* @param html - HTML content to sanitize
* @returns Sanitized HTML safe for rendering
*/
export const sanitizeRichText = (html: string | null | undefined): string => {
return sanitizeHTML(html, false);
};