update
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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')
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -1,6 +1,7 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
# Add parent directory to path to import from src
|
# Add parent directory to path to import from src
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
from sqlalchemy.orm import Session
|
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_section_subtitle = 'Awards and certifications that validate our commitment to excellence'
|
||||||
trust_badges_enabled = True
|
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 = [
|
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',
|
'title': 'Summer Special',
|
||||||
'description': 'Enjoy 25% off on all room bookings this summer. Limited time offer!',
|
'description': 'Enjoy 25% off on all room bookings this summer. Limited time offer!',
|
||||||
'image': 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=600',
|
'image': 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=600',
|
||||||
'discount': '25% OFF',
|
'discount': '25% OFF',
|
||||||
'valid_until': '2024-08-31',
|
'valid_until': expired_3_months_ago.strftime('%Y-%m-%d'),
|
||||||
'link': '/rooms',
|
'link': '/rooms',
|
||||||
'button_text': 'Book Now'
|
'button_text': 'Book Now',
|
||||||
|
'code': 'SUMMER25'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'title': 'Weekend Getaway',
|
'title': 'New Year Celebration',
|
||||||
'description': 'Perfect weekend escape with complimentary breakfast and spa access',
|
'description': 'Ring in the new year with our special celebration package. Includes party access and premium amenities',
|
||||||
'image': 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=600',
|
'image': 'https://images.unsplash.com/photo-1590490360182-c33d57733427?w=600',
|
||||||
'discount': '30% OFF',
|
'discount': '35% OFF',
|
||||||
'valid_until': '2024-12-31',
|
'valid_until': expired_1_month_ago.strftime('%Y-%m-%d'),
|
||||||
'link': '/rooms',
|
'link': '/rooms',
|
||||||
'button_text': 'Learn More'
|
'button_text': 'View Offer',
|
||||||
},
|
'code': 'NEWYEAR35'
|
||||||
{
|
|
||||||
'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'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
promotions_section_title = 'Special Offers'
|
promotions_section_title = 'Special Offers'
|
||||||
|
|||||||
157
Backend/seeds_data/seed_promotions.py
Normal file
157
Backend/seeds_data/seed_promotions.py
Normal 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()
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -12,6 +12,7 @@ from ..models.guest_request import GuestRequest, RequestType, RequestStatus, Req
|
|||||||
from ...bookings.models.booking import Booking, BookingStatus
|
from ...bookings.models.booking import Booking, BookingStatus
|
||||||
from ...rooms.models.room import Room
|
from ...rooms.models.room import Room
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from ...shared.utils.sanitization import sanitize_text
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
router = APIRouter(prefix='/guest-requests', tags=['guest-requests'])
|
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:
|
if booking.room_id != request_data.room_id:
|
||||||
raise HTTPException(status_code=400, detail='Room ID does not match booking')
|
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(
|
guest_request = GuestRequest(
|
||||||
booking_id=request_data.booking_id,
|
booking_id=request_data.booking_id,
|
||||||
room_id=request_data.room_id,
|
room_id=request_data.room_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
request_type=RequestType(request_data.request_type),
|
request_type=RequestType(request_data.request_type),
|
||||||
priority=RequestPriority(request_data.priority),
|
priority=RequestPriority(request_data.priority),
|
||||||
title=request_data.title,
|
title=sanitized_title,
|
||||||
description=request_data.description,
|
description=sanitized_description,
|
||||||
guest_notes=request_data.guest_notes,
|
guest_notes=sanitized_guest_notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(guest_request)
|
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.commit()
|
||||||
db.refresh(guest_request)
|
db.refresh(guest_request)
|
||||||
|
|
||||||
@@ -378,7 +496,8 @@ async def fulfill_request(
|
|||||||
request.fulfilled_at = datetime.utcnow()
|
request.fulfilled_at = datetime.utcnow()
|
||||||
|
|
||||||
if staff_notes:
|
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:
|
if request.started_at:
|
||||||
delta = datetime.utcnow() - request.started_at
|
delta = datetime.utcnow() - request.started_at
|
||||||
|
|||||||
Binary file not shown.
@@ -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 sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import enum
|
import enum
|
||||||
@@ -18,6 +18,30 @@ class Promotion(Base):
|
|||||||
discount_value = Column(Numeric(10, 2), nullable=False)
|
discount_value = Column(Numeric(10, 2), nullable=False)
|
||||||
min_booking_amount = Column(Numeric(10, 2), nullable=True)
|
min_booking_amount = Column(Numeric(10, 2), nullable=True)
|
||||||
max_discount_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)
|
start_date = Column(DateTime, nullable=False)
|
||||||
end_date = Column(DateTime, nullable=False)
|
end_date = Column(DateTime, nullable=False)
|
||||||
usage_limit = Column(Integer, nullable=True)
|
usage_limit = Column(Integer, nullable=True)
|
||||||
|
|||||||
Binary file not shown.
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from ...shared.config.database import get_db
|
from ...shared.config.database import get_db
|
||||||
from ...shared.config.logging_config import get_logger
|
from ...shared.config.logging_config import get_logger
|
||||||
from ...security.middleware.auth import get_current_user, authorize_roles
|
from ...security.middleware.auth import get_current_user, authorize_roles
|
||||||
@@ -17,6 +17,40 @@ from ..schemas.promotion import (
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
router = APIRouter(prefix='/promotions', tags=['promotions'])
|
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('/')
|
@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)):
|
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:
|
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()
|
promotions = query.order_by(Promotion.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
result = []
|
result = []
|
||||||
for promo in promotions:
|
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)
|
result.append(promo_dict)
|
||||||
return {'status': 'success', 'data': {'promotions': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}}
|
return {'status': 'success', 'data': {'promotions': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}}
|
||||||
except Exception as e:
|
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()
|
promotion = db.query(Promotion).filter(Promotion.code == code).first()
|
||||||
if not promotion:
|
if not promotion:
|
||||||
raise HTTPException(status_code=404, detail='Promotion not found')
|
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}}
|
return {'status': 'success', 'data': {'promotion': promo_dict}}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -70,17 +104,171 @@ async def validate_promotion(validation_data: ValidatePromotionRequest, db: Sess
|
|||||||
if not promotion.is_active:
|
if not promotion.is_active:
|
||||||
raise HTTPException(status_code=400, detail='Promotion is not active')
|
raise HTTPException(status_code=400, detail='Promotion is not active')
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
if promotion.start_date and now < promotion.start_date:
|
if promotion.start_date:
|
||||||
raise HTTPException(status_code=400, detail='Promotion is not valid at this time')
|
# Compare datetimes directly (both should be timezone-naive UTC)
|
||||||
if promotion.end_date and now > promotion.end_date:
|
if now < promotion.start_date:
|
||||||
raise HTTPException(status_code=400, detail='Promotion is not valid at this time')
|
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:
|
if promotion.usage_limit and promotion.used_count >= promotion.usage_limit:
|
||||||
raise HTTPException(status_code=400, detail='Promotion usage limit reached')
|
raise HTTPException(status_code=400, detail='Promotion usage limit reached')
|
||||||
if promotion.min_booking_amount and booking_amount < float(promotion.min_booking_amount):
|
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}')
|
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)
|
discount_amount = promotion.calculate_discount(booking_amount)
|
||||||
final_amount = booking_amount - discount_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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -105,6 +293,19 @@ async def create_promotion(promotion_data: CreatePromotionRequest, current_user:
|
|||||||
discount_value=discount_value,
|
discount_value=discount_value,
|
||||||
min_booking_amount=promotion_data.min_booking_amount,
|
min_booking_amount=promotion_data.min_booking_amount,
|
||||||
max_discount_amount=promotion_data.max_discount_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,
|
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,
|
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,
|
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
|
promotion.min_booking_amount = promotion_data.min_booking_amount
|
||||||
if promotion_data.max_discount_amount is not None:
|
if promotion_data.max_discount_amount is not None:
|
||||||
promotion.max_discount_amount = promotion_data.max_discount_amount
|
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:
|
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
|
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:
|
if promotion_data.end_date is not None:
|
||||||
|
|||||||
Binary file not shown.
@@ -2,7 +2,7 @@
|
|||||||
Pydantic schemas for promotion-related requests and responses.
|
Pydantic schemas for promotion-related requests and responses.
|
||||||
"""
|
"""
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -11,6 +11,11 @@ class ValidatePromotionRequest(BaseModel):
|
|||||||
code: str = Field(..., min_length=1, max_length=50, description="Promotion code")
|
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_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)")
|
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')
|
@field_validator('code')
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -24,7 +29,9 @@ class ValidatePromotionRequest(BaseModel):
|
|||||||
"json_schema_extra": {
|
"json_schema_extra": {
|
||||||
"example": {
|
"example": {
|
||||||
"code": "SUMMER2024",
|
"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")
|
discount_value: float = Field(..., gt=0, description="Discount value")
|
||||||
min_booking_amount: Optional[float] = Field(None, ge=0)
|
min_booking_amount: Optional[float] = Field(None, ge=0)
|
||||||
max_discount_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)")
|
start_date: Optional[str] = Field(None, description="Start date (ISO format)")
|
||||||
end_date: Optional[str] = Field(None, description="End date (ISO format)")
|
end_date: Optional[str] = Field(None, description="End date (ISO format)")
|
||||||
usage_limit: Optional[int] = Field(None, ge=1)
|
usage_limit: Optional[int] = Field(None, ge=1)
|
||||||
@@ -97,6 +128,25 @@ class UpdatePromotionRequest(BaseModel):
|
|||||||
discount_value: Optional[float] = Field(None, gt=0)
|
discount_value: Optional[float] = Field(None, gt=0)
|
||||||
min_booking_amount: Optional[float] = Field(None, ge=0)
|
min_booking_amount: Optional[float] = Field(None, ge=0)
|
||||||
max_discount_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
|
start_date: Optional[str] = None
|
||||||
end_date: Optional[str] = None
|
end_date: Optional[str] = None
|
||||||
usage_limit: Optional[int] = Field(None, ge=1)
|
usage_limit: Optional[int] = Field(None, ge=1)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -12,6 +12,7 @@ import json
|
|||||||
from ...shared.config.database import get_db
|
from ...shared.config.database import get_db
|
||||||
from ...shared.config.logging_config import get_logger
|
from ...shared.config.logging_config import get_logger
|
||||||
from ...security.middleware.auth import authorize_roles
|
from ...security.middleware.auth import authorize_roles
|
||||||
|
from ...security.middleware.step_up_auth import authorize_financial_access
|
||||||
from ...auth.models.user import User
|
from ...auth.models.user import User
|
||||||
from ..models.financial_audit_trail import FinancialAuditTrail, FinancialActionType
|
from ..models.financial_audit_trail import FinancialAuditTrail, FinancialActionType
|
||||||
from ..services.financial_audit_service import financial_audit_service
|
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),
|
end_date: Optional[str] = Query(None),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get financial audit trail records with filters. Requires step-up authentication."""
|
"""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}')
|
@router.get('/{record_id}')
|
||||||
async def get_audit_record(
|
async def get_audit_record(
|
||||||
record_id: int,
|
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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get a specific audit trail record."""
|
"""Get a specific audit trail record."""
|
||||||
@@ -259,7 +260,7 @@ async def export_audit_trail(
|
|||||||
user_id: Optional[int] = Query(None),
|
user_id: Optional[int] = Query(None),
|
||||||
start_date: Optional[str] = Query(None),
|
start_date: Optional[str] = Query(None),
|
||||||
end_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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Export financial audit trail to CSV or JSON. Requires step-up authentication."""
|
"""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')
|
@router.get('/retention/stats')
|
||||||
async def get_retention_stats(
|
async def get_retention_stats(
|
||||||
retention_days: int = Query(2555, ge=365, le=3650),
|
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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get statistics about audit trail retention."""
|
"""Get statistics about audit trail retention."""
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import io
|
|||||||
from ...shared.config.database import get_db
|
from ...shared.config.database import get_db
|
||||||
from ...shared.config.logging_config import get_logger
|
from ...shared.config.logging_config import get_logger
|
||||||
from ...security.middleware.auth import authorize_roles
|
from ...security.middleware.auth import authorize_roles
|
||||||
|
from ...security.middleware.step_up_auth import authorize_financial_access
|
||||||
from ...auth.models.user import User
|
from ...auth.models.user import User
|
||||||
from ..models.payment import Payment, PaymentStatus, PaymentMethod
|
from ..models.payment import Payment, PaymentStatus, PaymentMethod
|
||||||
from ..models.invoice import Invoice, InvoiceStatus
|
from ..models.invoice import Invoice, InvoiceStatus
|
||||||
@@ -27,7 +28,7 @@ router = APIRouter(prefix='/financial', tags=['financial'])
|
|||||||
async def get_profit_loss_report(
|
async def get_profit_loss_report(
|
||||||
start_date: Optional[str] = Query(None),
|
start_date: Optional[str] = Query(None),
|
||||||
end_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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Generate Profit & Loss statement."""
|
"""Generate Profit & Loss statement."""
|
||||||
@@ -240,7 +241,7 @@ async def get_profit_loss_report(
|
|||||||
@router.get('/balance-sheet')
|
@router.get('/balance-sheet')
|
||||||
async def get_balance_sheet(
|
async def get_balance_sheet(
|
||||||
as_of_date: Optional[str] = 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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Generate Balance Sheet statement."""
|
"""Generate Balance Sheet statement."""
|
||||||
@@ -413,7 +414,7 @@ async def get_tax_report(
|
|||||||
start_date: Optional[str] = Query(None),
|
start_date: Optional[str] = Query(None),
|
||||||
end_date: Optional[str] = Query(None),
|
end_date: Optional[str] = Query(None),
|
||||||
format: Optional[str] = Query('json', regex='^(json|csv)$'),
|
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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Generate tax report with export capability. Requires step-up authentication for exports."""
|
"""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),
|
start_date: Optional[str] = Query(None),
|
||||||
end_date: Optional[str] = Query(None),
|
end_date: Optional[str] = Query(None),
|
||||||
include_exceptions: bool = Query(True),
|
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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Generate payment reconciliation report with exception integration."""
|
"""Generate payment reconciliation report with exception integration."""
|
||||||
@@ -643,7 +644,7 @@ async def get_payment_reconciliation(
|
|||||||
async def get_refund_history(
|
async def get_refund_history(
|
||||||
start_date: Optional[str] = Query(None),
|
start_date: Optional[str] = Query(None),
|
||||||
end_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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get refund history and statistics."""
|
"""Get refund history and statistics."""
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from datetime import datetime
|
|||||||
from ...shared.config.database import get_db
|
from ...shared.config.database import get_db
|
||||||
from ...shared.config.logging_config import get_logger
|
from ...shared.config.logging_config import get_logger
|
||||||
from ...security.middleware.auth import authorize_roles, get_current_user
|
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 ...auth.models.user import User
|
||||||
from ..services.gl_service import gl_service
|
from ..services.gl_service import gl_service
|
||||||
from ..models.fiscal_period import FiscalPeriod, PeriodStatus
|
from ..models.fiscal_period import FiscalPeriod, PeriodStatus
|
||||||
@@ -23,7 +24,7 @@ router = APIRouter(prefix='/financial/gl', tags=['general-ledger'])
|
|||||||
async def get_trial_balance(
|
async def get_trial_balance(
|
||||||
period_id: Optional[int] = Query(None),
|
period_id: Optional[int] = Query(None),
|
||||||
as_of_date: Optional[str] = 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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get trial balance for a period or as of a date."""
|
"""Get trial balance for a period or as of a date."""
|
||||||
@@ -42,7 +43,7 @@ async def get_trial_balance(
|
|||||||
|
|
||||||
@router.get('/periods')
|
@router.get('/periods')
|
||||||
async def get_fiscal_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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get all fiscal periods."""
|
"""Get all fiscal periods."""
|
||||||
@@ -156,7 +157,7 @@ async def close_fiscal_period(
|
|||||||
|
|
||||||
@router.get('/accounts')
|
@router.get('/accounts')
|
||||||
async def get_chart_of_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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get chart of accounts."""
|
"""Get chart of accounts."""
|
||||||
@@ -188,7 +189,7 @@ async def get_journal_entries(
|
|||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get journal entries with pagination."""
|
"""Get journal entries with pagination."""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from datetime import datetime
|
|||||||
from ...shared.config.database import get_db
|
from ...shared.config.database import get_db
|
||||||
from ...shared.config.logging_config import get_logger
|
from ...shared.config.logging_config import get_logger
|
||||||
from ...security.middleware.auth import get_current_user, authorize_roles
|
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 ...auth.models.user import User
|
||||||
from ..models.invoice import Invoice, InvoiceStatus
|
from ..models.invoice import Invoice, InvoiceStatus
|
||||||
from ...bookings.models.booking import Booking
|
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))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.put('/{id}')
|
@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:
|
try:
|
||||||
invoice = db.query(Invoice).filter(Invoice.id == id).first()
|
invoice = db.query(Invoice).filter(Invoice.id == id).first()
|
||||||
if not invoice:
|
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))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.post('/{id}/mark-paid')
|
@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:
|
try:
|
||||||
request_id = get_request_id(request)
|
request_id = get_request_id(request)
|
||||||
amount = payment_data.amount
|
amount = payment_data.amount
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from datetime import datetime
|
|||||||
from ...shared.config.database import get_db
|
from ...shared.config.database import get_db
|
||||||
from ...shared.config.logging_config import get_logger
|
from ...shared.config.logging_config import get_logger
|
||||||
from ...security.middleware.auth import authorize_roles, get_current_user
|
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 ...auth.models.user import User
|
||||||
from ..services.reconciliation_service import reconciliation_service
|
from ..services.reconciliation_service import reconciliation_service
|
||||||
from ..models.reconciliation_exception import ExceptionStatus, ExceptionType
|
from ..models.reconciliation_exception import ExceptionStatus, ExceptionType
|
||||||
@@ -21,7 +22,7 @@ router = APIRouter(prefix='/financial/reconciliation', tags=['reconciliation'])
|
|||||||
async def run_reconciliation(
|
async def run_reconciliation(
|
||||||
start_date: Optional[str] = Query(None),
|
start_date: Optional[str] = Query(None),
|
||||||
end_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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Run reconciliation and detect exceptions."""
|
"""Run reconciliation and detect exceptions."""
|
||||||
@@ -55,7 +56,7 @@ async def get_reconciliation_exceptions(
|
|||||||
severity: Optional[str] = Query(None),
|
severity: Optional[str] = Query(None),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get reconciliation exceptions with filters."""
|
"""Get reconciliation exceptions with filters."""
|
||||||
@@ -96,7 +97,7 @@ async def get_reconciliation_exceptions(
|
|||||||
async def assign_exception(
|
async def assign_exception(
|
||||||
exception_id: int,
|
exception_id: int,
|
||||||
assign_data: dict,
|
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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Assign an exception to a user."""
|
"""Assign an exception to a user."""
|
||||||
@@ -130,7 +131,7 @@ async def assign_exception(
|
|||||||
async def resolve_exception(
|
async def resolve_exception(
|
||||||
exception_id: int,
|
exception_id: int,
|
||||||
resolve_data: dict,
|
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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Resolve an exception."""
|
"""Resolve an exception."""
|
||||||
@@ -165,7 +166,7 @@ async def resolve_exception(
|
|||||||
async def add_exception_comment(
|
async def add_exception_comment(
|
||||||
exception_id: int,
|
exception_id: int,
|
||||||
comment_data: dict,
|
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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Add a comment to an exception."""
|
"""Add a comment to an exception."""
|
||||||
@@ -198,7 +199,7 @@ async def add_exception_comment(
|
|||||||
|
|
||||||
@router.get('/exceptions/stats')
|
@router.get('/exceptions/stats')
|
||||||
async def get_exception_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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get statistics about reconciliation exceptions."""
|
"""Get statistics about reconciliation exceptions."""
|
||||||
|
|||||||
Binary file not shown.
@@ -9,6 +9,7 @@ import hashlib
|
|||||||
from ...shared.config.database import get_db
|
from ...shared.config.database import get_db
|
||||||
from ...shared.config.logging_config import get_logger
|
from ...shared.config.logging_config import get_logger
|
||||||
from ...security.middleware.auth import get_current_user, authorize_roles
|
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.user import User
|
||||||
from ...auth.models.role import Role
|
from ...auth.models.role import Role
|
||||||
from ..models.room import Room, RoomStatus
|
from ..models.room import Room, RoomStatus
|
||||||
@@ -246,12 +247,17 @@ async def create_maintenance_record(
|
|||||||
if maintenance_data.get('block_end'):
|
if maintenance_data.get('block_end'):
|
||||||
block_end = datetime.fromisoformat(maintenance_data['block_end'].replace('Z', '+00:00'))
|
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(
|
maintenance = RoomMaintenance(
|
||||||
room_id=maintenance_data['room_id'],
|
room_id=maintenance_data['room_id'],
|
||||||
maintenance_type=MaintenanceType(maintenance_data.get('maintenance_type', 'preventive')),
|
maintenance_type=MaintenanceType(maintenance_data.get('maintenance_type', 'preventive')),
|
||||||
status=MaintenanceStatus(maintenance_data.get('status', 'scheduled')),
|
status=MaintenanceStatus(maintenance_data.get('status', 'scheduled')),
|
||||||
title=maintenance_data.get('title', 'Maintenance'),
|
title=sanitized_title,
|
||||||
description=maintenance_data.get('description'),
|
description=sanitized_description,
|
||||||
scheduled_start=scheduled_start,
|
scheduled_start=scheduled_start,
|
||||||
scheduled_end=scheduled_end,
|
scheduled_end=scheduled_end,
|
||||||
assigned_to=maintenance_data.get('assigned_to'),
|
assigned_to=maintenance_data.get('assigned_to'),
|
||||||
@@ -261,7 +267,7 @@ async def create_maintenance_record(
|
|||||||
block_start=block_start,
|
block_start=block_start,
|
||||||
block_end=block_end,
|
block_end=block_end,
|
||||||
priority=maintenance_data.get('priority', 'medium'),
|
priority=maintenance_data.get('priority', 'medium'),
|
||||||
notes=maintenance_data.get('notes')
|
notes=sanitized_notes
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update room status if blocking and maintenance is active
|
# Update room status if blocking and maintenance is active
|
||||||
@@ -362,7 +368,7 @@ async def update_maintenance_record(
|
|||||||
if 'actual_end' in maintenance_data:
|
if 'actual_end' in maintenance_data:
|
||||||
maintenance.actual_end = datetime.fromisoformat(maintenance_data['actual_end'].replace('Z', '+00:00'))
|
maintenance.actual_end = datetime.fromisoformat(maintenance_data['actual_end'].replace('Z', '+00:00'))
|
||||||
if 'completion_notes' in maintenance_data:
|
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:
|
if 'actual_cost' in maintenance_data:
|
||||||
maintenance.actual_cost = maintenance_data['actual_cost']
|
maintenance.actual_cost = maintenance_data['actual_cost']
|
||||||
|
|
||||||
@@ -604,6 +610,9 @@ async def create_housekeeping_task(
|
|||||||
if is_housekeeping and not assigned_to:
|
if is_housekeeping and not assigned_to:
|
||||||
assigned_to = current_user.id
|
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(
|
task = HousekeepingTask(
|
||||||
room_id=task_data['room_id'],
|
room_id=task_data['room_id'],
|
||||||
booking_id=task_data.get('booking_id'),
|
booking_id=task_data.get('booking_id'),
|
||||||
@@ -613,7 +622,7 @@ async def create_housekeeping_task(
|
|||||||
assigned_to=assigned_to,
|
assigned_to=assigned_to,
|
||||||
created_by=current_user.id,
|
created_by=current_user.id,
|
||||||
checklist_items=task_data.get('checklist_items', []),
|
checklist_items=task_data.get('checklist_items', []),
|
||||||
notes=task_data.get('notes'),
|
notes=sanitized_notes,
|
||||||
estimated_duration_minutes=task_data.get('estimated_duration_minutes')
|
estimated_duration_minutes=task_data.get('estimated_duration_minutes')
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -774,16 +783,16 @@ async def update_housekeeping_task(
|
|||||||
if 'checklist_items' in task_data:
|
if 'checklist_items' in task_data:
|
||||||
task.checklist_items = task_data['checklist_items']
|
task.checklist_items = task_data['checklist_items']
|
||||||
if 'notes' in task_data:
|
if 'notes' in task_data:
|
||||||
task.notes = task_data['notes']
|
task.notes = sanitize_text(task_data['notes']) if task_data['notes'] else None
|
||||||
if 'issues_found' in task_data:
|
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:
|
if 'quality_score' in task_data:
|
||||||
task.quality_score = task_data['quality_score']
|
task.quality_score = task_data['quality_score']
|
||||||
if 'inspected_by' in task_data:
|
if 'inspected_by' in task_data:
|
||||||
task.inspected_by = task_data['inspected_by']
|
task.inspected_by = task_data['inspected_by']
|
||||||
task.inspected_at = datetime.utcnow()
|
task.inspected_at = datetime.utcnow()
|
||||||
if 'inspection_notes' in task_data:
|
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:
|
if 'photos' in task_data:
|
||||||
task.photos = task_data['photos']
|
task.photos = task_data['photos']
|
||||||
|
|
||||||
@@ -930,11 +939,11 @@ async def report_maintenance_issue_from_task(
|
|||||||
if not room:
|
if not room:
|
||||||
raise HTTPException(status_code=404, detail='Room not found')
|
raise HTTPException(status_code=404, detail='Room not found')
|
||||||
|
|
||||||
# Create maintenance record
|
# Create maintenance record - sanitize user input
|
||||||
title = issue_data.get('title', f'Issue reported from Room {room.room_number}')
|
title = sanitize_text(issue_data.get('title', f'Issue reported from Room {room.room_number}'))
|
||||||
description = issue_data.get('description', '')
|
description = sanitize_text(issue_data.get('description', ''))
|
||||||
if task.notes:
|
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:
|
else:
|
||||||
description = f"Reported from housekeeping task.\n\nIssue Description: {description}".strip()
|
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,
|
reported_by=current_user.id,
|
||||||
priority=issue_data.get('priority', 'high'),
|
priority=issue_data.get('priority', 'high'),
|
||||||
blocks_room=issue_data.get('blocks_room', True),
|
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
|
# Update room status if blocking
|
||||||
@@ -1168,15 +1177,15 @@ async def update_room_inspection(
|
|||||||
if 'overall_score' in inspection_data:
|
if 'overall_score' in inspection_data:
|
||||||
inspection.overall_score = inspection_data['overall_score']
|
inspection.overall_score = inspection_data['overall_score']
|
||||||
if 'overall_notes' in inspection_data:
|
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:
|
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:
|
if 'photos' in inspection_data:
|
||||||
inspection.photos = inspection_data['photos']
|
inspection.photos = inspection_data['photos']
|
||||||
if 'requires_followup' in inspection_data:
|
if 'requires_followup' in inspection_data:
|
||||||
inspection.requires_followup = inspection_data['requires_followup']
|
inspection.requires_followup = inspection_data['requires_followup']
|
||||||
if 'followup_notes' in inspection_data:
|
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:
|
if 'maintenance_request_id' in inspection_data:
|
||||||
inspection.maintenance_request_id = inspection_data['maintenance_request_id']
|
inspection.maintenance_request_id = inspection_data['maintenance_request_id']
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -124,6 +124,8 @@ def get_current_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# SECURITY: Check MFA for accountant/admin roles (warn but allow access for MFA setup)
|
# 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:
|
try:
|
||||||
from ...payments.services.accountant_security_service import accountant_security_service
|
from ...payments.services.accountant_security_service import accountant_security_service
|
||||||
from ...shared.utils.role_helpers import is_accountant, is_admin
|
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)
|
is_enforced, reason = accountant_security_service.is_mfa_enforced(user, db)
|
||||||
if not is_enforced and reason:
|
if not is_enforced and reason:
|
||||||
# Log warning but allow access so user can set up MFA
|
# 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(
|
logger.warning(
|
||||||
f'User {user.id} ({user.email}) accessed system without MFA enabled. '
|
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. '
|
f'MFA is required for {user.role.name if user.role else "unknown"} role. '
|
||||||
|
|||||||
110
Backend/src/security/middleware/permission_dependencies.py
Normal file
110
Backend/src/security/middleware/permission_dependencies.py
Normal 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'
|
||||||
|
)
|
||||||
@@ -63,20 +63,30 @@ def require_step_up_auth(
|
|||||||
def enforce_mfa_for_accountants():
|
def enforce_mfa_for_accountants():
|
||||||
"""
|
"""
|
||||||
Dependency to enforce MFA for accountant/admin roles.
|
Dependency to enforce MFA for accountant/admin roles.
|
||||||
|
This blocks access if MFA is required but not enabled.
|
||||||
"""
|
"""
|
||||||
async def mfa_enforcer(
|
async def mfa_enforcer(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
) -> User:
|
) -> 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)
|
is_enforced, reason = accountant_security_service.is_mfa_enforced(current_user, db)
|
||||||
|
|
||||||
if not is_enforced and reason:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail={
|
detail={
|
||||||
'error': 'mfa_required',
|
'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
|
'requires_mfa_setup': True
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -85,3 +95,62 @@ def enforce_mfa_for_accountants():
|
|||||||
|
|
||||||
return mfa_enforcer
|
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
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -168,6 +168,20 @@ ROLE_PERMISSIONS = {
|
|||||||
"financial.view_invoices",
|
"financial.view_invoices",
|
||||||
"financial.manage_invoices",
|
"financial.manage_invoices",
|
||||||
"financial.view_payments",
|
"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)
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
|
|||||||
|
|
||||||
const checkInDate = watch('checkInDate');
|
const checkInDate = watch('checkInDate');
|
||||||
const checkOutDate = watch('checkOutDate');
|
const checkOutDate = watch('checkOutDate');
|
||||||
|
const guestCount = watch('guestCount');
|
||||||
const paymentMethodForm = watch('paymentMethod');
|
const paymentMethodForm = watch('paymentMethod');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -134,6 +135,26 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
|
|||||||
fetchRoomDetails(roomId);
|
fetchRoomDetails(roomId);
|
||||||
fetchServices();
|
fetchServices();
|
||||||
fetchBookedDates(roomId);
|
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]);
|
}, [isOpen, roomId]);
|
||||||
|
|
||||||
@@ -251,16 +272,29 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
|
|||||||
setPromotionError('Please enter a promotion code');
|
setPromotionError('Please enter a promotion code');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (subtotal === 0) {
|
if (subtotal === 0 || !checkInDate || !checkOutDate) {
|
||||||
setPromotionError('Please select dates first');
|
setPromotionError('Please select dates first');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setValidatingPromotion(true);
|
setValidatingPromotion(true);
|
||||||
setPromotionError(null);
|
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(
|
const response = await promotionService.validatePromotion(
|
||||||
promotionCode.toUpperCase().trim(),
|
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) {
|
if (response.success && response.data) {
|
||||||
setSelectedPromotion(response.data.promotion);
|
setSelectedPromotion(response.data.promotion);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
BannerCarousel,
|
BannerCarousel,
|
||||||
@@ -29,6 +30,7 @@ import type { PageContent } from '../services/pageContentService';
|
|||||||
import type { Service } from '../../hotel_services/services/serviceService';
|
import type { Service } from '../../hotel_services/services/serviceService';
|
||||||
|
|
||||||
const HomePage: React.FC = () => {
|
const HomePage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { formatCurrency } = useFormatCurrency();
|
const { formatCurrency } = useFormatCurrency();
|
||||||
const [banners, setBanners] = useState<Banner[]>([]);
|
const [banners, setBanners] = useState<Banner[]>([]);
|
||||||
const [featuredRooms, setFeaturedRooms] = useState<Room[]>([]);
|
const [featuredRooms, setFeaturedRooms] = useState<Room[]>([]);
|
||||||
@@ -41,7 +43,7 @@ const HomePage: React.FC = () => {
|
|||||||
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
|
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
|
||||||
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
|
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
|
||||||
const [, setIsLoadingContent] = useState(true);
|
const [, setIsLoadingContent] = useState(true);
|
||||||
const [isLoadingServices, setIsLoadingServices] = useState(true);
|
const [, setIsLoadingServices] = useState(true);
|
||||||
const [isLoadingBlog, setIsLoadingBlog] = useState(false);
|
const [isLoadingBlog, setIsLoadingBlog] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [apiError, setApiError] = useState(false);
|
const [apiError, setApiError] = useState(false);
|
||||||
@@ -49,6 +51,7 @@ const HomePage: React.FC = () => {
|
|||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||||
const [lightboxImages, setLightboxImages] = useState<string[]>([]);
|
const [lightboxImages, setLightboxImages] = useState<string[]>([]);
|
||||||
|
const [clickedPromotion, setClickedPromotion] = useState<number | null>(null);
|
||||||
|
|
||||||
// Prevent body scroll when API error modal is shown
|
// Prevent body scroll when API error modal is shown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,6 +107,100 @@ const HomePage: React.FC = () => {
|
|||||||
return Array.from(roomMap.values());
|
return Array.from(roomMap.values());
|
||||||
}, [featuredRooms, newestRooms]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
fetchServices();
|
fetchServices();
|
||||||
@@ -1337,62 +1434,6 @@ const HomePage: React.FC = () => {
|
|||||||
</section>
|
</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 */}
|
{/* Promotions Section */}
|
||||||
{(pageContent?.sections_enabled?.promotions !== false) && pageContent?.promotions_enabled && pageContent?.promotions && Array.isArray(pageContent.promotions) && pageContent.promotions.length > 0 && (
|
{(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">
|
<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>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 px-4">
|
<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) => (
|
{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">
|
const isClicked = clickedPromotion === index;
|
||||||
{promo.image && (
|
const now = new Date();
|
||||||
<div className="relative h-48 overflow-hidden">
|
now.setHours(0, 0, 0, 0);
|
||||||
<img src={promo.image} alt={promo.title} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
|
const validUntilDate = promo.valid_until ? new Date(promo.valid_until) : null;
|
||||||
{promo.discount && (
|
if (validUntilDate) {
|
||||||
<div className="absolute top-4 right-4 bg-red-600 text-white px-4 py-2 rounded-full font-bold text-lg shadow-lg">
|
validUntilDate.setHours(23, 59, 59, 999);
|
||||||
{promo.discount}
|
}
|
||||||
</div>
|
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 || '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 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">
|
||||||
|
<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={`mb-4 line-clamp-3 ${isValid ? 'text-gray-600' : 'text-gray-400'}`}>
|
||||||
|
{promo.description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
{promo.valid_until && (
|
||||||
)}
|
<p className={`text-sm mb-4 ${
|
||||||
<div className="p-6">
|
isValid ? 'text-gray-500' : 'text-red-500 font-medium'
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-2">{promo.title}</h3>
|
}`}>
|
||||||
{promo.description && (
|
{isValid ? 'Valid until:' : 'Expired on:'} {new Date(promo.valid_until).toLocaleDateString('en-US', {
|
||||||
<p className="text-gray-600 mb-4">{promo.description}</p>
|
year: 'numeric',
|
||||||
)}
|
month: 'long',
|
||||||
{promo.valid_until && (
|
day: 'numeric'
|
||||||
<p className="text-sm text-gray-500 mb-4">Valid until: {new Date(promo.valid_until).toLocaleDateString()}</p>
|
})}
|
||||||
)}
|
</p>
|
||||||
{promo.link && (
|
)}
|
||||||
<Link
|
<div
|
||||||
to={promo.link}
|
onClick={(e) => {
|
||||||
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"
|
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'}
|
<span>{isClicked ? 'Loading...' : (promo.button_text || (isValid ? 'View Offer' : 'Expired'))}</span>
|
||||||
<ArrowRight className="w-4 h-4" />
|
{!isClicked && isValid && <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />}
|
||||||
</Link>
|
{isClicked && (
|
||||||
)}
|
<div className="w-4 h-4 border-2 border-[#0f0f0f] border-t-transparent rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ export interface Promotion {
|
|||||||
discount_value: number;
|
discount_value: number;
|
||||||
min_booking_amount?: number;
|
min_booking_amount?: number;
|
||||||
max_discount_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;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
usage_limit?: number;
|
usage_limit?: number;
|
||||||
@@ -41,6 +54,19 @@ export interface CreatePromotionData {
|
|||||||
discount_value: number;
|
discount_value: number;
|
||||||
min_booking_amount?: number;
|
min_booking_amount?: number;
|
||||||
max_discount_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;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
usage_limit?: number;
|
usage_limit?: number;
|
||||||
@@ -55,6 +81,19 @@ export interface UpdatePromotionData {
|
|||||||
discount_value?: number;
|
discount_value?: number;
|
||||||
min_booking_amount?: number;
|
min_booking_amount?: number;
|
||||||
max_discount_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;
|
start_date?: string;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
usage_limit?: number;
|
usage_limit?: number;
|
||||||
@@ -106,11 +145,21 @@ export const deletePromotion = async (
|
|||||||
|
|
||||||
export const validatePromotion = async (
|
export const validatePromotion = async (
|
||||||
code: string,
|
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 }> => {
|
): Promise<{ success: boolean; data: { promotion: Promotion; discount: number }; message: string }> => {
|
||||||
const response = await apiClient.post('/promotions/validate', {
|
const response = await apiClient.post('/promotions/validate', {
|
||||||
code,
|
code,
|
||||||
booking_value: bookingValue,
|
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;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ import { useAsync } from '../../shared/hooks/useAsync';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { logger } from '../../shared/utils/logger';
|
import { logger } from '../../shared/utils/logger';
|
||||||
import { getPaymentStatusColor } from '../../shared/utils/paymentUtils';
|
import { getPaymentStatusColor } from '../../shared/utils/paymentUtils';
|
||||||
|
import MfaRequiredBanner from '../../shared/components/MfaRequiredBanner';
|
||||||
|
import { useMfaStatus } from '../../shared/hooks/useMfaStatus';
|
||||||
|
|
||||||
const AccountantDashboardPage: React.FC = () => {
|
const AccountantDashboardPage: React.FC = () => {
|
||||||
const { formatCurrency } = useFormatCurrency();
|
const { formatCurrency } = useFormatCurrency();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { requiresMfaButNotEnabled } = useMfaStatus();
|
||||||
const [dateRange, setDateRange] = useState({
|
const [dateRange, setDateRange] = useState({
|
||||||
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||||
to: new Date().toISOString().split('T')[0],
|
to: new Date().toISOString().split('T')[0],
|
||||||
@@ -235,6 +238,11 @@ const AccountantDashboardPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
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">
|
<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 */}
|
{/* 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="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">
|
<div className="w-full lg:w-auto">
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Shield, Activity, LogOut, AlertTriangle, CheckCircle2, Clock } from 'lucide-react';
|
import { Shield, Activity, LogOut, AlertTriangle, CheckCircle2, Clock } from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import accountantSecurityService, { AccountantSession, AccountantActivityLog, MFAStatus } from '../../features/security/services/accountantSecurityService';
|
import accountantSecurityService, { AccountantSession, AccountantActivityLog, MFAStatus } from '../../features/security/services/accountantSecurityService';
|
||||||
import Loading from '../../shared/components/Loading';
|
import Loading from '../../shared/components/Loading';
|
||||||
import EmptyState from '../../shared/components/EmptyState';
|
import EmptyState from '../../shared/components/EmptyState';
|
||||||
import { formatDate } from '../../shared/utils/format';
|
import { formatDate } from '../../shared/utils/format';
|
||||||
|
|
||||||
const SecurityManagementPage: React.FC = () => {
|
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 [loading, setLoading] = useState(true);
|
||||||
const [sessions, setSessions] = useState<AccountantSession[]>([]);
|
const [sessions, setSessions] = useState<AccountantSession[]>([]);
|
||||||
const [activityLogs, setActivityLogs] = useState<AccountantActivityLog[]>([]);
|
const [activityLogs, setActivityLogs] = useState<AccountantActivityLog[]>([]);
|
||||||
@@ -20,6 +23,19 @@ const SecurityManagementPage: React.FC = () => {
|
|||||||
else if (activeTab === 'mfa') fetchMFAStatus();
|
else if (activeTab === 'mfa') fetchMFAStatus();
|
||||||
}, [activeTab]);
|
}, [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 () => {
|
const fetchSessions = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -24,8 +24,14 @@ import invoiceService, { Invoice } from '../../features/payments/services/invoic
|
|||||||
import paymentService from '../../features/payments/services/paymentService';
|
import paymentService from '../../features/payments/services/paymentService';
|
||||||
import type { Payment } from '../../features/payments/services/paymentService';
|
import type { Payment } from '../../features/payments/services/paymentService';
|
||||||
import promotionService, { Promotion } from '../../features/loyalty/services/promotionService';
|
import promotionService, { Promotion } from '../../features/loyalty/services/promotionService';
|
||||||
|
import { getRoomTypes } from '../../features/rooms/services/roomService';
|
||||||
import { formatDate } from '../../shared/utils/format';
|
import { formatDate } from '../../shared/utils/format';
|
||||||
|
|
||||||
|
interface RoomType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
type BusinessTab = 'overview' | 'invoices' | 'payments' | 'promotions';
|
type BusinessTab = 'overview' | 'invoices' | 'payments' | 'promotions';
|
||||||
|
|
||||||
const BusinessDashboardPage: React.FC = () => {
|
const BusinessDashboardPage: React.FC = () => {
|
||||||
@@ -83,11 +89,26 @@ const BusinessDashboardPage: React.FC = () => {
|
|||||||
discount_value: 0,
|
discount_value: 0,
|
||||||
min_booking_amount: 0,
|
min_booking_amount: 0,
|
||||||
max_discount_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: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
usage_limit: 0,
|
usage_limit: 0,
|
||||||
status: 'active' as 'active' | 'inactive' | 'expired',
|
status: 'active' as 'active' | 'inactive' | 'expired',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [roomTypes, setRoomTypes] = useState<RoomType[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'invoices') {
|
if (activeTab === 'invoices') {
|
||||||
@@ -99,6 +120,21 @@ const BusinessDashboardPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [activeTab, invoiceFilters, invoicesCurrentPage, paymentFilters, paymentsCurrentPage, promotionFilters, promotionsCurrentPage]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'invoices') {
|
if (activeTab === 'invoices') {
|
||||||
setInvoicesCurrentPage(1);
|
setInvoicesCurrentPage(1);
|
||||||
@@ -280,11 +316,30 @@ const BusinessDashboardPage: React.FC = () => {
|
|||||||
const handlePromotionSubmit = async (e: React.FormEvent) => {
|
const handlePromotionSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
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) {
|
if (editingPromotion) {
|
||||||
await promotionService.updatePromotion(editingPromotion.id, promotionFormData);
|
await promotionService.updatePromotion(editingPromotion.id, submitData);
|
||||||
toast.success('Promotion updated successfully');
|
toast.success('Promotion updated successfully');
|
||||||
} else {
|
} else {
|
||||||
await promotionService.createPromotion(promotionFormData);
|
await promotionService.createPromotion(submitData);
|
||||||
toast.success('Promotion added successfully');
|
toast.success('Promotion added successfully');
|
||||||
}
|
}
|
||||||
setShowPromotionModal(false);
|
setShowPromotionModal(false);
|
||||||
@@ -305,6 +360,19 @@ const BusinessDashboardPage: React.FC = () => {
|
|||||||
discount_value: promotion.discount_value,
|
discount_value: promotion.discount_value,
|
||||||
min_booking_amount: promotion.min_booking_amount || 0,
|
min_booking_amount: promotion.min_booking_amount || 0,
|
||||||
max_discount_amount: promotion.max_discount_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] || '',
|
start_date: promotion.start_date?.split('T')[0] || '',
|
||||||
end_date: promotion.end_date?.split('T')[0] || '',
|
end_date: promotion.end_date?.split('T')[0] || '',
|
||||||
usage_limit: promotion.usage_limit || 0,
|
usage_limit: promotion.usage_limit || 0,
|
||||||
@@ -335,6 +403,19 @@ const BusinessDashboardPage: React.FC = () => {
|
|||||||
discount_value: 0,
|
discount_value: 0,
|
||||||
min_booking_amount: 0,
|
min_booking_amount: 0,
|
||||||
max_discount_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: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
usage_limit: 0,
|
usage_limit: 0,
|
||||||
@@ -1041,36 +1122,37 @@ const BusinessDashboardPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{}
|
{}
|
||||||
{showPromotionModal && (
|
{showPromotionModal && (
|
||||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-4">
|
<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-start justify-center py-8">
|
<div className="min-h-full flex items-center justify-center py-4">
|
||||||
<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-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="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex justify-between items-center">
|
<div>
|
||||||
<div>
|
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-purple-100 mb-1">
|
||||||
<h2 className="text-3xl font-bold text-purple-100 mb-1">
|
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
|
||||||
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
|
</h2>
|
||||||
</h2>
|
<p className="text-purple-200/80 text-xs sm:text-sm font-light">
|
||||||
<p className="text-purple-200/80 text-sm font-light">
|
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
|
||||||
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
|
</p>
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<X className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
{}
|
onClick={() => setShowPromotionModal(false)}
|
||||||
<div className="p-8 overflow-y-auto max-h-[calc(100vh-12rem)] custom-scrollbar">
|
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"
|
||||||
<form onSubmit={handlePromotionSubmit} className="space-y-6">
|
>
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<X className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
||||||
Code <span className="text-red-500">*</span>
|
Code <span className="text-red-500">*</span>
|
||||||
@@ -1169,6 +1251,258 @@ const BusinessDashboardPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 Example: 2024-12-25 2024-12-31 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 className="grid grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
<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>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPromotionModal(false)}
|
onClick={() => setShowPromotionModal(false)}
|
||||||
@@ -1239,15 +1573,12 @@ const BusinessDashboardPage: React.FC = () => {
|
|||||||
{editingPromotion ? 'Update' : 'Create'}
|
{editingPromotion ? 'Update' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2895,167 +2895,6 @@ const PageContentDashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Promotions Section */}
|
||||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
<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">
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Plus, Search, Edit, Trash2, X, Tag } from 'lucide-react';
|
import { Plus, Search, Edit, Trash2, X, Tag } from 'lucide-react';
|
||||||
import promotionService, { Promotion } from '../../features/loyalty/services/promotionService';
|
import promotionService, { Promotion } from '../../features/loyalty/services/promotionService';
|
||||||
|
import { getRoomTypes } from '../../features/rooms/services/roomService';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import Loading from '../../shared/components/Loading';
|
import Loading from '../../shared/components/Loading';
|
||||||
import Pagination from '../../shared/components/Pagination';
|
import Pagination from '../../shared/components/Pagination';
|
||||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||||
import { useCurrency } from '../../features/payments/contexts/CurrencyContext';
|
import { useCurrency } from '../../features/payments/contexts/CurrencyContext';
|
||||||
|
|
||||||
|
interface RoomType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
const PromotionManagementPage: React.FC = () => {
|
const PromotionManagementPage: React.FC = () => {
|
||||||
const { currency } = useCurrency();
|
const { currency } = useCurrency();
|
||||||
const { formatCurrency } = useFormatCurrency();
|
const { formatCurrency } = useFormatCurrency();
|
||||||
@@ -32,11 +38,27 @@ const PromotionManagementPage: React.FC = () => {
|
|||||||
discount_value: 0,
|
discount_value: 0,
|
||||||
min_booking_amount: 0,
|
min_booking_amount: 0,
|
||||||
max_discount_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: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
usage_limit: 0,
|
usage_limit: 0,
|
||||||
status: 'active' as 'active' | 'inactive' | 'expired',
|
status: 'active' as 'active' | 'inactive' | 'expired',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [roomTypes, setRoomTypes] = useState<RoomType[]>([]);
|
||||||
|
const [loadingRoomTypes, setLoadingRoomTypes] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
@@ -46,6 +68,24 @@ const PromotionManagementPage: React.FC = () => {
|
|||||||
fetchPromotions();
|
fetchPromotions();
|
||||||
}, [filters, currentPage]);
|
}, [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 () => {
|
const fetchPromotions = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -69,11 +109,30 @@ const PromotionManagementPage: React.FC = () => {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
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) {
|
if (editingPromotion) {
|
||||||
await promotionService.updatePromotion(editingPromotion.id, formData);
|
await promotionService.updatePromotion(editingPromotion.id, submitData);
|
||||||
toast.success('Promotion updated successfully');
|
toast.success('Promotion updated successfully');
|
||||||
} else {
|
} else {
|
||||||
await promotionService.createPromotion(formData);
|
await promotionService.createPromotion(submitData);
|
||||||
toast.success('Promotion added successfully');
|
toast.success('Promotion added successfully');
|
||||||
}
|
}
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
@@ -94,6 +153,19 @@ const PromotionManagementPage: React.FC = () => {
|
|||||||
discount_value: promotion.discount_value,
|
discount_value: promotion.discount_value,
|
||||||
min_booking_amount: promotion.min_booking_amount || 0,
|
min_booking_amount: promotion.min_booking_amount || 0,
|
||||||
max_discount_amount: promotion.max_discount_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] || '',
|
start_date: promotion.start_date?.split('T')[0] || '',
|
||||||
end_date: promotion.end_date?.split('T')[0] || '',
|
end_date: promotion.end_date?.split('T')[0] || '',
|
||||||
usage_limit: promotion.usage_limit || 0,
|
usage_limit: promotion.usage_limit || 0,
|
||||||
@@ -124,6 +196,19 @@ const PromotionManagementPage: React.FC = () => {
|
|||||||
discount_value: 0,
|
discount_value: 0,
|
||||||
min_booking_amount: 0,
|
min_booking_amount: 0,
|
||||||
max_discount_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: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
usage_limit: 0,
|
usage_limit: 0,
|
||||||
@@ -315,32 +400,30 @@ const PromotionManagementPage: React.FC = () => {
|
|||||||
|
|
||||||
{}
|
{}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-4 animate-fade-in">
|
<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-start justify-center py-8">
|
<div className="min-h-full flex items-center justify-center py-4 sm: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-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="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex justify-between items-center">
|
<div>
|
||||||
<div>
|
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-amber-100 mb-1">
|
||||||
<h2 className="text-3xl font-bold text-amber-100 mb-1">
|
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
|
||||||
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
|
</h2>
|
||||||
</h2>
|
<p className="text-amber-200/80 text-xs sm:text-sm font-light">
|
||||||
<p className="text-amber-200/80 text-sm font-light">
|
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
|
||||||
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
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-5 h-5 sm:w-6 sm:h-6" />
|
||||||
|
</button>
|
||||||
</div>
|
</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"
|
|
||||||
>
|
|
||||||
<X className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<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="p-8 overflow-y-auto max-h-[calc(100vh-12rem)] custom-scrollbar">
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="grid grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
<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>
|
||||||
</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 Example: 2024-12-25 2024-12-31 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 className="grid grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
<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>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowModal(false)}
|
onClick={() => setShowModal(false)}
|
||||||
@@ -514,7 +849,6 @@ const PromotionManagementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import RoomFilter from '../../features/rooms/components/RoomFilter';
|
|||||||
import RoomCard from '../../features/rooms/components/RoomCard';
|
import RoomCard from '../../features/rooms/components/RoomCard';
|
||||||
import RoomCardSkeleton from '../../features/rooms/components/RoomCardSkeleton';
|
import RoomCardSkeleton from '../../features/rooms/components/RoomCardSkeleton';
|
||||||
import Pagination from '../../shared/components/Pagination';
|
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 { logger } from '../../shared/utils/logger';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
const RoomListPage: React.FC = () => {
|
const RoomListPage: React.FC = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -23,6 +24,45 @@ const RoomListPage: React.FC = () => {
|
|||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
});
|
});
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -108,6 +148,44 @@ const RoomListPage: React.FC = () => {
|
|||||||
return (
|
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' }}>
|
<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 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">
|
<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">
|
||||||
{}
|
{}
|
||||||
|
|||||||
102
Frontend/src/shared/components/MfaRequiredBanner.tsx
Normal file
102
Frontend/src/shared/components/MfaRequiredBanner.tsx
Normal 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;
|
||||||
52
Frontend/src/shared/hooks/useMfaStatus.ts
Normal file
52
Frontend/src/shared/hooks/useMfaStatus.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
235
Frontend/src/shared/hooks/usePermissions.ts
Normal file
235
Frontend/src/shared/hooks/usePermissions.ts
Normal 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';
|
||||||
|
};
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
import useAuthStore from '../../store/useAuthStore';
|
import useAuthStore from '../../store/useAuthStore';
|
||||||
import { logSecurityWarning, logDebug, logWarn } from '../utils/errorReporter';
|
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 errorMessage = 'You do not have permission to access this resource.';
|
||||||
let shouldRetry = false;
|
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.';
|
errorMessage = 'Security validation failed. Please refresh the page and try again.';
|
||||||
shouldRetry = true;
|
shouldRetry = true;
|
||||||
} else if (rawMessage.toLowerCase().includes('forbidden') || rawMessage.toLowerCase().includes('permission')) {
|
} else if (rawMessage.toLowerCase().includes('forbidden') || rawMessage.toLowerCase().includes('permission')) {
|
||||||
@@ -271,6 +326,7 @@ apiClient.interceptors.response.use(
|
|||||||
url: originalRequest?.url,
|
url: originalRequest?.url,
|
||||||
method: originalRequest?.method,
|
method: originalRequest?.method,
|
||||||
rawMessage: rawMessage.substring(0, 100), // Limit length
|
rawMessage: rawMessage.substring(0, 100), // Limit length
|
||||||
|
isMfaRequired,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
176
Frontend/src/shared/utils/errorHandler.ts
Normal file
176
Frontend/src/shared/utils/errorHandler.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
||||||
144
Frontend/src/shared/utils/permissionHelpers.ts
Normal file
144
Frontend/src/shared/utils/permissionHelpers.ts
Normal 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);
|
||||||
|
};
|
||||||
|
|
||||||
131
Frontend/src/shared/utils/sanitize.ts
Normal file
131
Frontend/src/shared/utils/sanitize.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
|
|
||||||
Reference in New Issue
Block a user