This commit is contained in:
Iliyan Angelov
2025-12-05 17:43:03 +02:00
parent e1988fe37a
commit 13c91f95f4
51 changed files with 11933 additions and 289 deletions

View File

View File

@@ -0,0 +1,55 @@
"""add_newsletter_subscribers_table
Revision ID: 316d876b1b71
Revises: add_enterprise_homepage_fields
Create Date: 2025-12-05 15:33:09.120967
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '316d876b1b71'
down_revision = 'add_enterprise_homepage_fields'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Check if table exists
from sqlalchemy import inspect
conn = op.get_bind()
inspector = inspect(conn)
tables = inspector.get_table_names()
if 'newsletter_subscribers' not in tables:
# Create newsletter_subscribers table
op.create_table(
'newsletter_subscribers',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('email', sa.String(255), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('name', sa.String(255), nullable=True),
sa.Column('subscribed_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_index(op.f('ix_newsletter_subscribers_email'), 'newsletter_subscribers', ['email'], unique=False)
op.create_index(op.f('ix_newsletter_subscribers_id'), 'newsletter_subscribers', ['id'], unique=False)
op.create_index(op.f('ix_newsletter_subscribers_is_active'), 'newsletter_subscribers', ['is_active'], unique=False)
op.create_index(op.f('ix_newsletter_subscribers_subscribed_at'), 'newsletter_subscribers', ['subscribed_at'], unique=False)
op.create_index(op.f('ix_newsletter_subscribers_user_id'), 'newsletter_subscribers', ['user_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_newsletter_subscribers_user_id'), table_name='newsletter_subscribers')
op.drop_index(op.f('ix_newsletter_subscribers_subscribed_at'), table_name='newsletter_subscribers')
op.drop_index(op.f('ix_newsletter_subscribers_is_active'), table_name='newsletter_subscribers')
op.drop_index(op.f('ix_newsletter_subscribers_id'), table_name='newsletter_subscribers')
op.drop_index(op.f('ix_newsletter_subscribers_email'), table_name='newsletter_subscribers')
op.drop_table('newsletter_subscribers')

View File

@@ -0,0 +1,40 @@
"""add_recipient_type_to_campaigns
Revision ID: 87e29a777cb3
Revises: 316d876b1b71
Create Date: 2025-12-05 15:36:11.095669
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '87e29a777cb3'
down_revision = '316d876b1b71'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add recipient_type column to email_campaigns table
from sqlalchemy import inspect
conn = op.get_bind()
inspector = inspect(conn)
# Check if column exists
columns = [col['name'] for col in inspector.get_columns('email_campaigns')]
if 'recipient_type' not in columns:
op.add_column('email_campaigns', sa.Column('recipient_type', sa.String(50), nullable=True, server_default='users'))
def downgrade() -> None:
# Remove recipient_type column
from sqlalchemy import inspect
conn = op.get_bind()
inspector = inspect(conn)
columns = [col['name'] for col in inspector.get_columns('email_campaigns')]
if 'recipient_type' in columns:
op.drop_column('email_campaigns', 'recipient_type')

View File

@@ -0,0 +1,127 @@
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
revision = 'add_enterprise_homepage_fields'
down_revision = 'service_detail_001' # Latest migration head
branch_labels = None
depends_on = None
def column_exists(table_name, column_name, connection):
"""Check if a column exists in a table."""
inspector = inspect(connection)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
connection = op.get_bind()
# Helper function to add column if it doesn't exist
def add_column_if_not_exists(table_name, column, column_name):
if not column_exists(table_name, column_name, connection):
op.add_column(table_name, column)
# Hero video fields - use TEXT to avoid row size limit
add_column_if_not_exists('page_contents', sa.Column('hero_video_url', sa.Text(), nullable=True), 'hero_video_url')
add_column_if_not_exists('page_contents', sa.Column('hero_video_poster', sa.Text(), nullable=True), 'hero_video_poster')
# Features section fields - use TEXT for subtitles
add_column_if_not_exists('page_contents', sa.Column('features_section_title', sa.Text(), nullable=True), 'features_section_title')
add_column_if_not_exists('page_contents', sa.Column('features_section_subtitle', sa.Text(), nullable=True), 'features_section_subtitle')
# Stats section fields - use TEXT for subtitles
add_column_if_not_exists('page_contents', sa.Column('stats_section_title', sa.Text(), nullable=True), 'stats_section_title')
add_column_if_not_exists('page_contents', sa.Column('stats_section_subtitle', sa.Text(), nullable=True), 'stats_section_subtitle')
# Rooms section fields - use TEXT for all string fields
add_column_if_not_exists('page_contents', sa.Column('rooms_section_title', sa.Text(), nullable=True), 'rooms_section_title')
add_column_if_not_exists('page_contents', sa.Column('rooms_section_subtitle', sa.Text(), nullable=True), 'rooms_section_subtitle')
add_column_if_not_exists('page_contents', sa.Column('rooms_section_button_text', sa.Text(), nullable=True), 'rooms_section_button_text')
add_column_if_not_exists('page_contents', sa.Column('rooms_section_button_link', sa.Text(), nullable=True), 'rooms_section_button_link')
add_column_if_not_exists('page_contents', sa.Column('rooms_section_enabled', sa.Boolean(), nullable=True, server_default='1'), 'rooms_section_enabled')
# Services section fields - use TEXT for all string fields
add_column_if_not_exists('page_contents', sa.Column('services_section_button_text', sa.Text(), nullable=True), 'services_section_button_text')
add_column_if_not_exists('page_contents', sa.Column('services_section_button_link', sa.Text(), nullable=True), 'services_section_button_link')
add_column_if_not_exists('page_contents', sa.Column('services_section_limit', sa.Integer(), nullable=True), 'services_section_limit')
# Sections enabled (JSON field for toggling sections)
add_column_if_not_exists('page_contents', sa.Column('sections_enabled', sa.Text(), nullable=True), 'sections_enabled')
# Newsletter section fields - use TEXT for all string fields
add_column_if_not_exists('page_contents', sa.Column('newsletter_section_title', sa.Text(), nullable=True), 'newsletter_section_title')
add_column_if_not_exists('page_contents', sa.Column('newsletter_section_subtitle', sa.Text(), nullable=True), 'newsletter_section_subtitle')
add_column_if_not_exists('page_contents', sa.Column('newsletter_placeholder', sa.Text(), nullable=True), 'newsletter_placeholder')
add_column_if_not_exists('page_contents', sa.Column('newsletter_button_text', sa.Text(), nullable=True), 'newsletter_button_text')
add_column_if_not_exists('page_contents', sa.Column('newsletter_enabled', sa.Boolean(), nullable=True, server_default='0'), 'newsletter_enabled')
# Trust badges section fields - use TEXT for subtitles
add_column_if_not_exists('page_contents', sa.Column('trust_badges_section_title', sa.Text(), nullable=True), 'trust_badges_section_title')
add_column_if_not_exists('page_contents', sa.Column('trust_badges_section_subtitle', sa.Text(), nullable=True), 'trust_badges_section_subtitle')
add_column_if_not_exists('page_contents', sa.Column('trust_badges', sa.Text(), nullable=True), 'trust_badges')
add_column_if_not_exists('page_contents', sa.Column('trust_badges_enabled', sa.Boolean(), nullable=True, server_default='0'), 'trust_badges_enabled')
# Promotions section fields - use TEXT for subtitles
add_column_if_not_exists('page_contents', sa.Column('promotions_section_title', sa.Text(), nullable=True), 'promotions_section_title')
add_column_if_not_exists('page_contents', sa.Column('promotions_section_subtitle', sa.Text(), nullable=True), 'promotions_section_subtitle')
add_column_if_not_exists('page_contents', sa.Column('promotions', sa.Text(), nullable=True), 'promotions')
add_column_if_not_exists('page_contents', sa.Column('promotions_enabled', sa.Boolean(), nullable=True, server_default='0'), 'promotions_enabled')
# Blog section fields - use TEXT for subtitles
add_column_if_not_exists('page_contents', sa.Column('blog_section_title', sa.Text(), nullable=True), 'blog_section_title')
add_column_if_not_exists('page_contents', sa.Column('blog_section_subtitle', sa.Text(), nullable=True), 'blog_section_subtitle')
add_column_if_not_exists('page_contents', sa.Column('blog_posts_limit', sa.Integer(), nullable=True), 'blog_posts_limit')
add_column_if_not_exists('page_contents', sa.Column('blog_enabled', sa.Boolean(), nullable=True, server_default='0'), 'blog_enabled')
def downgrade() -> None:
# Blog section fields
op.drop_column('page_contents', 'blog_enabled')
op.drop_column('page_contents', 'blog_posts_limit')
op.drop_column('page_contents', 'blog_section_subtitle')
op.drop_column('page_contents', 'blog_section_title')
# Promotions section fields
op.drop_column('page_contents', 'promotions_enabled')
op.drop_column('page_contents', 'promotions')
op.drop_column('page_contents', 'promotions_section_subtitle')
op.drop_column('page_contents', 'promotions_section_title')
# Trust badges section fields
op.drop_column('page_contents', 'trust_badges_enabled')
op.drop_column('page_contents', 'trust_badges')
op.drop_column('page_contents', 'trust_badges_section_subtitle')
op.drop_column('page_contents', 'trust_badges_section_title')
# Newsletter section fields
op.drop_column('page_contents', 'newsletter_enabled')
op.drop_column('page_contents', 'newsletter_button_text')
op.drop_column('page_contents', 'newsletter_placeholder')
op.drop_column('page_contents', 'newsletter_section_subtitle')
op.drop_column('page_contents', 'newsletter_section_title')
# Sections enabled
op.drop_column('page_contents', 'sections_enabled')
# Services section fields
op.drop_column('page_contents', 'services_section_limit')
op.drop_column('page_contents', 'services_section_button_link')
op.drop_column('page_contents', 'services_section_button_text')
# Rooms section fields
op.drop_column('page_contents', 'rooms_section_enabled')
op.drop_column('page_contents', 'rooms_section_button_link')
op.drop_column('page_contents', 'rooms_section_button_text')
op.drop_column('page_contents', 'rooms_section_subtitle')
op.drop_column('page_contents', 'rooms_section_title')
# Stats section fields
op.drop_column('page_contents', 'stats_section_subtitle')
op.drop_column('page_contents', 'stats_section_title')
# Features section fields
op.drop_column('page_contents', 'features_section_subtitle')
op.drop_column('page_contents', 'features_section_title')
# Hero video fields
op.drop_column('page_contents', 'hero_video_poster')
op.drop_column('page_contents', 'hero_video_url')

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -74,7 +74,206 @@ def seed_homepage_content(db: Session):
features = [{'icon': 'Shield', 'title': 'Secure & Safe', 'description': '24/7 security and state-of-the-art safety systems'}, {'icon': 'Wifi', 'title': 'Free WiFi', 'description': 'High-speed internet access throughout the property'}, {'icon': 'Coffee', 'title': 'Room Service', 'description': '24/7 room service available for your convenience'}, {'icon': 'Car', 'title': 'Parking', 'description': 'Complimentary valet parking for all guests'}, {'icon': 'UtensilsCrossed', 'title': 'Fine Dining', 'description': 'World-class restaurants and dining experiences'}, {'icon': 'Dumbbell', 'title': 'Fitness Center', 'description': 'State-of-the-art fitness facilities'}]
testimonials = [{'name': 'Robert Martinez', 'role': 'CEO, Tech Corp', 'image': 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=200', 'rating': 5, 'comment': 'Exceptional service and attention to detail. The staff went above and beyond to make our stay memorable.'}, {'name': 'Lisa Anderson', 'role': 'Travel Blogger', 'image': 'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?w=200', 'rating': 5, 'comment': "The most luxurious hotel experience I've ever had. Every detail was perfect."}, {'name': 'David Thompson', 'role': 'Investment Banker', 'image': 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200', 'rating': 5, 'comment': 'Outstanding facilities and impeccable service. Highly recommend for business travelers.'}]
gallery_images = ['https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=800', 'https://images.unsplash.com/photo-1590490360182-c33d57733427?w=800', 'https://images.unsplash.com/photo-1551882547-ff40c63fe5fa?w=800', 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=800', 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800', 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800']
homepage_data = {'page_type': PageType.HOME, 'title': 'Luxury Hotel - Experience Unparalleled Elegance', 'subtitle': 'Where timeless luxury meets modern sophistication', 'description': 'Discover a world of refined elegance and exceptional service', 'hero_title': 'Welcome to Luxury', 'hero_subtitle': 'Experience the pinnacle of hospitality', 'hero_image': 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1200', 'features': json.dumps(features), 'luxury_section_title': 'Experience Unparalleled Luxury', 'luxury_section_subtitle': 'Where elegance meets comfort in every detail', 'luxury_section_image': 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=1200', 'luxury_features': json.dumps(luxury_features), 'luxury_gallery_section_title': 'Our Luxury Gallery', 'luxury_gallery_section_subtitle': 'A glimpse into our world of elegance', 'luxury_gallery': json.dumps(luxury_gallery), 'luxury_testimonials_section_title': 'What Our Guests Say', 'luxury_testimonials_section_subtitle': 'Testimonials from our valued guests', 'luxury_testimonials': json.dumps(luxury_testimonials), 'luxury_services_section_title': 'Premium Services', 'luxury_services_section_subtitle': 'Indulge in our world-class amenities', 'luxury_services': json.dumps(luxury_services), 'luxury_experiences_section_title': 'Exclusive Experiences', 'luxury_experiences_section_subtitle': 'Create unforgettable memories', 'luxury_experiences': json.dumps(luxury_experiences), 'awards_section_title': 'Awards & Recognition', 'awards_section_subtitle': 'Recognized for excellence worldwide', 'awards': json.dumps(awards), 'partners_section_title': 'Our Partners', 'partners_section_subtitle': 'Trusted by leading brands', 'partners': json.dumps(partners), 'amenities_section_title': 'Premium Amenities', 'amenities_section_subtitle': 'Everything you need for a perfect stay', 'amenities': json.dumps(amenities), 'testimonials_section_title': 'Guest Reviews', 'testimonials_section_subtitle': 'Hear from our satisfied guests', 'testimonials': json.dumps(testimonials), 'gallery_section_title': 'Photo Gallery', 'gallery_section_subtitle': 'Explore our beautiful spaces', 'gallery_images': json.dumps(gallery_images), 'about_preview_title': 'About Our Luxury Hotel', 'about_preview_subtitle': 'A legacy of excellence', 'about_preview_content': 'Discover a world of refined elegance and exceptional service. Our hotel combines timeless luxury with modern amenities to create an unforgettable experience. With over 50,000 satisfied guests and numerous awards, we continue to set the standard for luxury hospitality.', 'about_preview_image': 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800', 'stats': json.dumps(stats), 'cta_title': 'Ready to Experience Luxury?', 'cta_subtitle': 'Book your stay today and discover the difference', 'cta_button_text': 'Book Now', 'cta_button_link': '/rooms', 'cta_image': 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1200', 'is_active': True}
# Enterprise homepage fields
hero_video_url = 'https://videos.unsplash.com/video-1564501049412-61c2a3083791'
hero_video_poster = 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1200'
features_section_title = 'Why Choose Us'
features_section_subtitle = 'Discover what makes us the perfect choice for your stay'
stats_section_title = 'Our Achievements'
stats_section_subtitle = 'Numbers that speak for themselves'
rooms_section_title = 'Luxurious Rooms & Suites'
rooms_section_subtitle = 'Experience comfort and elegance in every room'
rooms_section_button_text = 'View All Rooms'
rooms_section_button_link = '/rooms'
rooms_section_enabled = True
services_section_button_text = 'Explore All Services'
services_section_button_link = '/services'
services_section_limit = 4
# Sections enabled configuration
sections_enabled = {
'features': True,
'luxury': True,
'gallery': True,
'testimonials': True,
'stats': True,
'amenities': True,
'about_preview': True,
'services': True,
'experiences': True,
'awards': True,
'cta': True,
'partners': True,
'rooms': True,
'newsletter': True,
'trust_badges': True,
'promotions': True,
'blog': True
}
# Newsletter section
newsletter_section_title = 'Stay Connected'
newsletter_section_subtitle = 'Subscribe to our newsletter for exclusive offers and updates'
newsletter_placeholder = 'Enter your email address'
newsletter_button_text = 'Subscribe'
newsletter_enabled = True
# Trust badges
trust_badges = [
{
'name': '5-Star Rating',
'logo': 'https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?w=200',
'description': 'Awarded 5 stars by leading travel organizations',
'link': '#'
},
{
'name': 'TripAdvisor Excellence',
'logo': 'https://images.unsplash.com/photo-1606761568499-6d2451b23c66?w=200',
'description': 'Certificate of Excellence winner',
'link': '#'
},
{
'name': 'Green Certified',
'logo': 'https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=200',
'description': 'Eco-friendly and sustainable practices',
'link': '#'
},
{
'name': 'Luxury Collection',
'logo': 'https://images.unsplash.com/photo-1599305445671-ac291c95aaa9?w=200',
'description': 'Member of the world\'s finest luxury hotels',
'link': '#'
}
]
trust_badges_section_title = 'Trusted & Recognized'
trust_badges_section_subtitle = 'Awards and certifications that validate our commitment to excellence'
trust_badges_enabled = True
# Promotions
promotions = [
{
'title': 'Summer Special',
'description': 'Enjoy 25% off on all room bookings this summer. Limited time offer!',
'image': 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=600',
'discount': '25% OFF',
'valid_until': '2024-08-31',
'link': '/rooms',
'button_text': 'Book Now'
},
{
'title': 'Weekend Getaway',
'description': 'Perfect weekend escape with complimentary breakfast and spa access',
'image': 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=600',
'discount': '30% OFF',
'valid_until': '2024-12-31',
'link': '/rooms',
'button_text': 'Learn More'
},
{
'title': 'Honeymoon Package',
'description': 'Romantic getaway with champagne, flowers, and special amenities',
'image': 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=600',
'discount': 'Special Rate',
'valid_until': '2024-12-31',
'link': '/rooms',
'button_text': 'Book Package'
}
]
promotions_section_title = 'Special Offers'
promotions_section_subtitle = 'Exclusive deals and packages designed just for you'
promotions_enabled = True
# Blog section
blog_section_title = 'Latest News & Updates'
blog_section_subtitle = 'Stay informed about our latest news, events, and travel tips'
blog_posts_limit = 3
blog_enabled = True
homepage_data = {
'page_type': PageType.HOME,
'title': 'Luxury Hotel - Experience Unparalleled Elegance',
'subtitle': 'Where timeless luxury meets modern sophistication',
'description': 'Discover a world of refined elegance and exceptional service',
'hero_title': 'Welcome to Luxury',
'hero_subtitle': 'Experience the pinnacle of hospitality',
'hero_image': 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1200',
'hero_video_url': hero_video_url,
'hero_video_poster': hero_video_poster,
'features': json.dumps(features),
'features_section_title': features_section_title,
'features_section_subtitle': features_section_subtitle,
'luxury_section_title': 'Experience Unparalleled Luxury',
'luxury_section_subtitle': 'Where elegance meets comfort in every detail',
'luxury_section_image': 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=1200',
'luxury_features': json.dumps(luxury_features),
'luxury_gallery_section_title': 'Our Luxury Gallery',
'luxury_gallery_section_subtitle': 'A glimpse into our world of elegance',
'luxury_gallery': json.dumps(luxury_gallery),
'luxury_testimonials_section_title': 'What Our Guests Say',
'luxury_testimonials_section_subtitle': 'Testimonials from our valued guests',
'luxury_testimonials': json.dumps(luxury_testimonials),
'luxury_services_section_title': 'Premium Services',
'luxury_services_section_subtitle': 'Indulge in our world-class amenities',
'luxury_services': json.dumps(luxury_services),
'services_section_button_text': services_section_button_text,
'services_section_button_link': services_section_button_link,
'services_section_limit': services_section_limit,
'luxury_experiences_section_title': 'Exclusive Experiences',
'luxury_experiences_section_subtitle': 'Create unforgettable memories',
'luxury_experiences': json.dumps(luxury_experiences),
'awards_section_title': 'Awards & Recognition',
'awards_section_subtitle': 'Recognized for excellence worldwide',
'awards': json.dumps(awards),
'partners_section_title': 'Our Partners',
'partners_section_subtitle': 'Trusted by leading brands',
'partners': json.dumps(partners),
'amenities_section_title': 'Premium Amenities',
'amenities_section_subtitle': 'Everything you need for a perfect stay',
'amenities': json.dumps(amenities),
'testimonials_section_title': 'Guest Reviews',
'testimonials_section_subtitle': 'Hear from our satisfied guests',
'testimonials': json.dumps(testimonials),
'gallery_section_title': 'Photo Gallery',
'gallery_section_subtitle': 'Explore our beautiful spaces',
'gallery_images': json.dumps(gallery_images),
'about_preview_title': 'About Our Luxury Hotel',
'about_preview_subtitle': 'A legacy of excellence',
'about_preview_content': 'Discover a world of refined elegance and exceptional service. Our hotel combines timeless luxury with modern amenities to create an unforgettable experience. With over 50,000 satisfied guests and numerous awards, we continue to set the standard for luxury hospitality.',
'about_preview_image': 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800',
'stats': json.dumps(stats),
'stats_section_title': stats_section_title,
'stats_section_subtitle': stats_section_subtitle,
'rooms_section_title': rooms_section_title,
'rooms_section_subtitle': rooms_section_subtitle,
'rooms_section_button_text': rooms_section_button_text,
'rooms_section_button_link': rooms_section_button_link,
'rooms_section_enabled': rooms_section_enabled,
'sections_enabled': json.dumps(sections_enabled),
'newsletter_section_title': newsletter_section_title,
'newsletter_section_subtitle': newsletter_section_subtitle,
'newsletter_placeholder': newsletter_placeholder,
'newsletter_button_text': newsletter_button_text,
'newsletter_enabled': newsletter_enabled,
'trust_badges_section_title': trust_badges_section_title,
'trust_badges_section_subtitle': trust_badges_section_subtitle,
'trust_badges': json.dumps(trust_badges),
'trust_badges_enabled': trust_badges_enabled,
'promotions_section_title': promotions_section_title,
'promotions_section_subtitle': promotions_section_subtitle,
'promotions': json.dumps(promotions),
'promotions_enabled': promotions_enabled,
'blog_section_title': blog_section_title,
'blog_section_subtitle': blog_section_subtitle,
'blog_posts_limit': blog_posts_limit,
'blog_enabled': blog_enabled,
'cta_title': 'Ready to Experience Luxury?',
'cta_subtitle': 'Book your stay today and discover the difference',
'cta_button_text': 'Book Now',
'cta_button_link': '/rooms',
'cta_image': 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1200',
'is_active': True
}
if existing:
for key, value in homepage_data.items():
if key != 'page_type':

View File

@@ -41,9 +41,13 @@ class PageContent(Base):
hero_title = Column(String(500), nullable=True)
hero_subtitle = Column(String(1000), nullable=True)
hero_image = Column(String(1000), nullable=True)
hero_video_url = Column(Text, nullable=True)
hero_video_poster = Column(Text, nullable=True)
story_content = Column(Text, nullable=True)
values = Column(Text, nullable=True)
features = Column(Text, nullable=True)
features_section_title = Column(Text, nullable=True)
features_section_subtitle = Column(Text, nullable=True)
about_hero_image = Column(Text, nullable=True)
mission = Column(Text, nullable=True)
vision = Column(Text, nullable=True)
@@ -74,8 +78,18 @@ class PageContent(Base):
about_preview_content = Column(Text, nullable=True)
about_preview_image = Column(String(1000), nullable=True)
stats = Column(Text, nullable=True)
stats_section_title = Column(Text, nullable=True)
stats_section_subtitle = Column(Text, nullable=True)
rooms_section_title = Column(Text, nullable=True)
rooms_section_subtitle = Column(Text, nullable=True)
rooms_section_button_text = Column(Text, nullable=True)
rooms_section_button_link = Column(Text, nullable=True)
rooms_section_enabled = Column(Boolean, default=True, nullable=True)
luxury_services_section_title = Column(Text, nullable=True)
luxury_services_section_subtitle = Column(Text, nullable=True)
services_section_button_text = Column(Text, nullable=True)
services_section_button_link = Column(Text, nullable=True)
services_section_limit = Column(Integer, nullable=True)
luxury_services = Column(Text, nullable=True)
luxury_experiences_section_title = Column(Text, nullable=True)
luxury_experiences_section_subtitle = Column(Text, nullable=True)
@@ -91,6 +105,24 @@ class PageContent(Base):
partners_section_title = Column(Text, nullable=True)
partners_section_subtitle = Column(Text, nullable=True)
partners = Column(Text, nullable=True)
sections_enabled = Column(Text, nullable=True)
newsletter_section_title = Column(Text, nullable=True)
newsletter_section_subtitle = Column(Text, nullable=True)
newsletter_placeholder = Column(Text, nullable=True)
newsletter_button_text = Column(Text, nullable=True)
newsletter_enabled = Column(Boolean, default=False, nullable=True)
trust_badges_section_title = Column(Text, nullable=True)
trust_badges_section_subtitle = Column(Text, nullable=True)
trust_badges = Column(Text, nullable=True)
trust_badges_enabled = Column(Boolean, default=False, nullable=True)
promotions_section_title = Column(Text, nullable=True)
promotions_section_subtitle = Column(Text, nullable=True)
promotions = Column(Text, nullable=True)
promotions_enabled = Column(Boolean, default=False, nullable=True)
blog_section_title = Column(Text, nullable=True)
blog_section_subtitle = Column(Text, nullable=True)
blog_posts_limit = Column(Integer, nullable=True)
blog_enabled = Column(Boolean, default=False, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View File

@@ -8,7 +8,7 @@ from ...auth.models.user import User
from ...auth.models.role import Role
from ...system.models.system_settings import SystemSettings
from ...shared.utils.mailer import send_email
from ...shared.utils.html_sanitizer import sanitize_text_for_html
from ...shared.utils.sanitization import sanitize_text_for_html
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/contact', tags=['contact'])

View File

@@ -41,9 +41,13 @@ def serialize_page_content(content: PageContent) -> dict:
'hero_title': content.hero_title,
'hero_subtitle': content.hero_subtitle,
'hero_image': content.hero_image,
'hero_video_url': content.hero_video_url,
'hero_video_poster': content.hero_video_poster,
'story_content': content.story_content,
'values': safe_json_loads(content.values, []),
'features': safe_json_loads(content.features, []),
'features_section_title': content.features_section_title,
'features_section_subtitle': content.features_section_subtitle,
'about_hero_image': content.about_hero_image,
'mission': content.mission,
'vision': content.vision,
@@ -74,8 +78,18 @@ def serialize_page_content(content: PageContent) -> dict:
'about_preview_content': content.about_preview_content,
'about_preview_image': content.about_preview_image,
'stats': safe_json_loads(content.stats, []),
'stats_section_title': content.stats_section_title,
'stats_section_subtitle': content.stats_section_subtitle,
'rooms_section_title': content.rooms_section_title,
'rooms_section_subtitle': content.rooms_section_subtitle,
'rooms_section_button_text': content.rooms_section_button_text,
'rooms_section_button_link': content.rooms_section_button_link,
'rooms_section_enabled': content.rooms_section_enabled,
'luxury_services_section_title': content.luxury_services_section_title,
'luxury_services_section_subtitle': content.luxury_services_section_subtitle,
'services_section_button_text': content.services_section_button_text,
'services_section_button_link': content.services_section_button_link,
'services_section_limit': content.services_section_limit,
'luxury_services': safe_json_loads(content.luxury_services, []),
'luxury_experiences_section_title': content.luxury_experiences_section_title,
'luxury_experiences_section_subtitle': content.luxury_experiences_section_subtitle,
@@ -91,6 +105,24 @@ def serialize_page_content(content: PageContent) -> dict:
'partners_section_title': content.partners_section_title,
'partners_section_subtitle': content.partners_section_subtitle,
'partners': safe_json_loads(content.partners, []),
'sections_enabled': safe_json_loads(content.sections_enabled, {}),
'newsletter_section_title': content.newsletter_section_title,
'newsletter_section_subtitle': content.newsletter_section_subtitle,
'newsletter_placeholder': content.newsletter_placeholder,
'newsletter_button_text': content.newsletter_button_text,
'newsletter_enabled': content.newsletter_enabled,
'trust_badges_section_title': content.trust_badges_section_title,
'trust_badges_section_subtitle': content.trust_badges_section_subtitle,
'trust_badges': safe_json_loads(content.trust_badges, []),
'trust_badges_enabled': content.trust_badges_enabled,
'promotions_section_title': content.promotions_section_title,
'promotions_section_subtitle': content.promotions_section_subtitle,
'promotions': safe_json_loads(content.promotions, []),
'promotions_enabled': content.promotions_enabled,
'blog_section_title': content.blog_section_title,
'blog_section_subtitle': content.blog_section_subtitle,
'blog_posts_limit': content.blog_posts_limit,
'blog_enabled': content.blog_enabled,
'is_active': content.is_active,
'created_at': content.created_at.isoformat() if content.created_at else None,
'updated_at': content.updated_at.isoformat() if content.updated_at else None

File diff suppressed because one or more lines are too long

View File

@@ -27,9 +27,13 @@ class PageContentUpdateRequest(BaseModel):
hero_title: Optional[str] = Field(None, max_length=500)
hero_subtitle: Optional[str] = Field(None, max_length=1000)
hero_image: Optional[str] = Field(None, max_length=1000)
hero_video_url: Optional[str] = Field(None, max_length=1000)
hero_video_poster: Optional[str] = Field(None, max_length=1000)
story_content: Optional[str] = None
values: Optional[Union[str, List[Dict[str, Any]]]] = None
features: Optional[Union[str, List[Dict[str, Any]]]] = None
features_section_title: Optional[str] = Field(None, max_length=500)
features_section_subtitle: Optional[str] = Field(None, max_length=1000)
about_hero_image: Optional[str] = Field(None, max_length=1000)
mission: Optional[str] = Field(None, max_length=2000)
vision: Optional[str] = Field(None, max_length=2000)
@@ -60,8 +64,18 @@ class PageContentUpdateRequest(BaseModel):
about_preview_content: Optional[str] = None
about_preview_image: Optional[str] = Field(None, max_length=1000)
stats: Optional[Union[str, List[Dict[str, Any]]]] = None
stats_section_title: Optional[str] = Field(None, max_length=500)
stats_section_subtitle: Optional[str] = Field(None, max_length=1000)
rooms_section_title: Optional[str] = Field(None, max_length=500)
rooms_section_subtitle: Optional[str] = Field(None, max_length=1000)
rooms_section_button_text: Optional[str] = Field(None, max_length=200)
rooms_section_button_link: Optional[str] = Field(None, max_length=1000)
rooms_section_enabled: Optional[bool] = None
luxury_services_section_title: Optional[str] = Field(None, max_length=500)
luxury_services_section_subtitle: Optional[str] = Field(None, max_length=1000)
services_section_button_text: Optional[str] = Field(None, max_length=200)
services_section_button_link: Optional[str] = Field(None, max_length=1000)
services_section_limit: Optional[int] = None
luxury_services: Optional[Union[str, List[Dict[str, Any]]]] = None
luxury_experiences_section_title: Optional[str] = Field(None, max_length=500)
luxury_experiences_section_subtitle: Optional[str] = Field(None, max_length=1000)
@@ -77,6 +91,24 @@ class PageContentUpdateRequest(BaseModel):
partners_section_title: Optional[str] = Field(None, max_length=500)
partners_section_subtitle: Optional[str] = Field(None, max_length=1000)
partners: Optional[Union[str, List[Dict[str, Any]]]] = None
sections_enabled: Optional[Union[str, Dict[str, bool]]] = None
newsletter_section_title: Optional[str] = Field(None, max_length=500)
newsletter_section_subtitle: Optional[str] = Field(None, max_length=1000)
newsletter_placeholder: Optional[str] = Field(None, max_length=200)
newsletter_button_text: Optional[str] = Field(None, max_length=200)
newsletter_enabled: Optional[bool] = None
trust_badges_section_title: Optional[str] = Field(None, max_length=500)
trust_badges_section_subtitle: Optional[str] = Field(None, max_length=1000)
trust_badges: Optional[Union[str, List[Dict[str, Any]]]] = None
trust_badges_enabled: Optional[bool] = None
promotions_section_title: Optional[str] = Field(None, max_length=500)
promotions_section_subtitle: Optional[str] = Field(None, max_length=1000)
promotions: Optional[Union[str, List[Dict[str, Any]]]] = None
promotions_enabled: Optional[bool] = None
blog_section_title: Optional[str] = Field(None, max_length=500)
blog_section_subtitle: Optional[str] = Field(None, max_length=1000)
blog_posts_limit: Optional[int] = None
blog_enabled: Optional[bool] = None
copyright_text: Optional[str] = Field(None, max_length=500)
is_active: Optional[bool] = True
@@ -84,7 +116,8 @@ class PageContentUpdateRequest(BaseModel):
'features', 'amenities', 'testimonials', 'gallery_images', 'stats',
'luxury_features', 'luxury_gallery', 'luxury_testimonials',
'luxury_services', 'luxury_experiences', 'awards', 'partners',
'team', 'timeline', 'achievements', mode='before')
'team', 'timeline', 'achievements', 'sections_enabled', 'trust_badges',
'promotions', mode='before')
@classmethod
def validate_json_fields(cls, v):
"""Validate and parse JSON string fields."""

View File

@@ -44,8 +44,7 @@ else:
PageContent.__table__.create(bind=engine, checkfirst=True)
except Exception as e:
logger.error(f'Failed to ensure required tables exist: {e}')
from .auth.routes import auth_routes
from .content.routes import privacy_routes
app = FastAPI(title=settings.APP_NAME, description='Enterprise-grade Hotel Booking API', version=settings.APP_VERSION, docs_url='/api/docs' if not settings.is_production else None, redoc_url='/api/redoc' if not settings.is_production else None, openapi_url='/api/openapi.json' if not settings.is_production else None)
app.add_middleware(RequestIDMiddleware)
app.add_middleware(CookieConsentMiddleware)
@@ -193,7 +192,6 @@ async def metrics(
# Custom route for serving uploads with CORS headers
# This route takes precedence over the mount below
from fastapi.responses import FileResponse
import re
@app.options('/uploads/{file_path:path}')
async def serve_upload_file_options(file_path: str, request: Request):

View File

@@ -52,6 +52,7 @@ class Campaign(Base):
# Segmentation
segment_id = Column(Integer, ForeignKey('campaign_segments.id'), nullable=True)
segment_criteria = Column(JSON, nullable=True) # Store segment criteria as JSON
recipient_type = Column(String(50), nullable=True, default='users') # 'users', 'subscribers', 'both'
# A/B Testing
is_ab_test = Column(Boolean, nullable=False, default=False)
@@ -283,3 +284,18 @@ class Unsubscribe(Base):
user = relationship('User')
campaign = relationship('Campaign')
class NewsletterSubscriber(Base):
__tablename__ = 'newsletter_subscribers'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
email = Column(String(255), nullable=False, unique=True, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=True, index=True)
name = Column(String(255), nullable=True)
subscribed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
is_active = Column(Boolean, nullable=False, default=True, index=True)
# Relationships
user = relationship('User')

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from sqlalchemy.orm import Session, selectinload
from sqlalchemy import func
from typing import Optional, List, Union
from datetime import datetime
from pydantic import BaseModel, EmailStr, field_validator
@@ -10,7 +11,7 @@ from ...auth.models.user import User
from ..models.email_campaign import (
Campaign, CampaignStatus, CampaignType,
CampaignSegment, EmailTemplate, CampaignEmail, EmailStatus,
DripSequence, DripSequenceStep, Unsubscribe
DripSequence, DripSequenceStep, Unsubscribe, NewsletterSubscriber
)
from ..services.email_campaign_service import email_campaign_service
@@ -31,6 +32,7 @@ class CampaignCreate(BaseModel):
reply_to_email: Optional[str] = None
track_opens: bool = True
track_clicks: bool = True
recipient_type: Optional[str] = 'users' # 'users', 'subscribers', 'both'
@field_validator('segment_id', 'template_id', mode='before')
@classmethod
@@ -408,7 +410,8 @@ async def create_campaign(
from_email=data.from_email,
reply_to_email=data.reply_to_email,
track_opens=data.track_opens,
track_clicks=data.track_clicks
track_clicks=data.track_clicks,
recipient_type=data.recipient_type or 'users'
)
return {"status": "success", "campaign_id": campaign.id}
@@ -533,6 +536,139 @@ async def track_email_click(
from fastapi.responses import RedirectResponse
return RedirectResponse(url=url)
# Newsletter Subscription Routes
class NewsletterSubscribeRequest(BaseModel):
email: EmailStr
name: Optional[str] = None
@router.post("/newsletter/subscribe")
async def subscribe_to_newsletter(
request: NewsletterSubscribeRequest,
db: Session = Depends(get_db)
):
"""Subscribe to newsletter"""
email = request.email
name = request.name
# Check if already unsubscribed
existing_unsubscribe = db.query(Unsubscribe).filter(
Unsubscribe.email == email,
Unsubscribe.unsubscribe_all == True
).first()
if existing_unsubscribe:
# Remove unsubscribe record to re-subscribe
db.delete(existing_unsubscribe)
db.commit()
# Check if user exists
user = db.query(User).filter(User.email == email).first()
# Check if already subscribed
existing_subscriber = db.query(NewsletterSubscriber).filter(
NewsletterSubscriber.email == email
).first()
if existing_subscriber:
# Re-activate if was deactivated
if not existing_subscriber.is_active:
existing_subscriber.is_active = True
existing_subscriber.subscribed_at = datetime.utcnow()
if name and not existing_subscriber.name:
existing_subscriber.name = name
if user and not existing_subscriber.user_id:
existing_subscriber.user_id = user.id
db.commit()
else:
# Create new subscriber record
subscriber = NewsletterSubscriber(
email=email,
user_id=user.id if user else None,
name=name or (user.full_name if user else None),
is_active=True
)
db.add(subscriber)
db.commit()
# Find or create newsletter segment
newsletter_segment = db.query(CampaignSegment).filter(
CampaignSegment.name == "Newsletter Subscribers"
).first()
if not newsletter_segment:
# Create newsletter segment if it doesn't exist
newsletter_segment = CampaignSegment(
name="Newsletter Subscribers",
description="Users who have subscribed to the newsletter",
criteria={"subscribed": True}
)
db.add(newsletter_segment)
db.commit()
db.refresh(newsletter_segment)
# Update segment estimated count - count active subscribers
subscriber_count = db.query(func.count(NewsletterSubscriber.id)).filter(
NewsletterSubscriber.is_active == True
).scalar() or 0
newsletter_segment.estimated_count = subscriber_count
newsletter_segment.last_calculated_at = datetime.utcnow()
db.commit()
return {
"status": "success",
"message": "Successfully subscribed to newsletter",
"email": email
}
@router.get("/newsletter/subscribers")
async def get_newsletter_subscribers(
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=100)
):
"""Get list of newsletter subscribers"""
try:
# Get all active newsletter subscribers
subscribers_query = db.query(NewsletterSubscriber).filter(
NewsletterSubscriber.is_active == True
).order_by(NewsletterSubscriber.subscribed_at.desc())
# Get total count
total = subscribers_query.count()
# Pagination
offset = (page - 1) * limit
paginated_subscribers = subscribers_query.offset(offset).limit(limit).all()
# Format subscribers
subscribers_list = []
for subscriber in paginated_subscribers:
subscribers_list.append({
"email": subscriber.email,
"user_id": subscriber.user_id,
"name": subscriber.name or (subscriber.user.full_name if subscriber.user else None),
"type": "user" if subscriber.user_id else "guest",
"subscribed_at": subscriber.subscribed_at.isoformat() if subscriber.subscribed_at else None
})
return {
"status": "success",
"data": {
"subscribers": subscribers_list,
"total": total,
"page": page,
"limit": limit,
"total_pages": (total + limit - 1) // limit if limit > 0 else 1
}
}
except Exception as e:
from ...shared.config.logging_config import get_logger
logger = get_logger(__name__)
logger.error(f"Error fetching newsletter subscribers: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to fetch subscribers: {str(e)}")
# Unsubscribe Routes
@router.post("/unsubscribe")
async def unsubscribe(
@@ -553,6 +689,24 @@ async def unsubscribe(
user = db.query(User).filter(User.email == email).first()
# Mark newsletter subscriber as inactive if unsubscribing from all
if unsubscribe_all:
subscriber = db.query(NewsletterSubscriber).filter(
NewsletterSubscriber.email == email
).first()
if subscriber:
subscriber.is_active = False
# Update segment count
newsletter_segment = db.query(CampaignSegment).filter(
CampaignSegment.name == "Newsletter Subscribers"
).first()
if newsletter_segment:
subscriber_count = db.query(func.count(NewsletterSubscriber.id)).filter(
NewsletterSubscriber.is_active == True
).scalar() or 0
newsletter_segment.estimated_count = subscriber_count
newsletter_segment.last_calculated_at = datetime.utcnow()
unsubscribe_record = Unsubscribe(
email=email,
user_id=user.id if user else None,

View File

@@ -4,7 +4,7 @@ from datetime import datetime, timedelta
from ..models.email_campaign import (
Campaign, CampaignStatus, CampaignType, EmailStatus,
CampaignSegment, EmailTemplate, CampaignEmail, EmailClick,
DripSequence, DripSequenceStep, DripSequenceEnrollment, Unsubscribe
DripSequence, DripSequenceStep, DripSequenceEnrollment, Unsubscribe, NewsletterSubscriber
)
from ...auth.models.user import User
from ...bookings.models.booking import Booking
@@ -51,19 +51,54 @@ class EmailCampaignService:
db: Session,
campaign: Campaign
) -> List[User]:
"""Get list of recipients for a campaign based on segment"""
"""Get list of recipients for a campaign based on segment and recipient_type"""
recipient_type = getattr(campaign, 'recipient_type', 'users') or 'users'
recipients = []
# Get users if recipient_type is 'users' or 'both'
if recipient_type in ['users', 'both']:
if campaign.segment_id:
segment = db.query(CampaignSegment).filter(
CampaignSegment.id == campaign.segment_id
).first()
if segment:
return EmailCampaignService._apply_segment_criteria(db, segment.criteria)
recipients.extend(EmailCampaignService._apply_segment_criteria(db, segment.criteria))
else:
# If segment not found, get all active users
recipients.extend(db.query(User).filter(User.is_active == True).all())
else:
# If no segment, return all active users
recipients.extend(db.query(User).filter(User.is_active == True).all())
# If no segment, return all active users (or based on campaign type)
if campaign.campaign_type == CampaignType.newsletter:
return db.query(User).filter(User.is_active == True).all()
# Get subscribers if recipient_type is 'subscribers' or 'both'
if recipient_type in ['subscribers', 'both']:
subscribers = db.query(NewsletterSubscriber).filter(
NewsletterSubscriber.is_active == True
).all()
return []
# Convert subscribers to User-like objects or get their User records
for subscriber in subscribers:
if subscriber.user_id:
# If subscriber is a user, get the User object (avoid duplicates)
user = db.query(User).filter(User.id == subscriber.user_id).first()
if user and user not in recipients:
recipients.append(user)
else:
# Guest subscriber - create a minimal User-like object for sending
# We'll handle this in send_campaign by using the email directly
# For now, we'll skip guest subscribers in the User list
# They'll be handled separately in send_campaign
pass
# Remove duplicates based on email
seen_emails = set()
unique_recipients = []
for user in recipients:
if user.email not in seen_emails:
seen_emails.add(user.email)
unique_recipients.append(user)
return unique_recipients
@staticmethod
def _apply_segment_criteria(db: Session, criteria: Dict[str, Any]) -> List[User]:
@@ -105,9 +140,34 @@ class EmailCampaignService:
if campaign.status not in [CampaignStatus.draft, CampaignStatus.scheduled]:
raise ValueError(f"Cannot send campaign with status: {campaign.status}")
# Get recipients
recipients = EmailCampaignService.get_campaign_recipients(db, campaign)
campaign.total_recipients = len(recipients)
# Get recipients (users)
user_recipients = EmailCampaignService.get_campaign_recipients(db, campaign)
recipient_type = getattr(campaign, 'recipient_type', 'users') or 'users'
# Get guest subscribers if recipient_type is 'subscribers' or 'both'
guest_subscribers = []
if recipient_type in ['subscribers', 'both']:
guest_subscribers = db.query(NewsletterSubscriber).filter(
NewsletterSubscriber.is_active == True,
NewsletterSubscriber.user_id.is_(None) # Only guest subscribers
).all()
# Combine all recipient emails
all_recipient_emails = set()
recipient_data = {} # Store email -> (user_id, name) mapping
# Add user recipients
for user in user_recipients:
all_recipient_emails.add(user.email)
recipient_data[user.email] = (user.id, user.full_name)
# Add guest subscribers
for subscriber in guest_subscribers:
if subscriber.email not in all_recipient_emails:
all_recipient_emails.add(subscriber.email)
recipient_data[subscriber.email] = (None, subscriber.name)
campaign.total_recipients = len(all_recipient_emails)
# Update status
campaign.status = CampaignStatus.sending
@@ -116,17 +176,19 @@ class EmailCampaignService:
sent_count = 0
failed_count = 0
for user in recipients:
# Check if user unsubscribed
if EmailCampaignService._is_unsubscribed(db, user.email, campaign):
for email in all_recipient_emails:
# Check if email is unsubscribed
if EmailCampaignService._is_unsubscribed(db, email, campaign):
continue
user_id, name = recipient_data[email]
try:
# Create campaign email record
campaign_email = CampaignEmail(
campaign_id=campaign.id,
user_id=user.id,
email=user.email,
user_id=user_id,
email=email,
status=EmailStatus.pending
)
db.add(campaign_email)
@@ -135,11 +197,13 @@ class EmailCampaignService:
# Replace template variables
html_content = EmailCampaignService._replace_variables(
campaign.html_content or '',
user
name or 'Guest',
email
)
subject = EmailCampaignService._replace_variables(
campaign.subject,
user
name or 'Guest',
email
)
# Send email (async function)
@@ -151,7 +215,7 @@ class EmailCampaignService:
asyncio.set_event_loop(loop)
loop.run_until_complete(send_email(
to=user.email,
to=email,
subject=subject,
html=html_content,
text=campaign.text_content
@@ -162,7 +226,7 @@ class EmailCampaignService:
sent_count += 1
except Exception as e:
logger.error(f"Failed to send email to {user.email}: {str(e)}")
logger.error(f"Failed to send email to {email}: {str(e)}")
if 'campaign_email' in locals():
campaign_email.status = EmailStatus.failed
campaign_email.error_message = str(e)
@@ -177,16 +241,17 @@ class EmailCampaignService:
return {
"sent": sent_count,
"failed": failed_count,
"total": len(recipients)
"total": len(all_recipient_emails)
}
@staticmethod
def _replace_variables(content: str, user: User) -> str:
"""Replace template variables with user data"""
def _replace_variables(content: str, name: str, email: str) -> str:
"""Replace template variables with user/subscriber data"""
first_name = name.split()[0] if name and ' ' in name else (name or 'Guest')
replacements = {
'{{name}}': user.full_name or 'Guest',
'{{email}}': user.email,
'{{first_name}}': user.full_name.split()[0] if user.full_name else 'Guest',
'{{name}}': name or 'Guest',
'{{email}}': email,
'{{first_name}}': first_name,
}
for key, value in replacements.items():

View File

@@ -1,99 +0,0 @@
"""
HTML sanitization utilities for backend content storage.
Prevents XSS attacks by sanitizing HTML before storing in database.
"""
import bleach
from typing import Optional
# Allowed HTML tags for rich content
ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'u', 'b', 'i', 'span', 'div',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'a', 'blockquote', 'pre', 'code',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'img', 'hr', 'section', 'article'
]
# Allowed HTML attributes
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title', 'target', 'rel'],
'img': ['src', 'alt', 'title', 'width', 'height', 'class'],
'div': ['class', 'id', 'style'],
'span': ['class', 'id', 'style'],
'p': ['class', 'id', 'style'],
'h1': ['class', 'id'],
'h2': ['class', 'id'],
'h3': ['class', 'id'],
'h4': ['class', 'id'],
'h5': ['class', 'id'],
'h6': ['class', 'id'],
'table': ['class', 'id'],
'tr': ['class', 'id'],
'th': ['class', 'id', 'colspan', 'rowspan'],
'td': ['class', 'id', 'colspan', 'rowspan'],
}
# Allowed URL schemes
ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel']
def sanitize_html(html_content: Optional[str]) -> str:
"""
Sanitize HTML content to prevent XSS attacks.
Args:
html_content: HTML string to sanitize (can be None)
Returns:
Sanitized HTML string safe for storage
"""
if not html_content:
return ''
# Clean HTML content
cleaned = bleach.clean(
html_content,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
protocols=ALLOWED_SCHEMES,
strip=True, # Strip disallowed tags instead of escaping
strip_comments=True, # Remove HTML comments
)
# Additional link sanitization - ensure external links have rel="noopener"
if '<a' in cleaned:
import re
# Add rel="noopener noreferrer" to external links
def add_rel(match):
tag = match.group(0)
if 'href=' in tag and ('http://' in tag or 'https://' in tag):
if 'rel=' not in tag:
# Insert rel attribute before closing >
return tag[:-1] + ' rel="noopener noreferrer">'
elif 'noopener' not in tag and 'noreferrer' not in tag:
# Add to existing rel attribute
tag = tag.replace('rel="', 'rel="noopener noreferrer ')
tag = tag.replace("rel='", "rel='noopener noreferrer ")
return tag
return tag
cleaned = re.sub(r'<a[^>]*>', add_rel, cleaned)
return cleaned
def sanitize_text_for_html(text: Optional[str]) -> str:
"""
Escape text content to be safely included in HTML.
Use this for plain text that should be displayed as-is.
Args:
text: Plain text string to escape
Returns:
HTML-escaped string
"""
if not text:
return ''
return bleach.clean(text, tags=[], strip=True)

View File

@@ -60,6 +60,25 @@ def sanitize_html(content: Optional[str], strip: bool = False) -> str:
strip_comments=True
)
# Additional link sanitization - ensure external links have rel="noopener noreferrer"
if '<a' in sanitized:
import re
# Add rel="noopener noreferrer" to external links
def add_rel(match):
tag = match.group(0)
if 'href=' in tag and ('http://' in tag or 'https://' in tag):
if 'rel=' not in tag:
# Insert rel attribute before closing >
return tag[:-1] + ' rel="noopener noreferrer">'
elif 'noopener' not in tag and 'noreferrer' not in tag:
# Add to existing rel attribute
tag = tag.replace('rel="', 'rel="noopener noreferrer ')
tag = tag.replace("rel='", "rel='noopener noreferrer ")
return tag
return tag
sanitized = re.sub(r'<a[^>]*>', add_rel, sanitized)
# Linkify URLs (convert plain URLs to links)
# Only linkify if content doesn't already contain HTML links
if '<a' not in sanitized:
@@ -76,6 +95,7 @@ def sanitize_text(content: Optional[str]) -> str:
"""
Strip all HTML tags from content, leaving only plain text.
Useful for fields that should not contain any HTML.
Alias for sanitize_text_for_html for backward compatibility.
Args:
content: The content to sanitize (can be None)
@@ -93,6 +113,21 @@ def sanitize_text(content: Optional[str]) -> str:
return bleach.clean(content, tags=[], strip=True)
def sanitize_text_for_html(text: Optional[str]) -> str:
"""
Escape text content to be safely included in HTML.
Use this for plain text that should be displayed as-is.
Alias for sanitize_text for consistency.
Args:
text: Plain text string to escape
Returns:
HTML-escaped string
"""
return sanitize_text(text)
def sanitize_filename(filename: str) -> str:
"""
Sanitize filename to prevent path traversal and other attacks.

View File

@@ -121,6 +121,32 @@ def test_staff_role(db_session):
return role
@pytest.fixture
def test_accountant_role(db_session):
"""Create an accountant role."""
role = Role(
name="accountant",
description="Accountant role"
)
db_session.add(role)
db_session.commit()
db_session.refresh(role)
return role
@pytest.fixture
def test_housekeeping_role(db_session):
"""Create a housekeeping role."""
role = Role(
name="housekeeping",
description="Housekeeping role"
)
db_session.add(role)
db_session.commit()
db_session.refresh(role)
return role
@pytest.fixture
def test_user(db_session, test_role):
"""Create a test user."""
@@ -175,6 +201,42 @@ def test_staff_user(db_session, test_staff_role):
return user
@pytest.fixture
def test_accountant_user(db_session, test_accountant_role):
"""Create a test accountant user."""
hashed_password = bcrypt.hashpw("accountantpassword123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
user = User(
email="accountant@example.com",
password=hashed_password,
full_name="Accountant User",
phone="1234567890",
role_id=test_accountant_role.id,
is_active=True
)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
@pytest.fixture
def test_housekeeping_user(db_session, test_housekeeping_role):
"""Create a test housekeeping user."""
hashed_password = bcrypt.hashpw("housekeepingpassword123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
user = User(
email="housekeeping@example.com",
password=hashed_password,
full_name="Housekeeping User",
phone="1234567890",
role_id=test_housekeeping_role.id,
is_active=True
)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
@pytest.fixture
def auth_token(client, test_user):
"""Get authentication token for test user (from cookies)."""
@@ -227,6 +289,38 @@ def staff_token(client, test_staff_user):
return None
@pytest.fixture
def accountant_token(client, test_accountant_user):
"""Get authentication token for accountant user (from cookies)."""
response = client.post(
"/api/auth/login",
json={
"email": "accountant@example.com",
"password": "accountantpassword123"
}
)
if response.status_code == 200:
cookie_token = response.cookies.get("accessToken")
return cookie_token
return None
@pytest.fixture
def housekeeping_token(client, test_housekeeping_user):
"""Get authentication token for housekeeping user (from cookies)."""
response = client.post(
"/api/auth/login",
json={
"email": "housekeeping@example.com",
"password": "housekeepingpassword123"
}
)
if response.status_code == 200:
cookie_token = response.cookies.get("accessToken")
return cookie_token
return None
@pytest.fixture
def authenticated_client(client, test_user):
"""Create an authenticated test client (uses cookies)."""
@@ -257,6 +351,32 @@ def admin_client(client, test_admin_user):
return client
@pytest.fixture
def accountant_client(client, test_accountant_user):
"""Create an authenticated accountant test client (uses cookies)."""
response = client.post(
"/api/auth/login",
json={
"email": "accountant@example.com",
"password": "accountantpassword123"
}
)
return client
@pytest.fixture
def housekeeping_client(client, test_housekeeping_user):
"""Create an authenticated housekeeping test client (uses cookies)."""
response = client.post(
"/api/auth/login",
json={
"email": "housekeeping@example.com",
"password": "housekeepingpassword123"
}
)
return client
@pytest.fixture
def test_room_type(db_session):
"""Create a test room type."""

View File

@@ -0,0 +1,241 @@
"""
Comprehensive role-based permission tests.
Tests that each user role can only access authorized endpoints and features.
"""
import pytest
from fastapi import status
@pytest.mark.integration
class TestAdminPermissions:
"""Test admin role permissions."""
def test_admin_can_access_user_management(self, admin_client):
"""Admin should be able to access user management."""
response = admin_client.get("/api/admin/users")
assert response.status_code in [200, 404] # 404 if endpoint not implemented
def test_admin_can_create_user(self, admin_client):
"""Admin should be able to create users."""
response = admin_client.post(
"/api/admin/users",
json={
"email": "newuser@test.com",
"password": "Test123!",
"full_name": "New User",
"role": "customer"
}
)
assert response.status_code in [201, 400, 404]
def test_admin_can_access_analytics(self, admin_client):
"""Admin should be able to access analytics."""
response = admin_client.get("/api/admin/analytics/dashboard")
assert response.status_code in [200, 404]
def test_admin_can_manage_rooms(self, admin_client):
"""Admin should be able to manage rooms."""
response = admin_client.get("/api/admin/rooms")
assert response.status_code in [200, 404]
def test_admin_can_manage_bookings(self, admin_client):
"""Admin should be able to manage all bookings."""
response = admin_client.get("/api/admin/bookings")
assert response.status_code in [200, 404]
def test_admin_can_manage_payments(self, admin_client):
"""Admin should be able to manage payments."""
response = admin_client.get("/api/admin/payments")
assert response.status_code in [200, 404]
def test_admin_can_manage_invoices(self, admin_client):
"""Admin should be able to manage invoices."""
response = admin_client.get("/api/admin/invoices")
assert response.status_code in [200, 404]
@pytest.mark.integration
class TestStaffPermissions:
"""Test staff role permissions."""
def test_staff_can_view_bookings(self, staff_token, client):
"""Staff should be able to view bookings."""
response = client.get(
"/api/staff/bookings",
cookies={"accessToken": staff_token} if staff_token else {}
)
assert response.status_code in [200, 401, 403, 404]
def test_staff_can_update_booking_status(self, staff_token, client, test_booking):
"""Staff should be able to update booking status."""
response = client.put(
f"/api/staff/bookings/{test_booking.id}",
json={"status": "checked_in"},
cookies={"accessToken": staff_token} if staff_token else {}
)
assert response.status_code in [200, 401, 403, 404]
def test_staff_cannot_delete_users(self, staff_token, client, test_user):
"""Staff should NOT be able to delete users."""
response = client.delete(
f"/api/admin/users/{test_user.id}",
cookies={"accessToken": staff_token} if staff_token else {}
)
assert response.status_code in [401, 403, 404]
def test_staff_cannot_access_system_settings(self, staff_token, client):
"""Staff should NOT be able to access system settings."""
response = client.get(
"/api/admin/settings",
cookies={"accessToken": staff_token} if staff_token else {}
)
assert response.status_code in [401, 403, 404]
@pytest.mark.integration
class TestCustomerPermissions:
"""Test customer role permissions."""
def test_customer_can_view_own_bookings(self, authenticated_client, test_user, test_booking):
"""Customer should be able to view their own bookings."""
response = authenticated_client.get("/api/bookings/me")
assert response.status_code in [200, 404]
def test_customer_can_create_booking(self, authenticated_client, test_room):
"""Customer should be able to create bookings."""
from datetime import datetime, timedelta
check_in = datetime.utcnow() + timedelta(days=1)
check_out = datetime.utcnow() + timedelta(days=3)
response = authenticated_client.post(
"/api/bookings",
json={
"room_id": test_room.id,
"check_in_date": check_in.isoformat(),
"check_out_date": check_out.isoformat(),
"num_guests": 2
}
)
assert response.status_code in [201, 400, 404]
def test_customer_cannot_view_other_bookings(self, authenticated_client, test_booking):
"""Customer should NOT be able to view other users' bookings."""
# Try to access booking that doesn't belong to them
response = authenticated_client.get(f"/api/bookings/{test_booking.id}")
# Should return 403 or 404, not 200
assert response.status_code in [403, 404]
def test_customer_cannot_manage_users(self, authenticated_client):
"""Customer should NOT be able to manage users."""
response = authenticated_client.get("/api/admin/users")
assert response.status_code in [401, 403, 404]
def test_customer_can_view_rooms(self, client):
"""Customer should be able to view available rooms."""
response = client.get("/api/rooms")
assert response.status_code in [200, 404]
def test_customer_can_view_own_invoices(self, authenticated_client):
"""Customer should be able to view their own invoices."""
response = authenticated_client.get("/api/invoices/me")
assert response.status_code in [200, 404]
@pytest.mark.integration
class TestAccountantPermissions:
"""Test accountant role permissions."""
def test_accountant_can_view_payments(self, accountant_client):
"""Accountant should be able to view payments."""
response = accountant_client.get("/api/accountant/payments")
assert response.status_code in [200, 404]
def test_accountant_can_view_invoices(self, accountant_client):
"""Accountant should be able to view invoices."""
response = accountant_client.get("/api/accountant/invoices")
assert response.status_code in [200, 404]
def test_accountant_can_generate_reports(self, accountant_client):
"""Accountant should be able to generate financial reports."""
response = accountant_client.get("/api/accountant/reports")
assert response.status_code in [200, 404]
def test_accountant_cannot_manage_rooms(self, accountant_client):
"""Accountant should NOT be able to manage rooms."""
response = accountant_client.get("/api/admin/rooms")
assert response.status_code in [401, 403, 404]
def test_accountant_cannot_manage_bookings(self, accountant_client):
"""Accountant should NOT be able to manage bookings."""
response = accountant_client.put("/api/admin/bookings/1", json={"status": "confirmed"})
assert response.status_code in [401, 403, 404]
@pytest.mark.integration
class TestHousekeepingPermissions:
"""Test housekeeping role permissions."""
def test_housekeeping_can_view_tasks(self, housekeeping_client):
"""Housekeeping should be able to view assigned tasks."""
response = housekeeping_client.get("/api/housekeeping/tasks")
assert response.status_code in [200, 404]
def test_housekeeping_can_update_task_status(self, housekeeping_client):
"""Housekeeping should be able to update task status."""
# This test would need a test task fixture
# For now, just test the endpoint exists
response = housekeeping_client.put("/api/housekeeping/tasks/1", json={"status": "completed"})
assert response.status_code in [200, 404, 400]
def test_housekeeping_can_update_room_status(self, housekeeping_client):
"""Housekeeping should be able to update room status."""
response = housekeeping_client.put("/api/housekeeping/rooms/1/status", json={"status": "clean"})
assert response.status_code in [200, 404, 400]
def test_housekeeping_cannot_manage_bookings(self, housekeeping_client):
"""Housekeeping should NOT be able to manage bookings."""
response = housekeeping_client.get("/api/admin/bookings")
assert response.status_code in [401, 403, 404]
def test_housekeeping_cannot_access_payments(self, housekeeping_client):
"""Housekeeping should NOT be able to access payments."""
response = housekeeping_client.get("/api/admin/payments")
assert response.status_code in [401, 403, 404]
@pytest.mark.integration
class TestUnauthenticatedAccess:
"""Test that unauthenticated users cannot access protected endpoints."""
def test_unauthenticated_cannot_access_admin_endpoints(self, client):
"""Unauthenticated users should not access admin endpoints."""
response = client.get("/api/admin/users")
assert response.status_code in [401, 403, 404]
def test_unauthenticated_cannot_access_staff_endpoints(self, client):
"""Unauthenticated users should not access staff endpoints."""
response = client.get("/api/staff/bookings")
assert response.status_code in [401, 403, 404]
def test_unauthenticated_can_view_public_rooms(self, client):
"""Unauthenticated users should be able to view public room listings."""
response = client.get("/api/rooms")
assert response.status_code in [200, 404]
def test_unauthenticated_cannot_create_booking(self, client, test_room):
"""Unauthenticated users should not be able to create bookings."""
from datetime import datetime, timedelta
check_in = datetime.utcnow() + timedelta(days=1)
check_out = datetime.utcnow() + timedelta(days=3)
response = client.post(
"/api/bookings",
json={
"room_id": test_room.id,
"check_in_date": check_in.isoformat(),
"check_out_date": check_out.isoformat(),
"num_guests": 2
}
)
assert response.status_code in [401, 403]

View File

@@ -66,6 +66,7 @@ const ComplaintPage = lazy(() => import('./pages/customer/ComplaintPage'));
const GuestRequestsPage = lazy(() => import('./pages/customer/GuestRequestsPage'));
const GDPRPage = lazy(() => import('./pages/customer/GDPRPage'));
const GDPRDeletionConfirmPage = lazy(() => import('./pages/customer/GDPRDeletionConfirmPage'));
const SessionManagementPage = lazy(() => import('./pages/customer/SessionManagementPage'));
const AboutPage = lazy(() => import('./features/content/pages/AboutPage'));
const ContactPage = lazy(() => import('./features/content/pages/ContactPage'));
const PrivacyPolicyPage = lazy(() => import('./features/content/pages/PrivacyPolicyPage'));
@@ -112,13 +113,18 @@ const WebhookManagementPage = lazy(() => import('./pages/admin/WebhookManagement
const APIKeyManagementPage = lazy(() => import('./pages/admin/APIKeyManagementPage'));
const BackupManagementPage = lazy(() => import('./pages/admin/BackupManagementPage'));
const ServiceManagementPage = lazy(() => import('./pages/admin/ServiceManagementPage'));
const InventoryManagementPage = lazy(() => import('./pages/admin/InventoryManagementPage'));
const MaintenanceManagementPage = lazy(() => import('./pages/admin/MaintenanceManagementPage'));
const InspectionManagementPage = lazy(() => import('./pages/admin/InspectionManagementPage'));
const StaffShiftManagementPage = lazy(() => import('./pages/admin/StaffShiftManagementPage'));
const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage'));
const StaffInventoryViewPage = lazy(() => import('./pages/staff/InventoryViewPage'));
const StaffShiftViewPage = lazy(() => import('./pages/staff/ShiftViewPage'));
const StaffBookingManagementPage = lazy(() => import('./pages/staff/BookingManagementPage'));
const StaffReceptionDashboardPage = lazy(() => import('./pages/staff/ReceptionDashboardPage'));
const StaffPaymentManagementPage = lazy(() => import('./pages/staff/PaymentManagementPage'));
const StaffAnalyticsDashboardPage = lazy(() => import('./pages/staff/AnalyticsDashboardPage'));
const StaffLoyaltyManagementPage = lazy(() => import('./pages/staff/LoyaltyManagementPage'));
const StaffGuestProfilePage = lazy(() => import('./pages/staff/GuestProfilePage'));
const StaffAdvancedRoomManagementPage = lazy(() => import('./pages/staff/AdvancedRoomManagementPage'));
const ChatManagementPage = lazy(() => import('./pages/staff/ChatManagementPage'));
@@ -142,6 +148,7 @@ const AccountantLayout = lazy(() => import('./pages/AccountantLayout'));
const HousekeepingDashboardPage = lazy(() => import('./pages/housekeeping/DashboardPage'));
const HousekeepingTasksPage = lazy(() => import('./pages/housekeeping/TasksPage'));
const HousekeepingShiftViewPage = lazy(() => import('./pages/housekeeping/ShiftViewPage'));
const HousekeepingLayout = lazy(() => import('./pages/HousekeepingLayout'));
const AdminProfilePage = lazy(() => import('./pages/admin/ProfilePage'));
@@ -502,6 +509,16 @@ function App() {
</ErrorBoundaryRoute>
}
/>
<Route
path="sessions"
element={
<ErrorBoundaryRoute>
<CustomerRoute>
<SessionManagementPage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
</Route>
{/* Separate Login Pages for Each Role */}
@@ -696,10 +713,30 @@ function App() {
path="services"
element={<ServiceManagementPage />}
/>
<Route
path="inventory"
element={<InventoryManagementPage />}
/>
<Route
path="maintenance"
element={<MaintenanceManagementPage />}
/>
<Route
path="inspections"
element={<InspectionManagementPage />}
/>
<Route
path="shifts"
element={<StaffShiftManagementPage />}
/>
<Route
path="profile"
element={<AdminProfilePage />}
/>
<Route
path="sessions"
element={<SessionManagementPage />}
/>
</Route>
{}
@@ -738,10 +775,6 @@ function App() {
path="chats"
element={<ChatManagementPage />}
/>
<Route
path="loyalty"
element={<StaffLoyaltyManagementPage />}
/>
<Route
path="guest-profiles"
element={<StaffGuestProfilePage />}
@@ -774,6 +807,18 @@ function App() {
path="profile"
element={<StaffProfilePage />}
/>
<Route
path="sessions"
element={<SessionManagementPage />}
/>
<Route
path="inventory"
element={<StaffInventoryViewPage />}
/>
<Route
path="shifts"
element={<StaffShiftViewPage />}
/>
</Route>
{/* Accountant Routes */}
@@ -828,6 +873,10 @@ function App() {
path="profile"
element={<AccountantProfilePage />}
/>
<Route
path="sessions"
element={<SessionManagementPage />}
/>
<Route
path="invoices/:id/edit"
element={<InvoiceEditPage />}
@@ -854,6 +903,9 @@ function App() {
element={<Navigate to="dashboard" replace />}
/>
<Route path="dashboard" element={<HousekeepingDashboardPage />} />
<Route path="tasks" element={<HousekeepingTasksPage />} />
<Route path="shifts" element={<HousekeepingShiftViewPage />} />
<Route path="sessions" element={<SessionManagementPage />} />
</Route>
{}

View File

@@ -21,6 +21,7 @@ import bannerService from '../services/bannerService';
import roomService from '../../rooms/services/roomService';
import pageContentService from '../services/pageContentService';
import serviceService from '../../hotel_services/services/serviceService';
import blogService, { BlogPost } from '../services/blogService';
import { useFormatCurrency } from '../../payments/hooks/useFormatCurrency';
import type { Banner } from '../services/bannerService';
import type { Room } from '../../rooms/services/roomService';
@@ -34,12 +35,14 @@ const HomePage: React.FC = () => {
const [newestRooms, setNewestRooms] = useState<Room[]>([]);
const [pageContent, setPageContent] = useState<PageContent | null>(null);
const [services, setServices] = useState<Service[]>([]);
const [blogPosts, setBlogPosts] = useState<BlogPost[]>([]);
const [isLoadingBanners, setIsLoadingBanners] =
useState(true);
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
const [, setIsLoadingContent] = useState(true);
const [isLoadingServices, setIsLoadingServices] = useState(true);
const [isLoadingBlog, setIsLoadingBlog] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiError, setApiError] = useState(false);
const [apiErrorMessage, setApiErrorMessage] = useState<string>('');
@@ -153,16 +156,6 @@ const HomePage: React.FC = () => {
} else if (!Array.isArray(content.amenities)) {
content.amenities = content.amenities || [];
}
// Handle testimonials - can be string, array, or null/undefined
if (typeof content.testimonials === 'string') {
try {
content.testimonials = JSON.parse(content.testimonials);
} catch (e) {
content.testimonials = [];
}
} else if (!Array.isArray(content.testimonials)) {
content.testimonials = content.testimonials || [];
}
// Handle gallery_images - can be string, array, or null/undefined
if (typeof content.gallery_images === 'string') {
try {
@@ -258,6 +251,53 @@ const HomePage: React.FC = () => {
} else if (!Array.isArray(content.partners)) {
content.partners = content.partners || [];
}
// Handle trust_badges - can be string, array, or null/undefined
if (typeof content.trust_badges === 'string') {
try {
content.trust_badges = JSON.parse(content.trust_badges);
} catch (e) {
content.trust_badges = [];
}
} else if (!Array.isArray(content.trust_badges)) {
content.trust_badges = content.trust_badges || [];
}
// Handle promotions - can be string, array, or null/undefined
if (typeof content.promotions === 'string') {
try {
content.promotions = JSON.parse(content.promotions);
} catch (e) {
content.promotions = [];
}
} else if (!Array.isArray(content.promotions)) {
content.promotions = content.promotions || [];
}
// Handle sections_enabled - can be string, object, or null/undefined
if (typeof content.sections_enabled === 'string') {
try {
content.sections_enabled = JSON.parse(content.sections_enabled);
} catch (e) {
content.sections_enabled = {};
}
} else if (typeof content.sections_enabled !== 'object' || content.sections_enabled === null) {
content.sections_enabled = content.sections_enabled || {};
}
// Normalize boolean values (MySQL returns 1/0, convert to true/false)
if (content.newsletter_enabled !== undefined) {
content.newsletter_enabled = Boolean(content.newsletter_enabled);
}
if (content.trust_badges_enabled !== undefined) {
content.trust_badges_enabled = Boolean(content.trust_badges_enabled);
}
if (content.promotions_enabled !== undefined) {
content.promotions_enabled = Boolean(content.promotions_enabled);
}
if (content.blog_enabled !== undefined) {
content.blog_enabled = Boolean(content.blog_enabled);
}
if (content.rooms_section_enabled !== undefined) {
content.rooms_section_enabled = Boolean(content.rooms_section_enabled);
}
setPageContent(content);
@@ -290,27 +330,53 @@ const HomePage: React.FC = () => {
fetchPageContent();
}, []);
useEffect(() => {
const fetchBlogPosts = async () => {
if (!pageContent || !pageContent.blog_enabled || (pageContent.sections_enabled?.blog === false)) {
return;
}
try {
setIsLoadingBlog(true);
const limit = pageContent.blog_posts_limit || 3;
const response = await blogService.getBlogPosts({
published_only: true,
limit: limit,
page: 1,
});
if (response.status === 'success' && response.data?.posts) {
setBlogPosts(response.data.posts);
}
} catch (error: any) {
console.error('Error fetching blog posts:', error);
} finally {
setIsLoadingBlog(false);
}
};
if (pageContent) {
fetchBlogPosts();
}
}, [pageContent?.blog_enabled, pageContent?.sections_enabled?.blog, pageContent?.blog_posts_limit]);
useEffect(() => {
const fetchBanners = async () => {
try {
setIsLoadingBanners(true);
const response = await bannerService
.getBannersByPosition('home');
const response = await bannerService.getBannersByPosition('home');
if (
response.success ||
response.status === 'success'
) {
setBanners(response.data?.banners || []);
if (response.success || response.status === 'success') {
const fetchedBanners = response.data?.banners || [];
setBanners(fetchedBanners);
if (fetchedBanners.length === 0) {
console.log('No banners found for home position');
}
} else {
console.warn('Banner service returned unsuccessful response:', response);
setBanners([]);
}
} catch (err: any) {
console.error('Error fetching banners:', err);
setApiError(true);
setApiErrorMessage('Unable to connect to the server. Please check your internet connection and try again.');
// Don't set API error for banners - it's not critical
setBanners([]);
} finally {
setIsLoadingBanners(false);
@@ -477,6 +543,69 @@ const HomePage: React.FC = () => {
<SearchRoomForm className="overlay" />
</BannerCarousel>
</div>
) : pageContent ? (
(() => {
// Check for valid local video first
const videoUrl = pageContent.hero_video_url;
if (videoUrl && (
videoUrl.startsWith('/') ||
videoUrl.startsWith('http://localhost') ||
videoUrl.startsWith('https://localhost') ||
videoUrl.startsWith(window.location.origin)
)) {
return (
<div className="relative w-full h-[600px] md:h-[700px] lg:h-[800px] overflow-hidden">
<video
autoPlay
loop
muted
playsInline
poster={pageContent.hero_video_poster || pageContent.hero_image || ''}
className="absolute inset-0 w-full h-full object-cover"
onError={(e) => {
console.warn('Video failed to load, using fallback image');
const videoElement = e.target as HTMLVideoElement;
videoElement.style.display = 'none';
}}
>
<source src={pageContent.hero_video_url} type="video/mp4" />
Your browser does not support the video tag.
</video>
{(pageContent.hero_video_poster || pageContent.hero_image) && (
<img
src={pageContent.hero_video_poster || pageContent.hero_image || ''}
alt="Hero background"
className="absolute inset-0 w-full h-full object-cover"
style={{ zIndex: -1 }}
/>
)}
<div className="absolute inset-0 bg-black/40"></div>
<div className="absolute inset-0 flex items-center justify-center z-10">
<SearchRoomForm className="overlay" />
</div>
</div>
);
}
// Check for hero image as fallback
if (pageContent.hero_image) {
return (
<div className="relative w-full h-[600px] md:h-[700px] lg:h-[800px] overflow-hidden">
<img
src={pageContent.hero_image}
alt="Hero background"
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/40"></div>
<div className="absolute inset-0 flex items-center justify-center z-10">
<SearchRoomForm className="overlay" />
</div>
</div>
);
}
return null;
})()
) : null}
</section>
@@ -486,6 +615,7 @@ const HomePage: React.FC = () => {
<div className="relative z-10">
{}
{(pageContent?.sections_enabled?.rooms !== false) && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 md:py-12 lg:py-16">
{}
<div className="text-center animate-fade-in mb-6 md:mb-8">
@@ -493,20 +623,20 @@ const HomePage: React.FC = () => {
<div className="h-0.5 w-16 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
</div>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-3 md:mb-4">
{pageContent?.hero_title || (apiError ? '' : 'Featured & Newest Rooms')}
{pageContent?.rooms_section_title || pageContent?.hero_title || (apiError ? '' : 'Featured & Newest Rooms')}
</h2>
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto px-4 leading-relaxed">
{pageContent?.hero_subtitle || pageContent?.description || (apiError ? '' : 'Discover our most popular accommodations and latest additions')}
{pageContent?.rooms_section_subtitle || pageContent?.hero_subtitle || pageContent?.description || (apiError ? '' : 'Discover our most popular accommodations and latest additions')}
</p>
{}
<div className="mt-6 md:mt-8 flex justify-center">
<Link
to="/rooms"
to={pageContent?.rooms_section_button_link || '/rooms'}
className="group relative inline-flex items-center gap-2 px-6 py-2.5 md:px-8 md:py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-semibold tracking-wide text-sm md:text-base shadow-md shadow-[#d4af37]/20 hover:shadow-lg hover:shadow-[#d4af37]/30 hover:-translate-y-0.5 transition-all duration-300 overflow-hidden"
>
<span className="absolute inset-0 bg-gradient-to-r from-[#f5d76e] to-[#d4af37] opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
<span className="relative z-10">View All Rooms</span>
<span className="relative z-10">{pageContent?.rooms_section_button_text || 'View All Rooms'}</span>
<ArrowRight className="w-4 h-4 md:w-5 md:h-5 relative z-10 group-hover:translate-x-1 transition-transform duration-300" />
</Link>
</div>
@@ -571,9 +701,10 @@ const HomePage: React.FC = () => {
</>
)}
</section>
)}
{}
{(() => {
{(pageContent?.sections_enabled?.features !== false) && (() => {
const validFeatures = pageContent?.features?.filter(
(f: any) => f && (f.title || f.description)
@@ -590,6 +721,24 @@ const HomePage: React.FC = () => {
{}
<div className="absolute inset-0 opacity-[0.015] bg-[radial-gradient(circle_at_1px_1px,#d4af37_1px,transparent_0)] bg-[length:40px_40px]"></div>
{(pageContent?.features_section_title || pageContent?.features_section_subtitle) && (
<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.features_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.features_section_title}
</h2>
)}
{pageContent.features_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.features_section_subtitle}
</p>
)}
</div>
)}
<div className="relative grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
{validFeatures.length > 0 ? (
validFeatures.map((feature: any, index: number) => (
@@ -638,7 +787,7 @@ const HomePage: React.FC = () => {
})()}
{}
{(pageContent?.luxury_section_title || (pageContent?.luxury_features && pageContent.luxury_features.length > 0)) && (
{(pageContent?.sections_enabled?.luxury !== false) && (pageContent?.luxury_section_title || (pageContent?.luxury_features && pageContent.luxury_features.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">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
@@ -693,7 +842,7 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.luxury_gallery && Array.isArray(pageContent.luxury_gallery) && pageContent.luxury_gallery.length > 0 && (
{(pageContent?.sections_enabled?.gallery !== false) && pageContent?.luxury_gallery && Array.isArray(pageContent.luxury_gallery) && pageContent.luxury_gallery.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">
@@ -761,7 +910,7 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.luxury_testimonials && pageContent.luxury_testimonials.length > 0 && (
{(pageContent?.sections_enabled?.testimonials !== false) && pageContent?.luxury_testimonials && pageContent.luxury_testimonials.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">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
@@ -775,9 +924,6 @@ const HomePage: React.FC = () => {
{pageContent.luxury_testimonials_section_subtitle}
</p>
)}
<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">
Hear from our valued guests about their luxury stay
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 md:gap-6 px-4">
{pageContent.luxury_testimonials.map((testimonial, index) => (
@@ -812,13 +958,31 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.stats && Array.isArray(pageContent.stats) && pageContent.stats.length > 0 && (
{(pageContent?.sections_enabled?.stats !== false) && pageContent?.stats && Array.isArray(pageContent.stats) && pageContent.stats.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="relative bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 rounded-xl md:rounded-2xl p-6 md:p-8 lg:p-10 shadow-xl shadow-black/30 animate-fade-in overflow-hidden border border-[#d4af37]/15">
{}
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-[#d4af37] via-[#f5d76e] to-[#d4af37]"></div>
<div className="absolute inset-0 opacity-8 bg-[radial-gradient(circle_at_1px_1px,#d4af37_1px,transparent_0)] bg-[length:40px_40px]"></div>
{(pageContent?.stats_section_title || pageContent?.stats_section_subtitle) && (
<div className="text-center mb-8 md:mb-10 animate-fade-in relative z-10">
<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.stats_section_title && (
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-white tracking-tight mb-3 md:mb-4 px-4">
{pageContent.stats_section_title}
</h2>
)}
{pageContent.stats_section_subtitle && (
<p className="text-sm sm:text-base md:text-lg text-gray-300 font-light tracking-wide max-w-2xl mx-auto mt-3 md:mt-4 px-4 leading-relaxed">
{pageContent.stats_section_subtitle}
</p>
)}
</div>
)}
<div className="relative grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6 lg:gap-8">
{pageContent.stats.map((stat, index) => (
<div key={`stat-${index}-${stat.label || index}`} className="text-center group relative">
@@ -851,7 +1015,7 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.amenities && pageContent.amenities.length > 0 && (
{(pageContent?.sections_enabled?.amenities !== false) && pageContent?.amenities && pageContent.amenities.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">
@@ -896,56 +1060,9 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.testimonials && pageContent.testimonials.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">
<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>
<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.testimonials_section_title || 'Guest Testimonials'}
</h2>
<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.testimonials_section_subtitle || 'See what our guests say about their experience'}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 md:gap-6 px-4">
{pageContent.testimonials.map((testimonial, index) => (
<div key={index} className="relative bg-white rounded-lg md:rounded-xl p-5 md:p-6 shadow-lg shadow-gray-900/5 hover:shadow-xl hover:shadow-[#d4af37]/8 transition-all duration-300 animate-fade-in border border-gray-100/50 hover:border-[#d4af37]/25 hover:-translate-y-1" style={{ animationDelay: `${index * 0.1}s` }}>
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] opacity-0 hover:opacity-100 transition-opacity duration-300 rounded-t-lg md:rounded-t-xl"></div>
<div className="flex items-center mb-4 md:mb-5">
{testimonial.image ? (
<div className="relative">
<img src={testimonial.image} alt={testimonial.name} className="w-12 h-12 md:w-14 md:h-14 rounded-full object-cover mr-3 md:mr-4 border-2 border-[#d4af37]/20 shadow-md" />
<div className="absolute inset-0 rounded-full border border-[#d4af37]/40"></div>
</div>
) : (
<div className="w-12 h-12 md:w-14 md:h-14 rounded-full bg-gradient-to-br from-[#d4af37] to-[#f5d76e] flex items-center justify-center text-white font-bold text-base md:text-lg mr-3 md:mr-4 shadow-md border-2 border-white">
{testimonial.name.charAt(0).toUpperCase()}
</div>
)}
<div>
<h4 className="font-semibold text-gray-900 text-base md:text-lg">{testimonial.name}</h4>
<p className="text-xs md:text-sm text-gray-500 font-light">{testimonial.role}</p>
</div>
</div>
<div className="flex mb-3 md:mb-4 justify-center md:justify-start">
{[...Array(5)].map((_, i) => (
<span key={i} className={`text-base md:text-lg ${i < testimonial.rating ? 'text-[#d4af37]' : 'text-gray-300'}`}>★</span>
))}
</div>
<div className="relative">
<div className="absolute -top-1 -left-1 text-4xl md:text-5xl text-[#d4af37]/8 font-serif">"</div>
<p className="text-sm md:text-base text-gray-700 leading-relaxed italic relative z-10 font-light">"{testimonial.comment}"</p>
</div>
</div>
))}
</div>
</section>
)}
{}
{(pageContent?.about_preview_title || pageContent?.about_preview_content) && (
{(pageContent?.sections_enabled?.about_preview !== false) && (pageContent?.about_preview_title || pageContent?.about_preview_content) && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="relative bg-white rounded-xl md:rounded-2xl shadow-xl shadow-[#d4af37]/5 overflow-hidden animate-fade-in border border-gray-100/50">
{}
@@ -995,7 +1112,7 @@ const HomePage: React.FC = () => {
)}
{}
{services.length > 0 && (
{(pageContent?.sections_enabled?.services !== false) && services.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">
@@ -1011,7 +1128,7 @@ const HomePage: React.FC = () => {
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 md:gap-6 px-4">
{services.slice(0, 6).map((service, index: number) => {
{services.slice(0, pageContent?.services_section_limit || 6).map((service, index: number) => {
const serviceSlug = service.slug || service.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
return (
<Link
@@ -1047,10 +1164,10 @@ const HomePage: React.FC = () => {
</div>
<div className="text-center mt-8 md:mt-10">
<Link
to="/services"
to={pageContent?.services_section_button_link || '/services'}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-medium hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 shadow-lg shadow-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/40 hover:-translate-y-0.5"
>
<span>View All Services</span>
<span>{pageContent?.services_section_button_text || 'View All Services'}</span>
<ArrowRight className="w-5 h-5" />
</Link>
</div>
@@ -1058,7 +1175,7 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.luxury_experiences && pageContent.luxury_experiences.length > 0 && (
{(pageContent?.sections_enabled?.experiences !== false) && pageContent?.luxury_experiences && pageContent.luxury_experiences.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">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
@@ -1105,7 +1222,7 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.awards && pageContent.awards.length > 0 && (
{(pageContent?.sections_enabled?.awards !== false) && pageContent?.awards && pageContent.awards.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">
@@ -1194,7 +1311,7 @@ const HomePage: React.FC = () => {
)}
{}
{pageContent?.partners && pageContent.partners.length > 0 && (
{(pageContent?.sections_enabled?.partners !== false) && pageContent?.partners && pageContent.partners.length > 0 && (
<section className="w-full py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
@@ -1220,6 +1337,200 @@ const HomePage: React.FC = () => {
</section>
)}
{/* Trust Badges Section */}
{(pageContent?.sections_enabled?.trust_badges !== false) && pageContent?.trust_badges_enabled && pageContent?.trust_badges && Array.isArray(pageContent.trust_badges) && pageContent.trust_badges.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
<div className="text-center mb-8 md:mb-10 animate-fade-in">
<div className="inline-block mb-3">
<div className="h-0.5 w-16 md:w-20 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
</div>
{pageContent.trust_badges_section_title && (
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-3 md:mb-4 px-4">
{pageContent.trust_badges_section_title}
</h2>
)}
{pageContent.trust_badges_section_subtitle && (
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto mt-3 md:mt-4 px-4 leading-relaxed">
{pageContent.trust_badges_section_subtitle}
</p>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6 md:gap-8 px-4">
{pageContent.trust_badges.map((badge, index) => (
<div key={index} className="flex flex-col items-center text-center group">
{badge.link ? (
<a href={badge.link} target="_blank" rel="noopener noreferrer" className="w-full">
<div className="w-24 h-24 md:w-32 md:h-32 mx-auto mb-4 rounded-lg overflow-hidden bg-white p-4 shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-200 hover:border-[#d4af37]/50 group-hover:scale-105">
{badge.logo && (
<img src={badge.logo} alt={badge.name} className="w-full h-full object-contain" />
)}
</div>
{badge.name && (
<h4 className="text-sm md:text-base font-semibold text-gray-900 mt-2">{badge.name}</h4>
)}
{badge.description && (
<p className="text-xs md:text-sm text-gray-600 mt-1">{badge.description}</p>
)}
</a>
) : (
<>
<div className="w-24 h-24 md:w-32 md:h-32 mx-auto mb-4 rounded-lg overflow-hidden bg-white p-4 shadow-lg border border-gray-200">
{badge.logo && (
<img src={badge.logo} alt={badge.name} className="w-full h-full object-contain" />
)}
</div>
{badge.name && (
<h4 className="text-sm md:text-base font-semibold text-gray-900 mt-2">{badge.name}</h4>
)}
{badge.description && (
<p className="text-xs md:text-sm text-gray-600 mt-1">{badge.description}</p>
)}
</>
)}
</div>
))}
</div>
</section>
)}
{/* Promotions Section */}
{(pageContent?.sections_enabled?.promotions !== false) && pageContent?.promotions_enabled && pageContent?.promotions && Array.isArray(pageContent.promotions) && pageContent.promotions.length > 0 && (
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
<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.promotions_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.promotions_section_title}
</h2>
)}
{pageContent.promotions_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.promotions_section_subtitle}
</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 px-4">
{pageContent.promotions.map((promo, index) => (
<div key={index} className="relative bg-white rounded-xl shadow-xl overflow-hidden border border-gray-200 hover:shadow-2xl transition-all duration-300 group">
{promo.image && (
<div className="relative h-48 overflow-hidden">
<img src={promo.image} alt={promo.title} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
{promo.discount && (
<div className="absolute top-4 right-4 bg-red-600 text-white px-4 py-2 rounded-full font-bold text-lg shadow-lg">
{promo.discount}
</div>
)}
</div>
)}
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 mb-2">{promo.title}</h3>
{promo.description && (
<p className="text-gray-600 mb-4">{promo.description}</p>
)}
{promo.valid_until && (
<p className="text-sm text-gray-500 mb-4">Valid until: {new Date(promo.valid_until).toLocaleDateString()}</p>
)}
{promo.link && (
<Link
to={promo.link}
className="inline-flex items-center gap-2 px-6 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-semibold hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300"
>
{promo.button_text || 'Learn More'}
<ArrowRight className="w-4 h-4" />
</Link>
)}
</div>
</div>
))}
</div>
</section>
)}
{/* Blog Section */}
{(pageContent?.sections_enabled?.blog !== false) && pageContent?.blog_enabled && (
<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">
<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.blog_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.blog_section_title}
</h2>
)}
{pageContent.blog_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.blog_section_subtitle}
</p>
)}
</div>
{isLoadingBlog ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 px-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-xl shadow-lg overflow-hidden border border-gray-200 animate-pulse">
<div className="h-48 bg-gray-200"></div>
<div className="p-6">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
</div>
))}
</div>
) : blogPosts.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 px-4">
{blogPosts.map((post) => (
<Link
key={post.id}
to={`/blog/${post.slug}`}
className="group bg-white rounded-xl shadow-lg overflow-hidden border border-gray-200 hover:shadow-2xl transition-all duration-300 hover:-translate-y-1"
>
{post.featured_image && (
<div className="relative h-48 overflow-hidden">
<img
src={post.featured_image}
alt={post.title}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
</div>
)}
<div className="p-6">
{post.published_at && (
<p className="text-xs text-gray-500 mb-2">
{new Date(post.published_at).toLocaleDateString()}
</p>
)}
<h3 className="text-xl font-bold text-gray-900 mb-2 group-hover:text-[#d4af37] transition-colors">
{post.title}
</h3>
{post.excerpt && (
<p className="text-gray-600 mb-4 line-clamp-3">{post.excerpt}</p>
)}
<div className="flex items-center gap-2 text-[#d4af37] font-semibold group-hover:gap-3 transition-all">
Read More
<ArrowRight className="w-4 h-4" />
</div>
</div>
</Link>
))}
</div>
) : null}
{blogPosts.length > 0 && (
<div className="text-center mt-8 md:mt-10">
<Link
to="/blog"
className="inline-flex items-center gap-2 px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-semibold hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 shadow-lg shadow-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/40 hover:-translate-y-0.5"
>
View All Posts
<ArrowRight className="w-5 h-5" />
</Link>
</div>
)}
</section>
)}
</div>
</div>

View File

@@ -38,9 +38,13 @@ export interface PageContent {
hero_title?: string;
hero_subtitle?: string;
hero_image?: string;
hero_video_url?: string;
hero_video_poster?: string;
story_content?: string;
values?: Array<{ icon?: string; title: string; description: string }>;
features?: Array<{ icon?: string; title: string; description: string; image?: string }>;
features_section_title?: string;
features_section_subtitle?: string;
about_hero_image?: string;
mission?: string;
vision?: string;
@@ -72,8 +76,18 @@ export interface PageContent {
about_preview_content?: string;
about_preview_image?: string;
stats?: Array<{ number: string; label: string; icon?: string }>;
stats_section_title?: string;
stats_section_subtitle?: string;
rooms_section_title?: string;
rooms_section_subtitle?: string;
rooms_section_button_text?: string;
rooms_section_button_link?: string;
rooms_section_enabled?: boolean;
luxury_services_section_title?: string;
luxury_services_section_subtitle?: string;
services_section_button_text?: string;
services_section_button_link?: string;
services_section_limit?: number;
luxury_services?: Array<{
icon?: string;
title: string;
@@ -115,6 +129,50 @@ export interface PageContent {
partners_section_title?: string;
partners_section_subtitle?: string;
partners?: Array<{ name: string; logo: string; link?: string }>;
sections_enabled?: {
features?: boolean;
luxury?: boolean;
gallery?: boolean;
testimonials?: boolean;
stats?: boolean;
amenities?: boolean;
about_preview?: boolean;
services?: boolean;
experiences?: boolean;
awards?: boolean;
cta?: boolean;
partners?: boolean;
rooms?: boolean;
newsletter?: boolean;
trust_badges?: boolean;
promotions?: boolean;
blog?: boolean;
};
newsletter_section_title?: string;
newsletter_section_subtitle?: string;
newsletter_placeholder?: string;
newsletter_button_text?: string;
newsletter_enabled?: boolean;
trust_badges_section_title?: string;
trust_badges_section_subtitle?: string;
trust_badges?: Array<{ name: string; logo: string; description?: string; link?: string }>;
trust_badges_enabled?: boolean;
promotions_section_title?: string;
promotions_section_subtitle?: string;
promotions?: Array<{
title: string;
description: string;
image?: string;
discount?: string;
valid_until?: string;
link?: string;
button_text?: string;
}>;
promotions_enabled?: boolean;
blog_section_title?: string;
blog_section_subtitle?: string;
blog_posts_limit?: number;
blog_enabled?: boolean;
is_active?: boolean;
created_at?: string;
updated_at?: string;
@@ -163,9 +221,13 @@ export interface UpdatePageContentData {
hero_title?: string;
hero_subtitle?: string;
hero_image?: string;
hero_video_url?: string;
hero_video_poster?: string;
story_content?: string;
values?: Array<{ icon?: string; title: string; description: string }>;
features?: Array<{ icon?: string; title: string; description: string; image?: string }>;
features_section_title?: string;
features_section_subtitle?: string;
about_hero_image?: string;
mission?: string;
vision?: string;
@@ -197,8 +259,18 @@ export interface UpdatePageContentData {
about_preview_content?: string;
about_preview_image?: string;
stats?: Array<{ number: string; label: string; icon?: string }>;
stats_section_title?: string;
stats_section_subtitle?: string;
rooms_section_title?: string;
rooms_section_subtitle?: string;
rooms_section_button_text?: string;
rooms_section_button_link?: string;
rooms_section_enabled?: boolean;
luxury_services_section_title?: string;
luxury_services_section_subtitle?: string;
services_section_button_text?: string;
services_section_button_link?: string;
services_section_limit?: number;
luxury_services?: Array<{
icon?: string;
title: string;
@@ -240,6 +312,50 @@ export interface UpdatePageContentData {
partners_section_title?: string;
partners_section_subtitle?: string;
partners?: Array<{ name: string; logo: string; link?: string }>;
sections_enabled?: {
features?: boolean;
luxury?: boolean;
gallery?: boolean;
testimonials?: boolean;
stats?: boolean;
amenities?: boolean;
about_preview?: boolean;
services?: boolean;
experiences?: boolean;
awards?: boolean;
cta?: boolean;
partners?: boolean;
rooms?: boolean;
newsletter?: boolean;
trust_badges?: boolean;
promotions?: boolean;
blog?: boolean;
};
newsletter_section_title?: string;
newsletter_section_subtitle?: string;
newsletter_placeholder?: string;
newsletter_button_text?: string;
newsletter_enabled?: boolean;
trust_badges_section_title?: string;
trust_badges_section_subtitle?: string;
trust_badges?: Array<{ name: string; logo: string; description?: string; link?: string }>;
trust_badges_enabled?: boolean;
promotions_section_title?: string;
promotions_section_subtitle?: string;
promotions?: Array<{
title: string;
description: string;
image?: string;
discount?: string;
valid_until?: string;
link?: string;
button_text?: string;
}>;
promotions_enabled?: boolean;
blog_section_title?: string;
blog_section_subtitle?: string;
blog_posts_limit?: number;
blog_enabled?: boolean;
is_active?: boolean;
}
@@ -404,6 +520,15 @@ const pageContentService = {
if (data.achievements) {
updateData.achievements = data.achievements;
}
if (data.trust_badges) {
updateData.trust_badges = data.trust_badges;
}
if (data.promotions) {
updateData.promotions = data.promotions;
}
if (data.sections_enabled) {
updateData.sections_enabled = data.sections_enabled;
}
const response = await apiClient.put<PageContentResponse>(
`/page-content/${pageType}`,

View File

@@ -97,6 +97,7 @@ class EmailCampaignService {
reply_to_email?: string;
track_opens?: boolean;
track_clicks?: boolean;
recipient_type?: 'users' | 'subscribers' | 'both';
}): Promise<{ campaign_id: number }> {
const response = await apiClient.post('/email-campaigns', data);
return response.data;
@@ -165,6 +166,26 @@ class EmailCampaignService {
return response.data;
}
// Newsletter Subscribers
async getNewsletterSubscribers(params?: {
page?: number;
limit?: number;
}): Promise<{
subscribers: Array<{
email: string;
user_id: number | null;
name: string | null;
type: string;
}>;
total: number;
page: number;
limit: number;
total_pages: number;
}> {
const response = await apiClient.get('/email-campaigns/newsletter/subscribers', { params });
return response.data.data;
}
async createDripSequence(data: {
name: string;
description?: string;

View File

@@ -6,7 +6,9 @@ import {
FileText,
X,
Layers,
Target
Target,
Users,
Search as SearchIcon
} from 'lucide-react';
import { emailCampaignService, Campaign, CampaignSegment, EmailTemplate, DripSequence, CampaignAnalytics } from '../../features/notifications/services/emailCampaignService';
import { toast } from 'react-toastify';
@@ -54,7 +56,8 @@ const EmailCampaignManagementPage: React.FC = () => {
from_name: '',
from_email: '',
track_opens: true,
track_clicks: true
track_clicks: true,
recipient_type: 'users' as 'users' | 'subscribers' | 'both'
});
const [segmentForm, setSegmentForm] = useState({
@@ -557,7 +560,12 @@ const SegmentsTab: React.FC<{
segments: CampaignSegment[];
onRefresh: () => void;
onCreate: () => void;
}> = ({ segments, onCreate }) => (
}> = ({ segments, onCreate }) => {
const [showSubscribersModal, setShowSubscribersModal] = React.useState(false);
const newsletterSegment = segments.find(s => s.name === "Newsletter Subscribers");
return (
<>
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-xl font-semibold">Segments</h3>
@@ -573,14 +581,32 @@ const SegmentsTab: React.FC<{
<div key={segment.id} className="border rounded-xl p-4">
<h4 className="font-semibold">{segment.name}</h4>
<p className="text-sm text-gray-600 mt-1">{segment.description}</p>
<p className="text-sm text-blue-600 mt-2">
<div className="flex items-center justify-between mt-2">
<p className="text-sm text-blue-600">
Estimated: {segment.estimated_count || 0} users
</p>
{segment.name === "Newsletter Subscribers" && (
<button
onClick={() => setShowSubscribersModal(true)}
className="px-3 py-1 text-sm bg-green-500 text-white rounded-lg hover:bg-green-600"
>
View Subscribers
</button>
)}
</div>
</div>
))}
</div>
</div>
{showSubscribersModal && newsletterSegment && (
<SubscribersModal
segment={newsletterSegment}
onClose={() => setShowSubscribersModal(false)}
/>
)}
</>
);
};
const TemplatesTab: React.FC<{
templates: EmailTemplate[];
@@ -726,6 +752,15 @@ const CampaignModal: React.FC<{
<option value="abandoned_booking">Abandoned Booking</option>
<option value="welcome">Welcome</option>
</select>
<select
value={form.recipient_type || 'users'}
onChange={(e) => setForm({ ...form, recipient_type: e.target.value as 'users' | 'subscribers' | 'both' })}
className="w-full px-4 py-3 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 cursor-pointer"
>
<option value="users">All Platform Users</option>
<option value="subscribers">Newsletter Subscribers Only</option>
<option value="both">Both (Users + Subscribers)</option>
</select>
<select
value={form.segment_id || ''}
onChange={(e) => setForm({ ...form, segment_id: e.target.value ? parseInt(e.target.value) : undefined })}
@@ -957,5 +992,140 @@ const DripSequenceModal: React.FC<{
</div>
);
const SubscribersModal: React.FC<{
segment: CampaignSegment;
onClose: () => void;
}> = ({ segment, onClose }) => {
const [subscribers, setSubscribers] = useState<Array<{
email: string;
user_id: number | null;
name: string | null;
type: string;
}>>([]);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState('');
const limit = 20;
useEffect(() => {
fetchSubscribers();
}, [currentPage]);
const fetchSubscribers = async () => {
setLoading(true);
try {
const data = await emailCampaignService.getNewsletterSubscribers({
page: currentPage,
limit: limit
});
setSubscribers(data.subscribers);
setTotalPages(data.total_pages);
setTotal(data.total);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to fetch subscribers');
} finally {
setLoading(false);
}
};
const filteredSubscribers = subscribers.filter(sub =>
sub.email.toLowerCase().includes(search.toLowerCase()) ||
(sub.name && sub.name.toLowerCase().includes(search.toLowerCase()))
);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col">
<div className="p-6 border-b flex items-center justify-between">
<div>
<h3 className="text-2xl font-bold text-gray-900">{segment.name}</h3>
<p className="text-sm text-gray-600 mt-1">{segment.description}</p>
<p className="text-sm text-blue-600 mt-1">Total: {total} subscribers</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6 flex-1 overflow-y-auto">
<div className="mb-4">
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
{loading ? (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
) : filteredSubscribers.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No subscribers found
</div>
) : (
<div className="space-y-2">
{filteredSubscribers.map((subscriber, index) => (
<div
key={index}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50"
>
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<Users className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="font-medium text-gray-900">{subscriber.email}</p>
{subscriber.name && (
<p className="text-sm text-gray-500">{subscriber.name}</p>
)}
</div>
</div>
<span className="px-3 py-1 text-xs bg-green-100 text-green-800 rounded-full">
{subscriber.type}
</span>
</div>
))}
</div>
)}
</div>
{totalPages > 1 && (
<div className="p-6 border-t flex items-center justify-between">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-4 py-2 border rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-4 py-2 border rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Next
</button>
</div>
)}
</div>
</div>
);
};
export default EmailCampaignManagementPage;

View File

@@ -0,0 +1,651 @@
import React, { useEffect, useState } from 'react';
import { Search, Plus, Edit, Eye, CheckCircle, Clock, X, Filter, FileCheck, AlertCircle } from 'lucide-react';
import advancedRoomService, { RoomInspection } from '../../features/rooms/services/advancedRoomService';
import roomService, { Room } from '../../features/rooms/services/roomService';
import userService, { User } from '../../features/auth/services/userService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { formatDate } from '../../shared/utils/format';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
const InspectionManagementPage: React.FC = () => {
const [inspections, setInspections] = useState<RoomInspection[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingInspection, setEditingInspection] = useState<RoomInspection | null>(null);
const [viewingInspection, setViewingInspection] = useState<RoomInspection | null>(null);
const [rooms, setRooms] = useState<Room[]>([]);
const [staffMembers, setStaffMembers] = useState<User[]>([]);
const [filters, setFilters] = useState({
search: '',
status: '',
inspection_type: '',
room_id: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 20;
const [formData, setFormData] = useState({
room_id: 0,
booking_id: 0,
inspection_type: 'routine',
scheduled_at: new Date(),
inspected_by: 0,
checklist_items: [] as any[],
});
const inspectionTypes = [
{ value: 'pre_checkin', label: 'Pre Check-in', color: 'bg-blue-100 text-blue-800' },
{ value: 'post_checkout', label: 'Post Check-out', color: 'bg-green-100 text-green-800' },
{ value: 'routine', label: 'Routine', color: 'bg-purple-100 text-purple-800' },
{ value: 'maintenance', label: 'Maintenance', color: 'bg-orange-100 text-orange-800' },
{ value: 'damage', label: 'Damage', color: 'bg-red-100 text-red-800' },
];
const statuses = [
{ value: 'pending', label: 'Pending', color: 'bg-yellow-100 text-yellow-800' },
{ value: 'in_progress', label: 'In Progress', color: 'bg-blue-100 text-blue-800' },
{ value: 'completed', label: 'Completed', color: 'bg-green-100 text-green-800' },
{ value: 'failed', label: 'Failed', color: 'bg-red-100 text-red-800' },
{ value: 'cancelled', label: 'Cancelled', color: 'bg-gray-100 text-gray-800' },
];
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchInspections();
fetchRooms();
fetchStaffMembers();
}, [filters, currentPage]);
const fetchInspections = async () => {
try {
setLoading(true);
const params: any = {
page: currentPage,
limit: itemsPerPage,
};
if (filters.status) params.status = filters.status;
if (filters.inspection_type) params.inspection_type = filters.inspection_type;
if (filters.room_id) params.room_id = parseInt(filters.room_id);
const response = await advancedRoomService.getRoomInspections(params);
if (response.status === 'success' && response.data) {
let inspectionList = response.data.inspections || [];
if (filters.search) {
inspectionList = inspectionList.filter((inspection: RoomInspection) =>
inspection.room_number?.toLowerCase().includes(filters.search.toLowerCase()) ||
inspection.inspector_name?.toLowerCase().includes(filters.search.toLowerCase())
);
}
setInspections(inspectionList);
setTotalPages(response.data.pagination?.total_pages || 1);
setTotalItems(response.data.pagination?.total || 0);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load inspections');
} finally {
setLoading(false);
}
};
const fetchRooms = async () => {
try {
const response = await roomService.getRooms({ limit: 1000 });
if (response.data && response.data.rooms) {
setRooms(response.data.rooms);
}
} catch (error) {
console.error('Error fetching rooms:', error);
}
};
const fetchStaffMembers = async () => {
try {
const response = await userService.getUsers({ role: 'staff', limit: 100 });
if (response.data && response.data.users) {
setStaffMembers(response.data.users);
}
} catch (error) {
console.error('Error fetching staff members:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.room_id && !editingInspection) {
toast.error('Please select a room');
return;
}
try {
if (editingInspection) {
// Update existing inspection
const updateData: any = {
status: editingInspection.status,
};
if (formData.inspected_by && formData.inspected_by !== editingInspection.inspected_by) {
// Reassignment would need a different endpoint
}
await advancedRoomService.updateRoomInspection(editingInspection.id, updateData);
toast.success('Inspection updated successfully');
} else {
// Create new inspection
const dataToSubmit: any = {
room_id: formData.room_id,
inspection_type: formData.inspection_type,
scheduled_at: formData.scheduled_at.toISOString(),
checklist_items: formData.checklist_items,
};
if (formData.booking_id) {
dataToSubmit.booking_id = formData.booking_id;
}
if (formData.inspected_by) {
dataToSubmit.inspected_by = formData.inspected_by;
}
await advancedRoomService.createRoomInspection(dataToSubmit);
toast.success('Inspection created successfully');
}
setShowModal(false);
setEditingInspection(null);
resetForm();
fetchInspections();
} catch (error: any) {
toast.error(error.response?.data?.message || `Unable to ${editingInspection ? 'update' : 'create'} inspection`);
}
};
const handleStatusUpdate = async (inspectionId: number, newStatus: string) => {
try {
await advancedRoomService.updateRoomInspection(inspectionId, {
status: newStatus,
});
toast.success('Inspection status updated successfully');
fetchInspections();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
}
};
const resetForm = () => {
setFormData({
room_id: 0,
booking_id: 0,
inspection_type: 'routine',
scheduled_at: new Date(),
inspected_by: 0,
checklist_items: [],
});
};
const getStatusBadge = (status: string) => {
const statusObj = statuses.find((s) => s.value === status);
return statusObj || statuses[0];
};
const getTypeBadge = (type: string) => {
const typeObj = inspectionTypes.find((t) => t.value === type);
return typeObj || inspectionTypes[2];
};
const getPendingInspections = () => inspections.filter((i) => i.status === 'pending').length;
const getCompletedInspections = () => inspections.filter((i) => i.status === 'completed').length;
const getFailedInspections = () => inspections.filter((i) => i.status === 'failed').length;
if (loading && inspections.length === 0) {
return <Loading fullScreen text="Loading inspections..." />;
}
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Inspection Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage room inspections and quality control</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="w-5 h-5" />
New Inspection
</button>
</div>
{/* Filters */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search inspections..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 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 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-3.5 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 hover:shadow-md cursor-pointer"
>
<option value="">All Statuses</option>
{statuses.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
<select
value={filters.inspection_type}
onChange={(e) => setFilters({ ...filters, inspection_type: e.target.value })}
className="px-4 py-3.5 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 hover:shadow-md cursor-pointer"
>
<option value="">All Types</option>
{inspectionTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<select
value={filters.room_id}
onChange={(e) => setFilters({ ...filters, room_id: e.target.value })}
className="px-4 py-3.5 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 hover:shadow-md cursor-pointer"
>
<option value="">All Rooms</option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>
Room {room.room_number}
</option>
))}
</select>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Total Inspections</p>
<p className="text-3xl font-bold text-slate-900 mt-2">{totalItems}</p>
</div>
<FileCheck className="w-12 h-12 text-amber-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Pending</p>
<p className="text-3xl font-bold text-yellow-600 mt-2">{getPendingInspections()}</p>
</div>
<Clock className="w-12 h-12 text-yellow-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Completed</p>
<p className="text-3xl font-bold text-green-600 mt-2">{getCompletedInspections()}</p>
</div>
<CheckCircle className="w-12 h-12 text-green-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Failed</p>
<p className="text-3xl font-bold text-red-600 mt-2">{getFailedInspections()}</p>
</div>
<AlertCircle className="w-12 h-12 text-red-500" />
</div>
</div>
</div>
{/* Inspections Table */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b-2 border-slate-200">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Inspection
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Room
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Scheduled
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Inspector
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Score
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{inspections.map((inspection) => {
const statusBadge = getStatusBadge(inspection.status);
const typeBadge = getTypeBadge(inspection.inspection_type);
return (
<tr key={inspection.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div className="font-semibold text-slate-900">
{inspectionTypes.find((t) => t.value === inspection.inspection_type)?.label || 'Inspection'}
</div>
{inspection.overall_notes && (
<div className="text-sm text-slate-500 mt-1 line-clamp-2">{inspection.overall_notes}</div>
)}
</td>
<td className="px-6 py-4">
<div className="font-semibold text-slate-900">
Room {inspection.room_number || inspection.room_id}
</div>
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${typeBadge.color}`}>
{typeBadge.label}
</span>
</td>
<td className="px-6 py-4">
<div className="text-slate-900 font-medium">
{formatDate(inspection.scheduled_at)}
</div>
{inspection.completed_at && (
<div className="text-xs text-slate-500 mt-1">
Completed: {formatDate(inspection.completed_at)}
</div>
)}
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusBadge.color}`}>
{statusBadge.label}
</span>
</td>
<td className="px-6 py-4 text-slate-600">
{inspection.inspector_name || 'Unassigned'}
</td>
<td className="px-6 py-4">
{inspection.overall_score !== null && inspection.overall_score !== undefined ? (
<div className="flex items-center gap-1">
<span className="font-semibold text-slate-900">{inspection.overall_score}</span>
<span className="text-slate-500 text-sm">/ 5</span>
</div>
) : (
<span className="text-slate-400"></span>
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setViewingInspection(inspection)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="View Details"
>
<Eye className="w-5 h-5" />
</button>
<button
onClick={() => handleEdit(inspection)}
className="p-2 text-amber-600 hover:bg-amber-50 rounded-lg transition-colors"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
{inspection.status !== 'completed' && inspection.status !== 'cancelled' && (
<select
value={inspection.status}
onChange={(e) => handleStatusUpdate(inspection.id, e.target.value)}
className="px-3 py-1.5 text-sm border-2 border-slate-200 rounded-lg focus:border-amber-400 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
{statuses.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{inspections.length === 0 && !loading && (
<div className="text-center py-12">
<FileCheck className="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 text-lg">No inspections found</p>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">
{editingInspection ? 'Edit Inspection' : 'Create Inspection'}
</h2>
<button
onClick={() => {
setShowModal(false);
setEditingInspection(null);
resetForm();
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Room *</label>
<select
value={formData.room_id}
onChange={(e) => setFormData({ ...formData, room_id: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
>
<option value={0}>Select Room</option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>
Room {room.room_number}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Inspection Type *</label>
<select
value={formData.inspection_type}
onChange={(e) => setFormData({ ...formData, inspection_type: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
>
{inspectionTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Scheduled At *</label>
<DatePicker
selected={formData.scheduled_at}
onChange={(date: Date) => setFormData({ ...formData, scheduled_at: date })}
showTimeSelect
dateFormat="MMMM d, yyyy h:mm aa"
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Inspector</label>
<select
value={formData.inspected_by}
onChange={(e) => setFormData({ ...formData, inspected_by: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
>
<option value={0}>Unassigned</option>
{staffMembers.map((staff) => (
<option key={staff.id} value={staff.id}>
{staff.full_name}
</option>
))}
</select>
</div>
</div>
<div className="flex gap-4 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowModal(false);
setEditingInspection(null);
resetForm();
}}
className="flex-1 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700"
>
{editingInspection ? 'Update Inspection' : 'Create Inspection'}
</button>
</div>
</form>
</div>
</div>
)}
{/* View Details Modal - Simplified */}
{viewingInspection && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">Inspection Details</h2>
<button
onClick={() => setViewingInspection(null)}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-slate-600">Room</p>
<p className="font-semibold">Room {viewingInspection.room_number || viewingInspection.room_id}</p>
</div>
<div>
<p className="text-sm text-slate-600">Type</p>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getTypeBadge(viewingInspection.inspection_type).color}`}>
{getTypeBadge(viewingInspection.inspection_type).label}
</span>
</div>
<div>
<p className="text-sm text-slate-600">Status</p>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusBadge(viewingInspection.status).color}`}>
{getStatusBadge(viewingInspection.status).label}
</span>
</div>
<div>
<p className="text-sm text-slate-600">Inspector</p>
<p className="font-semibold">{viewingInspection.inspector_name || 'Unassigned'}</p>
</div>
</div>
{viewingInspection.overall_score !== null && (
<div>
<p className="text-sm text-slate-600">Overall Score</p>
<p className="text-2xl font-bold">{viewingInspection.overall_score} / 5</p>
</div>
)}
{viewingInspection.overall_notes && (
<div>
<p className="text-sm text-slate-600 mb-2">Notes</p>
<p className="text-slate-900">{viewingInspection.overall_notes}</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
const handleEdit = (inspection: RoomInspection) => {
setEditingInspection(inspection);
setFormData({
room_id: inspection.room_id,
booking_id: inspection.booking_id || 0,
inspection_type: inspection.inspection_type,
scheduled_at: new Date(inspection.scheduled_at),
inspected_by: inspection.inspected_by || 0,
checklist_items: inspection.checklist_items || [],
});
setShowModal(true);
};
};
export default InspectionManagementPage;

View File

@@ -0,0 +1,808 @@
import React, { useEffect, useState } from 'react';
import { Search, Plus, Edit, Trash2, Package, AlertTriangle, TrendingDown, Filter, X } from 'lucide-react';
import inventoryService, { InventoryItem, ReorderRequest } from '../../features/inventory/services/inventoryService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { formatDate } from '../../shared/utils/format';
const InventoryManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [items, setItems] = useState<InventoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
const [showReorderModal, setShowReorderModal] = useState(false);
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
const [reorderRequests, setReorderRequests] = useState<ReorderRequest[]>([]);
const [lowStockItems, setLowStockItems] = useState<InventoryItem[]>([]);
const [filters, setFilters] = useState({
search: '',
category: '',
low_stock: false,
is_active: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 20;
const [formData, setFormData] = useState({
name: '',
description: '',
category: 'cleaning_supplies',
unit: 'piece',
minimum_quantity: 0,
maximum_quantity: 0,
reorder_quantity: 0,
unit_cost: 0,
supplier: '',
supplier_contact: '',
storage_location: '',
barcode: '',
sku: '',
notes: '',
is_active: true,
is_tracked: true,
});
const categories = [
{ value: 'cleaning_supplies', label: 'Cleaning Supplies' },
{ value: 'linens', label: 'Linens' },
{ value: 'toiletries', label: 'Toiletries' },
{ value: 'amenities', label: 'Amenities' },
{ value: 'maintenance', label: 'Maintenance' },
{ value: 'food_beverage', label: 'Food & Beverage' },
{ value: 'other', label: 'Other' },
];
const units = [
{ value: 'piece', label: 'Piece' },
{ value: 'box', label: 'Box' },
{ value: 'bottle', label: 'Bottle' },
{ value: 'roll', label: 'Roll' },
{ value: 'pack', label: 'Pack' },
{ value: 'liter', label: 'Liter' },
{ value: 'kilogram', label: 'Kilogram' },
{ value: 'meter', label: 'Meter' },
{ value: 'other', label: 'Other' },
];
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchItems();
fetchLowStockItems();
fetchReorderRequests();
}, [filters, currentPage]);
const fetchItems = async () => {
try {
setLoading(true);
const params: any = {
page: currentPage,
limit: itemsPerPage,
};
if (filters.category) params.category = filters.category;
if (filters.low_stock) params.low_stock = true;
if (filters.is_active !== '') params.is_active = filters.is_active === 'true';
const response = await inventoryService.getInventoryItems(params);
if (response.status === 'success' && response.data) {
let itemList = response.data.items || [];
if (filters.search) {
itemList = itemList.filter((item: InventoryItem) =>
item.name.toLowerCase().includes(filters.search.toLowerCase()) ||
item.description?.toLowerCase().includes(filters.search.toLowerCase()) ||
item.sku?.toLowerCase().includes(filters.search.toLowerCase()) ||
item.barcode?.toLowerCase().includes(filters.search.toLowerCase())
);
}
setItems(itemList);
setTotalPages(response.data.pagination?.total_pages || 1);
setTotalItems(response.data.pagination?.total || 0);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load inventory items');
} finally {
setLoading(false);
}
};
const fetchLowStockItems = async () => {
try {
const response = await inventoryService.getLowStockItems();
if (response.status === 'success' && response.data) {
setLowStockItems(response.data.items || []);
}
} catch (error) {
// Silently fail - not critical
}
};
const fetchReorderRequests = async () => {
try {
const response = await inventoryService.getReorderRequests({ status: 'pending' });
if (response.status === 'success' && response.data) {
setReorderRequests(response.data.requests || []);
}
} catch (error) {
// Silently fail - not critical
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingItem) {
await inventoryService.updateInventoryItem(editingItem.id, formData);
toast.success('Inventory item updated successfully');
} else {
await inventoryService.createInventoryItem(formData);
toast.success('Inventory item created successfully');
}
setShowModal(false);
resetForm();
fetchItems();
fetchLowStockItems();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to save inventory item');
}
};
const handleEdit = (item: InventoryItem) => {
setEditingItem(item);
setFormData({
name: item.name,
description: item.description || '',
category: item.category,
unit: item.unit,
minimum_quantity: item.minimum_quantity,
maximum_quantity: item.maximum_quantity || 0,
reorder_quantity: item.reorder_quantity || 0,
unit_cost: item.unit_cost || 0,
supplier: item.supplier || '',
supplier_contact: '',
storage_location: item.storage_location || '',
barcode: item.barcode || '',
sku: item.sku || '',
notes: '',
is_active: item.is_active,
is_tracked: item.is_tracked,
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this inventory item?')) {
return;
}
try {
await inventoryService.updateInventoryItem(id, { is_active: false });
toast.success('Inventory item deactivated successfully');
fetchItems();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete inventory item');
}
};
const resetForm = () => {
setEditingItem(null);
setFormData({
name: '',
description: '',
category: 'cleaning_supplies',
unit: 'piece',
minimum_quantity: 0,
maximum_quantity: 0,
reorder_quantity: 0,
unit_cost: 0,
supplier: '',
supplier_contact: '',
storage_location: '',
barcode: '',
sku: '',
notes: '',
is_active: true,
is_tracked: true,
});
};
const openReorderModal = (item: InventoryItem) => {
setSelectedItem(item);
setShowReorderModal(true);
};
const handleCreateReorder = async (quantity: number, priority: string, notes: string) => {
if (!selectedItem) return;
try {
await inventoryService.createReorderRequest({
item_id: selectedItem.id,
requested_quantity: quantity,
priority,
notes,
});
toast.success('Reorder request created successfully');
setShowReorderModal(false);
setSelectedItem(null);
fetchReorderRequests();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to create reorder request');
}
};
const getStockStatus = (item: InventoryItem) => {
if (!item.is_tracked) return { color: 'bg-slate-100 text-slate-700', label: 'Not Tracked' };
if (item.is_low_stock) return { color: 'bg-red-100 text-red-800', label: 'Low Stock' };
if (item.maximum_quantity && item.current_quantity >= item.maximum_quantity) {
return { color: 'bg-blue-100 text-blue-800', label: 'Max Stock' };
}
return { color: 'bg-green-100 text-green-800', label: 'In Stock' };
};
if (loading && items.length === 0) {
return <Loading fullScreen text="Loading inventory..." />;
}
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Inventory Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage hotel inventory and supplies</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="w-5 h-5" />
Add Item
</button>
</div>
{/* Alerts */}
{lowStockItems.length > 0 && (
<div className="bg-gradient-to-r from-red-50 to-orange-50 border-2 border-red-200 rounded-xl p-4 animate-fade-in">
<div className="flex items-center gap-3">
<AlertTriangle className="w-6 h-6 text-red-600" />
<div>
<h3 className="font-semibold text-red-900">Low Stock Alert</h3>
<p className="text-red-700 text-sm">
{lowStockItems.length} item{lowStockItems.length > 1 ? 's' : ''} below minimum quantity
</p>
</div>
</div>
</div>
)}
{/* Filters */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search items..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 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 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.category}
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
className="px-4 py-3.5 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 hover:shadow-md cursor-pointer"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
<select
value={filters.is_active}
onChange={(e) => setFilters({ ...filters, is_active: e.target.value })}
className="px-4 py-3.5 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 hover:shadow-md cursor-pointer"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<label className="flex items-center gap-3 px-4 py-3.5 bg-gradient-to-r from-slate-50 to-white border-2 border-slate-200 rounded-xl cursor-pointer hover:border-amber-400 transition-all">
<input
type="checkbox"
checked={filters.low_stock}
onChange={(e) => setFilters({ ...filters, low_stock: e.target.checked })}
className="w-5 h-5 text-amber-600 rounded focus:ring-amber-500"
/>
<span className="text-slate-700 font-medium">Low Stock Only</span>
</label>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Total Items</p>
<p className="text-3xl font-bold text-slate-900 mt-2">{totalItems}</p>
</div>
<Package className="w-12 h-12 text-amber-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Low Stock</p>
<p className="text-3xl font-bold text-red-600 mt-2">{lowStockItems.length}</p>
</div>
<AlertTriangle className="w-12 h-12 text-red-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Pending Orders</p>
<p className="text-3xl font-bold text-orange-600 mt-2">{reorderRequests.length}</p>
</div>
<TrendingDown className="w-12 h-12 text-orange-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Active Items</p>
<p className="text-3xl font-bold text-green-600 mt-2">
{items.filter((i) => i.is_active).length}
</p>
</div>
<Package className="w-12 h-12 text-green-500" />
</div>
</div>
</div>
{/* Items Table */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b-2 border-slate-200">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Item
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Category
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Quantity
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Location
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Cost
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{items.map((item) => {
const stockStatus = getStockStatus(item);
return (
<tr key={item.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div>
<div className="font-semibold text-slate-900">{item.name}</div>
{item.description && (
<div className="text-sm text-slate-500 mt-1">{item.description}</div>
)}
{item.sku && (
<div className="text-xs text-slate-400 mt-1">SKU: {item.sku}</div>
)}
</div>
</td>
<td className="px-6 py-4">
<span className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm font-medium">
{categories.find((c) => c.value === item.category)?.label || item.category}
</span>
</td>
<td className="px-6 py-4">
<div className="text-slate-900 font-semibold">
{item.current_quantity} {units.find((u) => u.value === item.unit)?.label || item.unit}
</div>
{item.is_tracked && (
<div className="text-xs text-slate-500 mt-1">
Min: {item.minimum_quantity}
</div>
)}
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${stockStatus.color}`}>
{stockStatus.label}
</span>
</td>
<td className="px-6 py-4 text-slate-600">
{item.storage_location || '—'}
</td>
<td className="px-6 py-4 text-slate-900 font-semibold">
{item.unit_cost ? formatCurrency(item.unit_cost) : '—'}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{item.is_low_stock && (
<button
onClick={() => openReorderModal(item)}
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
title="Reorder"
>
<TrendingDown className="w-5 h-5" />
</button>
)}
<button
onClick={() => handleEdit(item)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Delete"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{items.length === 0 && !loading && (
<div className="text-center py-12">
<Package className="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 text-lg">No inventory items found</p>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">
{editingItem ? 'Edit Inventory Item' : 'Add Inventory Item'}
</h2>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Item Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Category *
</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
>
{categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
rows={3}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Unit *
</label>
<select
value={formData.unit}
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
>
{units.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Min Quantity *
</label>
<input
type="number"
value={formData.minimum_quantity}
onChange={(e) => setFormData({ ...formData, minimum_quantity: parseFloat(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
min="0"
step="0.01"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Max Quantity
</label>
<input
type="number"
value={formData.maximum_quantity}
onChange={(e) => setFormData({ ...formData, maximum_quantity: parseFloat(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
min="0"
step="0.01"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Unit Cost
</label>
<input
type="number"
value={formData.unit_cost}
onChange={(e) => setFormData({ ...formData, unit_cost: parseFloat(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
min="0"
step="0.01"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Storage Location
</label>
<input
type="text"
value={formData.storage_location}
onChange={(e) => setFormData({ ...formData, storage_location: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Supplier
</label>
<input
type="text"
value={formData.supplier}
onChange={(e) => setFormData({ ...formData, supplier: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
SKU
</label>
<input
type="text"
value={formData.sku}
onChange={(e) => setFormData({ ...formData, sku: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
/>
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="w-5 h-5 text-amber-600 rounded"
/>
<span className="text-slate-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.is_tracked}
onChange={(e) => setFormData({ ...formData, is_tracked: e.target.checked })}
className="w-5 h-5 text-amber-600 rounded"
/>
<span className="text-slate-700 font-medium">Track Quantity</span>
</label>
</div>
<div className="flex gap-4 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="flex-1 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all"
>
{editingItem ? 'Update Item' : 'Create Item'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Reorder Modal - Simplified version */}
{showReorderModal && selectedItem && (
<ReorderModal
item={selectedItem}
onClose={() => {
setShowReorderModal(false);
setSelectedItem(null);
}}
onSubmit={handleCreateReorder}
/>
)}
</div>
);
};
// Simple Reorder Modal Component
const ReorderModal: React.FC<{
item: InventoryItem;
onClose: () => void;
onSubmit: (quantity: number, priority: string, notes: string) => void;
}> = ({ item, onClose, onSubmit }) => {
const [quantity, setQuantity] = useState(item.reorder_quantity || item.minimum_quantity);
const [priority, setPriority] = useState('normal');
const [notes, setNotes] = useState('');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h2 className="text-xl font-bold text-slate-900">Create Reorder Request</h2>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-lg">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<p className="text-slate-600">Item: <span className="font-semibold">{item.name}</span></p>
<p className="text-sm text-slate-500">Current: {item.current_quantity} | Min: {item.minimum_quantity}</p>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Quantity *</label>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(parseFloat(e.target.value) || 0)}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
min="1"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Priority</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
rows={3}
/>
</div>
<div className="flex gap-4 pt-4">
<button
onClick={onClose}
className="flex-1 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200"
>
Cancel
</button>
<button
onClick={() => onSubmit(quantity, priority, notes)}
className="flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700"
>
Create Request
</button>
</div>
</div>
</div>
</div>
);
};
export default InventoryManagementPage;

View File

@@ -0,0 +1,793 @@
import React, { useEffect, useState } from 'react';
import { Search, Plus, Edit, Wrench, AlertTriangle, CheckCircle, Clock, X, Filter, Calendar } from 'lucide-react';
import advancedRoomService, { MaintenanceRecord } from '../../features/rooms/services/advancedRoomService';
import roomService, { Room } from '../../features/rooms/services/roomService';
import userService, { User } from '../../features/auth/services/userService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { formatDate } from '../../shared/utils/format';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
const MaintenanceManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [records, setRecords] = useState<MaintenanceRecord[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingRecord, setEditingRecord] = useState<MaintenanceRecord | null>(null);
const [rooms, setRooms] = useState<Room[]>([]);
const [staffMembers, setStaffMembers] = useState<User[]>([]);
const [loadingRooms, setLoadingRooms] = useState(false);
const [loadingStaff, setLoadingStaff] = useState(false);
const [filters, setFilters] = useState({
search: '',
status: '',
maintenance_type: '',
room_id: '',
priority: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 20;
const [formData, setFormData] = useState({
room_id: 0,
maintenance_type: 'preventive',
title: '',
description: '',
scheduled_start: new Date(),
scheduled_end: null as Date | null,
assigned_to: 0,
priority: 'medium',
estimated_cost: 0,
blocks_room: true,
block_start: null as Date | null,
block_end: null as Date | null,
notes: '',
});
const maintenanceTypes = [
{ value: 'preventive', label: 'Preventive', color: 'bg-blue-100 text-blue-800' },
{ value: 'corrective', label: 'Corrective', color: 'bg-orange-100 text-orange-800' },
{ value: 'emergency', label: 'Emergency', color: 'bg-red-100 text-red-800' },
{ value: 'upgrade', label: 'Upgrade', color: 'bg-purple-100 text-purple-800' },
{ value: 'inspection', label: 'Inspection', color: 'bg-green-100 text-green-800' },
];
const statuses = [
{ value: 'scheduled', label: 'Scheduled', color: 'bg-yellow-100 text-yellow-800' },
{ value: 'in_progress', label: 'In Progress', color: 'bg-blue-100 text-blue-800' },
{ value: 'completed', label: 'Completed', color: 'bg-green-100 text-green-800' },
{ value: 'cancelled', label: 'Cancelled', color: 'bg-gray-100 text-gray-800' },
{ value: 'on_hold', label: 'On Hold', color: 'bg-orange-100 text-orange-800' },
];
const priorities = [
{ value: 'low', label: 'Low', color: 'bg-gray-100 text-gray-800' },
{ value: 'medium', label: 'Medium', color: 'bg-yellow-100 text-yellow-800' },
{ value: 'high', label: 'High', color: 'bg-orange-100 text-orange-800' },
{ value: 'urgent', label: 'Urgent', color: 'bg-red-100 text-red-800' },
];
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchRecords();
fetchRooms();
fetchStaffMembers();
}, [filters, currentPage]);
const fetchRecords = async () => {
try {
setLoading(true);
const params: any = {
page: currentPage,
limit: itemsPerPage,
};
if (filters.status) params.status = filters.status;
if (filters.maintenance_type) params.maintenance_type = filters.maintenance_type;
if (filters.room_id) params.room_id = parseInt(filters.room_id);
if (filters.priority) params.priority = filters.priority;
const response = await advancedRoomService.getMaintenanceRecords(params);
if (response.status === 'success' && response.data) {
let recordList = response.data.maintenance_records || [];
if (filters.search) {
recordList = recordList.filter((record: MaintenanceRecord) =>
record.title.toLowerCase().includes(filters.search.toLowerCase()) ||
record.description?.toLowerCase().includes(filters.search.toLowerCase()) ||
record.room_number?.toLowerCase().includes(filters.search.toLowerCase())
);
}
setRecords(recordList);
setTotalPages(response.data.pagination?.total_pages || 1);
setTotalItems(response.data.pagination?.total || 0);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load maintenance records');
} finally {
setLoading(false);
}
};
const fetchRooms = async () => {
try {
setLoadingRooms(true);
const response = await roomService.getRooms({ limit: 1000 });
if (response.data && response.data.rooms) {
setRooms(response.data.rooms);
}
} catch (error) {
console.error('Error fetching rooms:', error);
} finally {
setLoadingRooms(false);
}
};
const fetchStaffMembers = async () => {
try {
setLoadingStaff(true);
const response = await userService.getUsers({ role: 'staff', limit: 100 });
if (response.data && response.data.users) {
setStaffMembers(response.data.users);
}
} catch (error) {
console.error('Error fetching staff members:', error);
} finally {
setLoadingStaff(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.room_id) {
toast.error('Please select a room');
return;
}
if (!formData.title.trim()) {
toast.error('Please enter a title');
return;
}
try {
const dataToSubmit: any = {
room_id: formData.room_id,
maintenance_type: formData.maintenance_type,
title: formData.title,
description: formData.description || undefined,
scheduled_start: formData.scheduled_start.toISOString(),
priority: formData.priority,
blocks_room: formData.blocks_room,
notes: formData.notes || undefined,
};
if (formData.scheduled_end) {
dataToSubmit.scheduled_end = formData.scheduled_end.toISOString();
}
if (formData.assigned_to) {
dataToSubmit.assigned_to = formData.assigned_to;
}
if (formData.estimated_cost > 0) {
dataToSubmit.estimated_cost = formData.estimated_cost;
}
if (formData.blocks_room) {
if (formData.block_start) {
dataToSubmit.block_start = formData.block_start.toISOString();
}
if (formData.block_end) {
dataToSubmit.block_end = formData.block_end.toISOString();
}
}
if (editingRecord) {
await advancedRoomService.updateMaintenanceRecord(editingRecord.id, {
status: editingRecord.status,
actual_start: editingRecord.actual_start,
actual_end: editingRecord.actual_end,
completion_notes: editingRecord.completion_notes,
actual_cost: editingRecord.actual_cost,
});
toast.success('Maintenance record updated successfully');
} else {
await advancedRoomService.createMaintenanceRecord(dataToSubmit);
toast.success('Maintenance record created successfully');
}
setShowModal(false);
resetForm();
fetchRecords();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to save maintenance record');
}
};
const handleEdit = (record: MaintenanceRecord) => {
setEditingRecord(record);
// For editing, we can only update status and completion fields
setFormData({
room_id: record.room_id,
maintenance_type: record.maintenance_type,
title: record.title,
description: record.description || '',
scheduled_start: new Date(record.scheduled_start),
scheduled_end: record.scheduled_end ? new Date(record.scheduled_end) : null,
assigned_to: record.assigned_to || 0,
priority: record.priority,
estimated_cost: record.estimated_cost || 0,
blocks_room: record.blocks_room,
block_start: record.block_start ? new Date(record.block_start) : null,
block_end: record.block_end ? new Date(record.block_end) : null,
notes: record.notes || '',
});
setShowModal(true);
};
const handleStatusUpdate = async (recordId: number, newStatus: string) => {
try {
await advancedRoomService.updateMaintenanceRecord(recordId, {
status: newStatus,
});
toast.success('Maintenance status updated successfully');
fetchRecords();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
}
};
const resetForm = () => {
setEditingRecord(null);
setFormData({
room_id: 0,
maintenance_type: 'preventive',
title: '',
description: '',
scheduled_start: new Date(),
scheduled_end: null,
assigned_to: 0,
priority: 'medium',
estimated_cost: 0,
blocks_room: true,
block_start: null,
block_end: null,
notes: '',
});
};
const getStatusBadge = (status: string) => {
const statusObj = statuses.find((s) => s.value === status);
return statusObj || statuses[0];
};
const getTypeBadge = (type: string) => {
const typeObj = maintenanceTypes.find((t) => t.value === type);
return typeObj || maintenanceTypes[0];
};
const getPriorityBadge = (priority: string) => {
const priorityObj = priorities.find((p) => p.value === priority);
return priorityObj || priorities[1];
};
const getActiveRecords = () => records.filter((r) => ['scheduled', 'in_progress', 'on_hold'].includes(r.status)).length;
const getCompletedRecords = () => records.filter((r) => r.status === 'completed').length;
const getEmergencyRecords = () => records.filter((r) => r.maintenance_type === 'emergency' && ['scheduled', 'in_progress'].includes(r.status)).length;
if (loading && records.length === 0) {
return <Loading fullScreen text="Loading maintenance records..." />;
}
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Maintenance Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage room maintenance and repairs</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="w-5 h-5" />
New Maintenance
</button>
</div>
{/* Filters */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="grid grid-cols-1 md:grid-cols-5 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search maintenance..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 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 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-3.5 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 hover:shadow-md cursor-pointer"
>
<option value="">All Statuses</option>
{statuses.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
<select
value={filters.maintenance_type}
onChange={(e) => setFilters({ ...filters, maintenance_type: e.target.value })}
className="px-4 py-3.5 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 hover:shadow-md cursor-pointer"
>
<option value="">All Types</option>
{maintenanceTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<select
value={filters.room_id}
onChange={(e) => setFilters({ ...filters, room_id: e.target.value })}
className="px-4 py-3.5 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 hover:shadow-md cursor-pointer"
>
<option value="">All Rooms</option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>
Room {room.room_number}
</option>
))}
</select>
<select
value={filters.priority}
onChange={(e) => setFilters({ ...filters, priority: e.target.value })}
className="px-4 py-3.5 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 hover:shadow-md cursor-pointer"
>
<option value="">All Priorities</option>
{priorities.map((priority) => (
<option key={priority.value} value={priority.value}>
{priority.label}
</option>
))}
</select>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Total Records</p>
<p className="text-3xl font-bold text-slate-900 mt-2">{totalItems}</p>
</div>
<Wrench className="w-12 h-12 text-amber-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Active</p>
<p className="text-3xl font-bold text-blue-600 mt-2">{getActiveRecords()}</p>
</div>
<Clock className="w-12 h-12 text-blue-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Completed</p>
<p className="text-3xl font-bold text-green-600 mt-2">{getCompletedRecords()}</p>
</div>
<CheckCircle className="w-12 h-12 text-green-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Emergency</p>
<p className="text-3xl font-bold text-red-600 mt-2">{getEmergencyRecords()}</p>
</div>
<AlertTriangle className="w-12 h-12 text-red-500" />
</div>
</div>
</div>
{/* Records Table */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b-2 border-slate-200">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Maintenance
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Room
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Scheduled
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Priority
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Assigned To
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{records.map((record) => {
const statusBadge = getStatusBadge(record.status);
const typeBadge = getTypeBadge(record.maintenance_type);
const priorityBadge = getPriorityBadge(record.priority);
return (
<tr key={record.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div>
<div className="font-semibold text-slate-900">{record.title}</div>
{record.description && (
<div className="text-sm text-slate-500 mt-1 line-clamp-2">{record.description}</div>
)}
</div>
</td>
<td className="px-6 py-4">
<div className="font-semibold text-slate-900">Room {record.room_number || record.room_id}</div>
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${typeBadge.color}`}>
{typeBadge.label}
</span>
</td>
<td className="px-6 py-4">
<div className="text-slate-900 font-medium">
{formatDate(record.scheduled_start)}
</div>
{record.scheduled_end && (
<div className="text-xs text-slate-500 mt-1">
Until: {formatDate(record.scheduled_end)}
</div>
)}
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusBadge.color}`}>
{statusBadge.label}
</span>
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${priorityBadge.color}`}>
{priorityBadge.label}
</span>
</td>
<td className="px-6 py-4 text-slate-600">
{record.assigned_staff_name || 'Unassigned'}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(record)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
{record.status !== 'completed' && record.status !== 'cancelled' && (
<select
value={record.status}
onChange={(e) => handleStatusUpdate(record.id, e.target.value)}
className="px-3 py-1.5 text-sm border-2 border-slate-200 rounded-lg focus:border-amber-400 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
{statuses.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{records.length === 0 && !loading && (
<div className="text-center py-12">
<Wrench className="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 text-lg">No maintenance records found</p>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">
{editingRecord ? 'Edit Maintenance Record' : 'Create Maintenance Record'}
</h2>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Room *
</label>
<select
value={formData.room_id}
onChange={(e) => setFormData({ ...formData, room_id: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
disabled={!!editingRecord}
>
<option value={0}>Select Room</option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>
Room {room.room_number} ({room.room_type?.name || 'Unknown'})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Maintenance Type *
</label>
<select
value={formData.maintenance_type}
onChange={(e) => setFormData({ ...formData, maintenance_type: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
>
{maintenanceTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Title *
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Scheduled Start *
</label>
<DatePicker
selected={formData.scheduled_start}
onChange={(date: Date) => setFormData({ ...formData, scheduled_start: date })}
showTimeSelect
dateFormat="MMMM d, yyyy h:mm aa"
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Scheduled End
</label>
<DatePicker
selected={formData.scheduled_end}
onChange={(date: Date | null) => setFormData({ ...formData, scheduled_end: date })}
showTimeSelect
dateFormat="MMMM d, yyyy h:mm aa"
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Assigned To
</label>
<select
value={formData.assigned_to}
onChange={(e) => setFormData({ ...formData, assigned_to: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
>
<option value={0}>Unassigned</option>
{staffMembers.map((staff) => (
<option key={staff.id} value={staff.id}>
{staff.full_name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Priority *
</label>
<select
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
required
>
{priorities.map((priority) => (
<option key={priority.value} value={priority.value}>
{priority.label}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Estimated Cost
</label>
<input
type="number"
value={formData.estimated_cost}
onChange={(e) => setFormData({ ...formData, estimated_cost: parseFloat(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
min="0"
step="0.01"
/>
</div>
<div className="flex items-center pt-8">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.blocks_room}
onChange={(e) => setFormData({ ...formData, blocks_room: e.target.checked })}
className="w-5 h-5 text-amber-600 rounded"
/>
<span className="text-slate-700 font-medium">Blocks Room</span>
</label>
</div>
</div>
{formData.blocks_room && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Block Start
</label>
<DatePicker
selected={formData.block_start}
onChange={(date: Date | null) => setFormData({ ...formData, block_start: date })}
showTimeSelect
dateFormat="MMMM d, yyyy h:mm aa"
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Block End
</label>
<DatePicker
selected={formData.block_end}
onChange={(date: Date | null) => setFormData({ ...formData, block_end: date })}
showTimeSelect
dateFormat="MMMM d, yyyy h:mm aa"
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Notes
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100"
rows={3}
/>
</div>
<div className="flex gap-4 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="flex-1 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all"
>
{editingRecord ? 'Update Record' : 'Create Record'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default MaintenanceManagementPage;

View File

@@ -215,6 +215,8 @@ const PageContentDashboard: React.FC = () => {
og_description: contents.home.og_description || '',
og_image: contents.home.og_image || '',
features: normalizeArray(contents.home.features),
features_section_title: contents.home.features_section_title || '',
features_section_subtitle: contents.home.features_section_subtitle || '',
amenities_section_title: contents.home.amenities_section_title || '',
amenities_section_subtitle: contents.home.amenities_section_subtitle || '',
amenities: normalizeArray(contents.home.amenities),
@@ -226,6 +228,13 @@ const PageContentDashboard: React.FC = () => {
about_preview_content: contents.home.about_preview_content || '',
about_preview_image: contents.home.about_preview_image || '',
stats: normalizeArray(contents.home.stats),
stats_section_title: contents.home.stats_section_title || '',
stats_section_subtitle: contents.home.stats_section_subtitle || '',
rooms_section_title: contents.home.rooms_section_title || '',
rooms_section_subtitle: contents.home.rooms_section_subtitle || '',
rooms_section_button_text: contents.home.rooms_section_button_text || '',
rooms_section_button_link: contents.home.rooms_section_button_link || '',
rooms_section_enabled: contents.home.rooms_section_enabled ?? true,
luxury_section_title: contents.home.luxury_section_title || '',
luxury_section_subtitle: contents.home.luxury_section_subtitle || '',
luxury_section_image: contents.home.luxury_section_image || '',
@@ -238,6 +247,9 @@ const PageContentDashboard: React.FC = () => {
luxury_testimonials: normalizeArray(contents.home.luxury_testimonials),
luxury_services_section_title: contents.home.luxury_services_section_title || '',
luxury_services_section_subtitle: contents.home.luxury_services_section_subtitle || '',
services_section_button_text: contents.home.services_section_button_text || '',
services_section_button_link: contents.home.services_section_button_link || '',
services_section_limit: contents.home.services_section_limit || 6,
luxury_experiences_section_title: contents.home.luxury_experiences_section_title || '',
luxury_experiences_section_subtitle: contents.home.luxury_experiences_section_subtitle || '',
luxury_experiences: normalizeArray(contents.home.luxury_experiences),
@@ -252,6 +264,21 @@ const PageContentDashboard: React.FC = () => {
partners_section_title: contents.home.partners_section_title || '',
partners_section_subtitle: contents.home.partners_section_subtitle || '',
partners: normalizeArray(contents.home.partners),
sections_enabled: contents.home.sections_enabled || {},
trust_badges_section_title: contents.home.trust_badges_section_title || '',
trust_badges_section_subtitle: contents.home.trust_badges_section_subtitle || '',
trust_badges: normalizeArray(contents.home.trust_badges),
trust_badges_enabled: contents.home.trust_badges_enabled ?? false,
promotions_section_title: contents.home.promotions_section_title || '',
promotions_section_subtitle: contents.home.promotions_section_subtitle || '',
promotions: normalizeArray(contents.home.promotions),
promotions_enabled: contents.home.promotions_enabled ?? false,
blog_section_title: contents.home.blog_section_title || '',
blog_section_subtitle: contents.home.blog_section_subtitle || '',
blog_posts_limit: contents.home.blog_posts_limit || 3,
blog_enabled: contents.home.blog_enabled ?? false,
hero_video_url: contents.home.hero_video_url || '',
hero_video_poster: contents.home.hero_video_poster || '',
});
}
@@ -408,7 +435,9 @@ const PageContentDashboard: React.FC = () => {
const stringFields = [
'title', 'subtitle', 'description', 'content', 'meta_title', 'meta_description', 'meta_keywords',
'og_title', 'og_description', 'og_image', 'canonical_url', 'hero_title', 'hero_subtitle', 'hero_image',
'hero_video_url', 'hero_video_poster',
'story_content', 'about_hero_image', 'mission', 'vision',
'features_section_title', 'features_section_subtitle',
'amenities_section_title', 'amenities_section_subtitle',
'testimonials_section_title', 'testimonials_section_subtitle',
'gallery_section_title', 'gallery_section_subtitle',
@@ -416,11 +445,17 @@ const PageContentDashboard: React.FC = () => {
'luxury_gallery_section_title', 'luxury_gallery_section_subtitle',
'luxury_testimonials_section_title', 'luxury_testimonials_section_subtitle',
'about_preview_title', 'about_preview_subtitle', 'about_preview_content', 'about_preview_image',
'stats_section_title', 'stats_section_subtitle',
'rooms_section_title', 'rooms_section_subtitle', 'rooms_section_button_text', 'rooms_section_button_link',
'luxury_services_section_title', 'luxury_services_section_subtitle',
'services_section_button_text', 'services_section_button_link',
'luxury_experiences_section_title', 'luxury_experiences_section_subtitle',
'awards_section_title', 'awards_section_subtitle',
'cta_title', 'cta_subtitle', 'cta_button_text', 'cta_button_link', 'cta_image',
'partners_section_title', 'partners_section_subtitle',
'trust_badges_section_title', 'trust_badges_section_subtitle',
'promotions_section_title', 'promotions_section_subtitle',
'blog_section_title', 'blog_section_subtitle',
'copyright_text', 'map_url'
];
@@ -461,6 +496,19 @@ const PageContentDashboard: React.FC = () => {
return;
}
// Handle special fields that might be numbers or booleans
if (key === 'services_section_limit' || key === 'blog_posts_limit') {
if (typeof value === 'number' && value > 0) {
cleanData[key] = value;
}
return;
}
if (key === 'rooms_section_enabled' || key === 'trust_badges_enabled' ||
key === 'promotions_enabled' || key === 'blog_enabled') {
cleanData[key] = value === true;
return;
}
// Handle other types - ensure they're valid
if (typeof value === 'number' || typeof value === 'boolean') {
cleanData[key] = value;
@@ -1895,6 +1943,31 @@ const PageContentDashboard: React.FC = () => {
<div id="stats-section" 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">Statistics Section</h2>
</div>
<div className="space-y-6 mb-6">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Title</label>
<input
type="text"
value={homeData.stats_section_title || ''}
onChange={(e) => setHomeData({ ...homeData, stats_section_title: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Our Achievements"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Subtitle</label>
<input
type="text"
value={homeData.stats_section_subtitle || ''}
onChange={(e) => setHomeData({ ...homeData, stats_section_subtitle: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Numbers that speak for themselves"
/>
</div>
</div>
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-semibold text-gray-900">Statistics</h3>
<button
type="button"
onClick={() => {
@@ -1976,6 +2049,185 @@ const PageContentDashboard: React.FC = () => {
</div>
</div>
{/* Rooms Section */}
<div id="rooms-section" className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h2 className="text-2xl font-extrabold text-gray-900 mb-6">Rooms Section</h2>
<div className="space-y-6">
<div className="flex items-center gap-4 mb-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={homeData.rooms_section_enabled !== false}
onChange={(e) => setHomeData({ ...homeData, rooms_section_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 Rooms Section</span>
</label>
</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">Section Title</label>
<input
type="text"
value={homeData.rooms_section_title || homeData.hero_title || ''}
onChange={(e) => setHomeData({ ...homeData, rooms_section_title: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Featured & Newest Rooms"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Subtitle</label>
<input
type="text"
value={homeData.rooms_section_subtitle || homeData.hero_subtitle || ''}
onChange={(e) => setHomeData({ ...homeData, rooms_section_subtitle: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Discover our most popular accommodations"
/>
</div>
</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">Button Text</label>
<input
type="text"
value={homeData.rooms_section_button_text || 'View All Rooms'}
onChange={(e) => setHomeData({ ...homeData, rooms_section_button_text: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="View All Rooms"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Button Link</label>
<input
type="text"
value={homeData.rooms_section_button_link || '/rooms'}
onChange={(e) => setHomeData({ ...homeData, rooms_section_button_link: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="/rooms"
/>
</div>
</div>
</div>
</div>
{/* Features Section */}
<div id="features-section" className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h2 className="text-2xl font-extrabold text-gray-900 mb-6">Features Section</h2>
<div className="space-y-6 mb-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.features_section_title || ''}
onChange={(e) => setHomeData({ ...homeData, features_section_title: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Why Choose Us"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Subtitle</label>
<input
type="text"
value={homeData.features_section_subtitle || ''}
onChange={(e) => setHomeData({ ...homeData, features_section_subtitle: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Experience excellence in every detail"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Features</h3>
<button
onClick={() => {
setHomeData((prevData) => {
const current = Array.isArray(prevData.features) ? prevData.features : [];
return {
...prevData,
features: [...current, { icon: 'Star', title: '', description: '', image: '' }]
};
});
}}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-purple-500 to-purple-600 text-white rounded-lg font-semibold hover:from-purple-600 hover:to-purple-700 transition-all"
>
<Plus className="w-4 h-4" />
Add Feature
</button>
</div>
<div className="space-y-4">
{Array.isArray(homeData.features) && homeData.features.map((feature, index) => (
<div key={`feature-${index}-${feature.title || 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">Feature {index + 1}</h4>
<button
type="button"
onClick={() => {
setHomeData((prevData) => {
const currentFeatures = Array.isArray(prevData.features) ? prevData.features : [];
const updated = currentFeatures.filter((_, i) => i !== index);
return { ...prevData, features: updated };
});
}}
className="text-red-600 hover:text-red-700 p-1"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
<div>
<IconPicker
value={feature?.icon || ''}
onChange={(iconName) => {
setHomeData((prevData) => {
const currentFeatures = Array.isArray(prevData.features) ? [...prevData.features] : [];
currentFeatures[index] = { ...currentFeatures[index], icon: iconName };
return { ...prevData, features: currentFeatures };
});
}}
label="Icon"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Title</label>
<input
type="text"
value={feature?.title || ''}
onChange={(e) => {
setHomeData((prevData) => {
const currentFeatures = Array.isArray(prevData.features) ? [...prevData.features] : [];
currentFeatures[index] = { ...currentFeatures[index], title: e.target.value };
return { ...prevData, features: currentFeatures };
});
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Feature Title"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Description</label>
<textarea
value={feature?.description || ''}
onChange={(e) => {
setHomeData((prevData) => {
const currentFeatures = Array.isArray(prevData.features) ? [...prevData.features] : [];
currentFeatures[index] = { ...currentFeatures[index], description: e.target.value };
return { ...prevData, features: currentFeatures };
});
}}
rows={2}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Feature description"
/>
</div>
</div>
))}
{(!homeData.features || homeData.features.length === 0) && (
<p className="text-gray-500 text-center py-8">No features added yet. Click "Add Feature" to get started.</p>
)}
</div>
</div>
{/* Luxury Services Section */}
<div id="luxury-services-section" className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h2 className="text-2xl font-extrabold text-gray-900 mb-6">Luxury Services Section</h2>
@@ -1994,7 +2246,7 @@ const PageContentDashboard: React.FC = () => {
</div>
{/* Section Title and Subtitle */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Title</label>
<input
@@ -2018,6 +2270,40 @@ const PageContentDashboard: React.FC = () => {
<p className="text-xs text-gray-500 mt-1">Subtitle displayed below the title on homepage</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Button Text</label>
<input
type="text"
value={homeData.services_section_button_text || 'View All Services'}
onChange={(e) => setHomeData({ ...homeData, services_section_button_text: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="View All Services"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Button Link</label>
<input
type="text"
value={homeData.services_section_button_link || '/services'}
onChange={(e) => setHomeData({ ...homeData, services_section_button_link: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="/services"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Services to Display</label>
<input
type="number"
min="1"
max="12"
value={homeData.services_section_limit || 6}
onChange={(e) => setHomeData({ ...homeData, services_section_limit: parseInt(e.target.value) || 6 })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="6"
/>
</div>
</div>
</div>
{/* Services are now managed in Service Management page only */}
@@ -2568,6 +2854,460 @@ const PageContentDashboard: React.FC = () => {
</div>
</div>
{/* Section Visibility Toggles */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h2 className="text-2xl font-extrabold text-gray-900 mb-6">Section Visibility</h2>
<p className="text-gray-600 mb-6">Toggle sections on/off to control what appears on the homepage</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[
{ key: 'rooms', label: 'Rooms Section' },
{ key: 'features', label: 'Features Section' },
{ key: 'luxury', label: 'Luxury Section' },
{ key: 'gallery', label: 'Gallery Section' },
{ key: 'testimonials', label: 'Testimonials Section' },
{ key: 'stats', label: 'Statistics Section' },
{ key: 'amenities', label: 'Amenities Section' },
{ key: 'about_preview', label: 'About Preview Section' },
{ key: 'services', label: 'Services Section' },
{ key: 'experiences', label: 'Experiences Section' },
{ key: 'awards', label: 'Awards Section' },
{ key: 'cta', label: 'CTA Section' },
{ key: 'partners', label: 'Partners Section' },
].map((section) => (
<label key={section.key} className="flex items-center gap-3 p-4 border-2 border-gray-200 rounded-xl cursor-pointer hover:border-purple-300 transition-colors">
<input
type="checkbox"
checked={(homeData.sections_enabled?.[section.key as keyof typeof homeData.sections_enabled] ?? true)}
onChange={(e) => {
setHomeData({
...homeData,
sections_enabled: {
...homeData.sections_enabled,
[section.key]: 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">{section.label}</span>
</label>
))}
</div>
</div>
{/* Trust Badges Section */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-extrabold text-gray-900">Trust Badges Section</h2>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={homeData.trust_badges_enabled ?? false}
onChange={(e) => setHomeData({ ...homeData, trust_badges_enabled: e.target.checked })}
className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
/>
<span className="text-sm font-semibold text-gray-700">Enable Trust Badges</span>
</label>
</div>
{homeData.trust_badges_enabled && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Title</label>
<input
type="text"
value={homeData.trust_badges_section_title || ''}
onChange={(e) => setHomeData({ ...homeData, trust_badges_section_title: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Trusted & Certified"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Subtitle</label>
<input
type="text"
value={homeData.trust_badges_section_subtitle || ''}
onChange={(e) => setHomeData({ ...homeData, trust_badges_section_subtitle: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Recognized for excellence"
/>
</div>
</div>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Trust Badges</h3>
<button
type="button"
onClick={() => {
const current = Array.isArray(homeData.trust_badges) ? homeData.trust_badges : [];
setHomeData({
...homeData,
trust_badges: [...current, { name: '', logo: '', description: '', link: '' }]
});
}}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-lg font-semibold hover:from-green-600 hover:to-green-700 transition-all"
>
<Plus className="w-4 h-4" />
Add Badge
</button>
</div>
<div className="space-y-4">
{Array.isArray(homeData.trust_badges) && homeData.trust_badges.map((badge, index) => (
<div key={`badge-${index}`} className="p-6 border-2 border-gray-200 rounded-xl space-y-4">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-gray-900">Badge {index + 1}</h4>
<button
type="button"
onClick={() => {
const current = Array.isArray(homeData.trust_badges) ? homeData.trust_badges : [];
const updated = current.filter((_, i) => i !== index);
setHomeData({ ...homeData, trust_badges: updated });
}}
className="text-red-600 hover:text-red-700 p-1"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Name</label>
<input
type="text"
value={badge?.name || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], name: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Certification Name"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Link (Optional)</label>
<input
type="url"
value={badge?.link || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], link: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="https://example.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Description (Optional)</label>
<textarea
value={badge?.description || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], description: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
rows={2}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Brief description"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Logo</label>
<div className="flex gap-2">
<input
type="url"
value={badge?.logo || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], logo: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
className="flex-1 px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Logo URL or upload"
/>
<label className="px-5 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-bold hover:from-green-700 hover:to-green-800 transition-all cursor-pointer flex items-center gap-2">
<Upload className="w-4 h-4" />
Upload
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
await handlePageContentImageUpload(file, (imageUrl) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], logo: imageUrl };
setHomeData({ ...homeData, trust_badges: current });
});
}
}}
className="hidden"
/>
</label>
</div>
</div>
</div>
))}
{(!homeData.trust_badges || homeData.trust_badges.length === 0) && (
<p className="text-gray-500 text-center py-8">No trust badges added yet. Click "Add Badge" to get started.</p>
)}
</div>
</div>
)}
</div>
{/* Promotions Section */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-extrabold text-gray-900">Promotions & Special Offers</h2>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={homeData.promotions_enabled ?? false}
onChange={(e) => setHomeData({ ...homeData, promotions_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 Promotions</span>
</label>
</div>
{homeData.promotions_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.promotions_section_title || ''}
onChange={(e) => setHomeData({ ...homeData, promotions_section_title: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Special Offers"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Subtitle</label>
<input
type="text"
value={homeData.promotions_section_subtitle || ''}
onChange={(e) => setHomeData({ ...homeData, promotions_section_subtitle: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Limited time offers"
/>
</div>
</div>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Promotions</h3>
<button
type="button"
onClick={() => {
const current = Array.isArray(homeData.promotions) ? homeData.promotions : [];
setHomeData({
...homeData,
promotions: [...current, { title: '', description: '', image: '', discount: '', valid_until: '', link: '', button_text: '' }]
});
}}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-orange-500 to-orange-600 text-white rounded-lg font-semibold hover:from-orange-600 hover:to-orange-700 transition-all"
>
<Plus className="w-4 h-4" />
Add Promotion
</button>
</div>
<div className="space-y-4">
{Array.isArray(homeData.promotions) && homeData.promotions.map((promo, index) => (
<div key={`promo-${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">Promotion {index + 1}</h4>
<button
type="button"
onClick={() => {
const current = Array.isArray(homeData.promotions) ? homeData.promotions : [];
const updated = current.filter((_, i) => i !== index);
setHomeData({ ...homeData, promotions: 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">Title</label>
<input
type="text"
value={promo?.title || ''}
onChange={(e) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], title: e.target.value };
setHomeData({ ...homeData, promotions: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Promotion Title"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Discount</label>
<input
type="text"
value={promo?.discount || ''}
onChange={(e) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], discount: e.target.value };
setHomeData({ ...homeData, promotions: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="20% OFF"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Description</label>
<textarea
value={promo?.description || ''}
onChange={(e) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], description: e.target.value };
setHomeData({ ...homeData, promotions: current });
}}
rows={2}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Promotion description"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Valid Until</label>
<input
type="date"
value={promo?.valid_until || ''}
onChange={(e) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], valid_until: e.target.value };
setHomeData({ ...homeData, promotions: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Button Text</label>
<input
type="text"
value={promo?.button_text || ''}
onChange={(e) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], button_text: e.target.value };
setHomeData({ ...homeData, promotions: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Book Now"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Link</label>
<input
type="url"
value={promo?.link || ''}
onChange={(e) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], link: e.target.value };
setHomeData({ ...homeData, promotions: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="/rooms or /booking"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Image</label>
<div className="flex gap-2">
<input
type="url"
value={promo?.image || ''}
onChange={(e) => {
const current = Array.isArray(homeData.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], image: e.target.value };
setHomeData({ ...homeData, promotions: current });
}}
className="flex-1 px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Image URL or upload"
/>
<label className="px-5 py-2 bg-gradient-to-r from-orange-600 to-orange-700 text-white rounded-lg font-bold hover:from-orange-700 hover:to-orange-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.promotions) ? [...homeData.promotions] : [];
current[index] = { ...current[index], image: imageUrl };
setHomeData({ ...homeData, promotions: current });
});
}
}}
className="hidden"
/>
</label>
</div>
</div>
</div>
))}
{(!homeData.promotions || homeData.promotions.length === 0) && (
<p className="text-gray-500 text-center py-8">No promotions added yet. Click "Add Promotion" to get started.</p>
)}
</div>
</div>
)}
</div>
{/* Hero Video Section */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<h2 className="text-2xl font-extrabold text-gray-900 mb-6">Hero Video (Optional)</h2>
<div className="space-y-6">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Video URL</label>
<input
type="url"
value={homeData.hero_video_url || ''}
onChange={(e) => setHomeData({ ...homeData, hero_video_url: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="https://youtube.com/watch?v=... or direct video URL"
/>
<p className="text-xs text-gray-500 mt-1">Supports YouTube, Vimeo, or direct video URLs</p>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Video Poster Image (Optional)</label>
<div className="flex gap-2">
<input
type="url"
value={homeData.hero_video_poster || ''}
onChange={(e) => setHomeData({ ...homeData, hero_video_poster: e.target.value })}
className="flex-1 px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Poster image URL or upload"
/>
<label className="px-5 py-3 bg-gradient-to-r from-purple-600 to-purple-700 text-white rounded-xl font-bold hover:from-purple-700 hover:to-purple-800 transition-all cursor-pointer flex items-center gap-2">
<Upload className="w-5 h-5" />
Upload
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
await handlePageContentImageUpload(file, (imageUrl) => {
setHomeData({ ...homeData, hero_video_poster: imageUrl });
});
}
}}
className="hidden"
/>
</label>
</div>
<p className="text-xs text-gray-500 mt-1">Thumbnail image shown before video plays</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex justify-end">

View File

@@ -0,0 +1,620 @@
import React, { useEffect, useState } from 'react';
import { Search, Plus, Edit, Calendar, Clock, Users, X, Filter } from 'lucide-react';
import staffShiftService, { StaffShift } from '../../features/staffShifts/services/staffShiftService';
import userService, { User } from '../../features/auth/services/userService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { formatDate } from '../../shared/utils/format';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
const StaffShiftManagementPage: React.FC = () => {
const [shifts, setShifts] = useState<StaffShift[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingShift, setEditingShift] = useState<StaffShift | null>(null);
const [staffMembers, setStaffMembers] = useState<User[]>([]);
const [filters, setFilters] = useState({
search: '',
status: '',
staff_id: '',
shift_date: '',
department: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 20;
const [formData, setFormData] = useState({
staff_id: 0,
shift_date: new Date(),
shift_type: 'full_day',
start_time: '08:00',
end_time: '20:00',
status: 'scheduled',
break_duration_minutes: 30,
department: '',
notes: '',
});
const shiftTypes = [
{ value: 'morning', label: 'Morning (6 AM - 2 PM)' },
{ value: 'afternoon', label: 'Afternoon (2 PM - 10 PM)' },
{ value: 'night', label: 'Night (10 PM - 6 AM)' },
{ value: 'full_day', label: 'Full Day (8 AM - 8 PM)' },
{ value: 'custom', label: 'Custom' },
];
const statuses = [
{ value: 'scheduled', label: 'Scheduled', color: 'bg-yellow-100 text-yellow-800' },
{ value: 'in_progress', label: 'In Progress', color: 'bg-blue-100 text-blue-800' },
{ value: 'completed', label: 'Completed', color: 'bg-green-100 text-green-800' },
{ value: 'cancelled', label: 'Cancelled', color: 'bg-gray-100 text-gray-800' },
{ value: 'no_show', label: 'No Show', color: 'bg-red-100 text-red-800' },
];
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchShifts();
fetchStaffMembers();
}, [filters, currentPage]);
const fetchShifts = async () => {
try {
setLoading(true);
const params: any = {
page: currentPage,
limit: itemsPerPage,
};
if (filters.status) params.status = filters.status;
if (filters.staff_id) params.staff_id = parseInt(filters.staff_id);
if (filters.shift_date) params.shift_date = filters.shift_date;
if (filters.department) params.department = filters.department;
const response = await staffShiftService.getShifts(params);
if (response.status === 'success' && response.data) {
let shiftList = response.data.shifts || [];
if (filters.search) {
shiftList = shiftList.filter((shift: StaffShift) =>
shift.staff_name?.toLowerCase().includes(filters.search.toLowerCase()) ||
shift.department?.toLowerCase().includes(filters.search.toLowerCase())
);
}
setShifts(shiftList);
setTotalPages(response.data.pagination?.total_pages || 1);
setTotalItems(response.data.pagination?.total || 0);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load shifts');
} finally {
setLoading(false);
}
};
const fetchStaffMembers = async () => {
try {
// Fetch both staff and housekeeping users
const [staffResponse, housekeepingResponse] = await Promise.all([
userService.getUsers({ role: 'staff', limit: 100 }),
userService.getUsers({ role: 'housekeeping', limit: 100 })
]);
const allUsers: User[] = [];
if (staffResponse.data && staffResponse.data.users) {
allUsers.push(...staffResponse.data.users);
}
if (housekeepingResponse.data && housekeepingResponse.data.users) {
allUsers.push(...housekeepingResponse.data.users);
}
setStaffMembers(allUsers);
} catch (error) {
console.error('Error fetching staff members:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.staff_id) {
toast.error('Please select a staff member or housekeeping user');
return;
}
try {
const dataToSubmit: any = {
staff_id: formData.staff_id,
shift_date: formData.shift_date.toISOString(),
shift_type: formData.shift_type,
start_time: formData.start_time,
end_time: formData.end_time,
status: formData.status,
break_duration_minutes: formData.break_duration_minutes,
};
if (formData.department) {
dataToSubmit.department = formData.department;
}
if (formData.notes) {
dataToSubmit.notes = formData.notes;
}
if (editingShift) {
await staffShiftService.updateShift(editingShift.id, dataToSubmit);
toast.success('Shift updated successfully');
} else {
await staffShiftService.createShift(dataToSubmit);
toast.success('Shift created successfully');
}
setShowModal(false);
resetForm();
fetchShifts();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to save shift');
}
};
const handleEdit = (shift: StaffShift) => {
setEditingShift(shift);
const shiftDate = shift.shift_date ? new Date(shift.shift_date) : new Date();
setFormData({
staff_id: shift.staff_id,
shift_date: shiftDate,
shift_type: shift.shift_type,
start_time: shift.start_time || '08:00',
end_time: shift.end_time || '20:00',
status: shift.status,
break_duration_minutes: shift.break_duration_minutes || 30,
department: shift.department || '',
notes: shift.notes || '',
});
setShowModal(true);
};
const handleStatusUpdate = async (shiftId: number, newStatus: string) => {
try {
await staffShiftService.updateShift(shiftId, {
status: newStatus,
});
toast.success('Shift status updated successfully');
fetchShifts();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
}
};
const resetForm = () => {
setEditingShift(null);
setFormData({
staff_id: 0,
shift_date: new Date(),
shift_type: 'full_day',
start_time: '08:00',
end_time: '20:00',
status: 'scheduled',
break_duration_minutes: 30,
department: '',
notes: '',
});
};
const getStatusBadge = (status: string) => {
const statusObj = statuses.find((s) => s.value === status);
return statusObj || statuses[0];
};
const getScheduledShifts = () => shifts.filter((s) => s.status === 'scheduled').length;
const getCompletedShifts = () => shifts.filter((s) => s.status === 'completed').length;
const getInProgressShifts = () => shifts.filter((s) => s.status === 'in_progress').length;
if (loading && shifts.length === 0) {
return <Loading fullScreen text="Loading shifts..." />;
}
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Staff Shift Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage staff and housekeeping schedules and shifts</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="w-5 h-5" />
New Shift
</button>
</div>
{/* Filters */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="grid grid-cols-1 md:grid-cols-5 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search shifts..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 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 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-3.5 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 hover:shadow-md cursor-pointer"
>
<option value="">All Statuses</option>
{statuses.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
<select
value={filters.staff_id}
onChange={(e) => setFilters({ ...filters, staff_id: e.target.value })}
className="px-4 py-3.5 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 hover:shadow-md cursor-pointer"
>
<option value="">All Staff / Housekeeping</option>
{staffMembers.map((staff) => (
<option key={staff.id} value={staff.id}>
{staff.full_name}
</option>
))}
</select>
<input
type="date"
value={filters.shift_date}
onChange={(e) => setFilters({ ...filters, shift_date: e.target.value })}
className="px-4 py-3.5 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 hover:shadow-md"
placeholder="Shift Date"
/>
<input
type="text"
value={filters.department}
onChange={(e) => setFilters({ ...filters, department: e.target.value })}
placeholder="Department"
className="px-4 py-3.5 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 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Total Shifts</p>
<p className="text-3xl font-bold text-slate-900 mt-2">{totalItems}</p>
</div>
<Calendar className="w-12 h-12 text-amber-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Scheduled</p>
<p className="text-3xl font-bold text-yellow-600 mt-2">{getScheduledShifts()}</p>
</div>
<Clock className="w-12 h-12 text-yellow-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">In Progress</p>
<p className="text-3xl font-bold text-blue-600 mt-2">{getInProgressShifts()}</p>
</div>
<Users className="w-12 h-12 text-blue-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Completed</p>
<p className="text-3xl font-bold text-green-600 mt-2">{getCompletedShifts()}</p>
</div>
<Clock className="w-12 h-12 text-green-500" />
</div>
</div>
</div>
{/* Shifts Table */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b-2 border-slate-200">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Staff Member
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Shift Type
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Time
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Department
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{shifts.map((shift) => {
const statusBadge = getStatusBadge(shift.status);
return (
<tr key={shift.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div className="font-semibold text-slate-900">{shift.staff_name || `Staff #${shift.staff_id}`}</div>
</td>
<td className="px-6 py-4">
<div className="text-slate-900 font-medium">
{formatDate(shift.shift_date)}
</div>
</td>
<td className="px-6 py-4">
<span className="px-3 py-1 rounded-full text-sm font-medium bg-slate-100 text-slate-800">
{shiftTypes.find((t) => t.value === shift.shift_type)?.label || shift.shift_type}
</span>
</td>
<td className="px-6 py-4">
<div className="text-slate-900 font-medium">
{shift.start_time} - {shift.end_time}
</div>
{shift.break_duration_minutes && (
<div className="text-xs text-slate-500 mt-1">
Break: {shift.break_duration_minutes} min
</div>
)}
</td>
<td className="px-6 py-4 text-slate-600">
{shift.department || '—'}
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusBadge.color}`}>
{statusBadge.label}
</span>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(shift)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<select
value={shift.status}
onChange={(e) => handleStatusUpdate(shift.id, e.target.value)}
className="px-3 py-1.5 text-sm border-2 border-slate-200 rounded-lg focus:border-amber-400 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
{statuses.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{shifts.length === 0 && !loading && (
<div className="text-center py-12">
<Calendar className="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 text-lg">No shifts found</p>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">
{editingShift ? 'Edit Shift' : 'Create Shift'}
</h2>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Staff Member / Housekeeping *</label>
<select
value={formData.staff_id}
onChange={(e) => setFormData({ ...formData, staff_id: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
disabled={!!editingShift}
>
<option value={0}>Select Staff or Housekeeping</option>
{staffMembers.map((staff) => (
<option key={staff.id} value={staff.id}>
{staff.full_name} ({staff.role})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Shift Date *</label>
<DatePicker
selected={formData.shift_date}
onChange={(date: Date) => setFormData({ ...formData, shift_date: date })}
dateFormat="MMMM d, yyyy"
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Shift Type *</label>
<select
value={formData.shift_type}
onChange={(e) => setFormData({ ...formData, shift_type: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
>
{shiftTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Status *</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
>
{statuses.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Start Time *</label>
<input
type="time"
value={formData.start_time}
onChange={(e) => setFormData({ ...formData, start_time: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">End Time *</label>
<input
type="time"
value={formData.end_time}
onChange={(e) => setFormData({ ...formData, end_time: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Break (minutes)</label>
<input
type="number"
value={formData.break_duration_minutes}
onChange={(e) => setFormData({ ...formData, break_duration_minutes: parseInt(e.target.value) || 30 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
min="0"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Department</label>
<input
type="text"
value={formData.department}
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
placeholder="e.g., reception, housekeeping"
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Notes</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-amber-400"
rows={3}
/>
</div>
<div className="flex gap-4 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="flex-1 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all"
>
{editingShift ? 'Update Shift' : 'Create Shift'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default StaffShiftManagementPage;

View File

@@ -0,0 +1,232 @@
import React, { useEffect, useState } from 'react';
import { Calendar, Clock, CheckCircle, AlertCircle } from 'lucide-react';
import staffShiftService, { StaffShift, StaffTask } from '../../features/staffShifts/services/staffShiftService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
const HousekeepingShiftViewPage: React.FC = () => {
const [shifts, setShifts] = useState<StaffShift[]>([]);
const [tasks, setTasks] = useState<StaffTask[]>([]);
const [loading, setLoading] = useState(true);
const [selectedDate, setSelectedDate] = useState(new Date());
useEffect(() => {
fetchShifts();
fetchTasks();
}, [selectedDate]);
const fetchShifts = async () => {
try {
setLoading(true);
const dateStr = selectedDate.toISOString().split('T')[0];
const response = await staffShiftService.getShifts({
shift_date: dateStr,
limit: 100,
});
if (response.status === 'success' && response.data) {
setShifts(response.data.shifts || []);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load shifts');
} finally {
setLoading(false);
}
};
const fetchTasks = async () => {
try {
const response = await staffShiftService.getTasks({
limit: 100,
});
if (response.status === 'success' && response.data) {
setTasks(response.data.tasks || []);
}
} catch (error) {
console.error('Error fetching tasks:', error);
}
};
const handleStatusUpdate = async (shiftId: number, newStatus: string) => {
try {
await staffShiftService.updateShift(shiftId, {
status: newStatus,
});
toast.success('Shift status updated successfully');
fetchShifts();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
}
};
if (loading && shifts.length === 0) {
return <Loading fullScreen text="Loading your shifts..." />;
}
const getUpcomingShifts = () => shifts.filter((s) => s.status === 'scheduled').length;
const getCompletedShifts = () => shifts.filter((s) => s.status === 'completed').length;
const getPendingTasks = () => tasks.filter((t) => ['pending', 'assigned'].includes(t.status)).length;
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-[#d4af37] to-[#c9a227] rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
My Shifts
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">View and manage your shift schedule</p>
</div>
<input
type="date"
value={selectedDate.toISOString().split('T')[0]}
onChange={(e) => setSelectedDate(new Date(e.target.value))}
className="px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-[#d4af37] focus:ring-4 focus:ring-yellow-100"
/>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Upcoming Shifts</p>
<p className="text-3xl font-bold text-[#d4af37] mt-2">{getUpcomingShifts()}</p>
</div>
<Calendar className="w-12 h-12 text-[#d4af37]" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Completed</p>
<p className="text-3xl font-bold text-green-600 mt-2">{getCompletedShifts()}</p>
</div>
<CheckCircle className="w-12 h-12 text-green-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Pending Tasks</p>
<p className="text-3xl font-bold text-orange-600 mt-2">{getPendingTasks()}</p>
</div>
<AlertCircle className="w-12 h-12 text-orange-500" />
</div>
</div>
</div>
{/* Shifts List */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="p-6">
<h2 className="text-xl font-bold text-slate-900 mb-4">My Shifts</h2>
{shifts.length === 0 ? (
<div className="text-center py-12">
<Calendar className="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 text-lg">No shifts scheduled</p>
</div>
) : (
<div className="space-y-4">
{shifts.map((shift) => (
<div
key={shift.id}
className="bg-gradient-to-r from-slate-50 to-white border border-slate-200 rounded-xl p-6 hover:shadow-lg transition-all"
>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold text-slate-900">
{formatDate(shift.shift_date)}
</h3>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
shift.status === 'completed' ? 'bg-green-100 text-green-800' :
shift.status === 'in_progress' ? 'bg-blue-100 text-blue-800' :
shift.status === 'scheduled' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{shift.status.replace('_', ' ').toUpperCase()}
</span>
</div>
<div className="flex items-center gap-4 text-slate-600">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>{shift.start_time} - {shift.end_time}</span>
</div>
{shift.department && (
<span className="px-2 py-1 bg-slate-100 rounded text-sm">{shift.department}</span>
)}
</div>
{shift.notes && (
<p className="text-slate-600 mt-2 text-sm">{shift.notes}</p>
)}
</div>
{shift.status === 'scheduled' && (
<select
value={shift.status}
onChange={(e) => handleStatusUpdate(shift.id, e.target.value)}
className="px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-[#d4af37] cursor-pointer"
>
<option value="scheduled">Scheduled</option>
<option value="in_progress">Start Shift</option>
<option value="cancelled">Cancel</option>
</select>
)}
{shift.status === 'in_progress' && (
<button
onClick={() => handleStatusUpdate(shift.id, 'completed')}
className="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700 transition-colors"
>
End Shift
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Tasks List */}
{tasks.length > 0 && (
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="p-6">
<h2 className="text-xl font-bold text-slate-900 mb-4">My Tasks</h2>
<div className="space-y-3">
{tasks.slice(0, 5).map((task) => (
<div
key={task.id}
className="bg-slate-50 border border-slate-200 rounded-lg p-4"
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-slate-900">{task.title}</h4>
{task.description && (
<p className="text-sm text-slate-600 mt-1">{task.description}</p>
)}
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
task.priority === 'urgent' ? 'bg-red-100 text-red-800' :
task.priority === 'high' ? 'bg-orange-100 text-orange-800' :
'bg-blue-100 text-blue-800'
}`}>
{task.priority.toUpperCase()}
</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default HousekeepingShiftViewPage;

View File

@@ -1,19 +1,141 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import {
RefreshCw,
Clock,
CheckCircle,
AlertCircle,
Calendar,
Users
} from 'lucide-react';
import HousekeepingManagement from '../../features/hotel_services/components/HousekeepingManagement';
import advancedRoomService from '../../features/rooms/services/advancedRoomService';
const HousekeepingTasksPage: React.FC = () => {
const [stats, setStats] = useState({
total: 0,
pending: 0,
in_progress: 0,
completed: 0,
today: 0,
});
const [loadingStats, setLoadingStats] = useState(true);
useEffect(() => {
fetchStats();
// Refresh stats every 30 seconds
const interval = setInterval(fetchStats, 30000);
return () => clearInterval(interval);
}, []);
const fetchStats = async () => {
try {
setLoadingStats(true);
const response = await advancedRoomService.getHousekeepingTasks({
limit: 1000, // Get all tasks for stats
});
if (response.status === 'success' && response.data) {
const tasks = response.data.tasks || [];
const today = new Date().toISOString().split('T')[0];
setStats({
total: tasks.length,
pending: tasks.filter((t: any) => t.status === 'pending').length,
in_progress: tasks.filter((t: any) => t.status === 'in_progress').length,
completed: tasks.filter((t: any) => t.status === 'completed').length,
today: tasks.filter((t: any) => {
if (!t.scheduled_time) return false;
const taskDate = new Date(t.scheduled_time).toISOString().split('T')[0];
return taskDate === today;
}).length,
});
}
} catch (error) {
console.error('Error fetching stats:', error);
} finally {
setLoadingStats(false);
}
};
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900">My Housekeeping Tasks</h1>
<p className="mt-1 text-sm text-gray-500">
View and manage your assigned housekeeping tasks
</p>
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Housekeeping Tasks
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage and track your cleaning tasks</p>
</div>
<button
onClick={fetchStats}
disabled={loadingStats}
className="flex items-center gap-2 px-4 py-2 bg-white border-2 border-slate-200 rounded-xl hover:border-amber-400 transition-colors disabled:opacity-50"
title="Refresh"
>
<RefreshCw className={`w-5 h-5 ${loadingStats ? 'animate-spin' : ''}`} />
<span className="text-sm font-medium">Refresh</span>
</button>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Total Tasks</p>
<p className="text-3xl font-bold text-slate-900 mt-2">{stats.total}</p>
</div>
<Calendar className="w-12 h-12 text-amber-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Pending</p>
<p className="text-3xl font-bold text-yellow-600 mt-2">{stats.pending}</p>
</div>
<Clock className="w-12 h-12 text-yellow-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">In Progress</p>
<p className="text-3xl font-bold text-blue-600 mt-2">{stats.in_progress}</p>
</div>
<Users className="w-12 h-12 text-blue-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Completed</p>
<p className="text-3xl font-bold text-green-600 mt-2">{stats.completed}</p>
</div>
<CheckCircle className="w-12 h-12 text-green-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Today's Tasks</p>
<p className="text-3xl font-bold text-purple-600 mt-2">{stats.today}</p>
</div>
<AlertCircle className="w-12 h-12 text-purple-500" />
</div>
</div>
</div>
{/* Enhanced Housekeeping Management Component */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<HousekeepingManagement />
</div>
</div>
);
};
export default HousekeepingTasksPage;

View File

@@ -0,0 +1,601 @@
import React, { useEffect, useState } from 'react';
import { Search, Plus, Edit, Package, AlertTriangle, Eye, X } from 'lucide-react';
import inventoryService, { InventoryItem } from '../../features/inventory/services/inventoryService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { formatDate } from '../../shared/utils/format';
const InventoryViewPage: React.FC = () => {
const [items, setItems] = useState<InventoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
const [viewingItem, setViewingItem] = useState<InventoryItem | null>(null);
const [lowStockItems, setLowStockItems] = useState<InventoryItem[]>([]);
const [filters, setFilters] = useState({
search: '',
category: '',
low_stock: false,
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 20;
const [formData, setFormData] = useState({
name: '',
description: '',
category: 'cleaning_supplies',
unit: 'piece',
minimum_quantity: 0,
maximum_quantity: 0,
reorder_quantity: 0,
unit_cost: 0,
supplier: '',
supplier_contact: '',
storage_location: '',
barcode: '',
sku: '',
notes: '',
});
const categories = [
{ value: 'cleaning_supplies', label: 'Cleaning Supplies' },
{ value: 'linens', label: 'Linens' },
{ value: 'toiletries', label: 'Toiletries' },
{ value: 'amenities', label: 'Amenities' },
{ value: 'maintenance', label: 'Maintenance' },
{ value: 'food_beverage', label: 'Food & Beverage' },
{ value: 'other', label: 'Other' },
];
const units = [
{ value: 'piece', label: 'Piece' },
{ value: 'box', label: 'Box' },
{ value: 'bottle', label: 'Bottle' },
{ value: 'roll', label: 'Roll' },
{ value: 'pack', label: 'Pack' },
{ value: 'liter', label: 'Liter' },
{ value: 'kilogram', label: 'Kilogram' },
{ value: 'meter', label: 'Meter' },
{ value: 'other', label: 'Other' },
];
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchItems();
fetchLowStockItems();
}, [filters, currentPage]);
const fetchItems = async () => {
try {
setLoading(true);
const params: any = {
page: currentPage,
limit: itemsPerPage,
};
if (filters.category) params.category = filters.category;
if (filters.low_stock) params.low_stock = true;
const response = await inventoryService.getInventoryItems(params);
if (response.status === 'success' && response.data) {
let itemList = response.data.items || [];
if (filters.search) {
itemList = itemList.filter((item: InventoryItem) =>
item.name.toLowerCase().includes(filters.search.toLowerCase()) ||
item.description?.toLowerCase().includes(filters.search.toLowerCase()) ||
item.sku?.toLowerCase().includes(filters.search.toLowerCase())
);
}
setItems(itemList);
setTotalPages(response.data.pagination?.total_pages || 1);
setTotalItems(response.data.pagination?.total || 0);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load inventory items');
} finally {
setLoading(false);
}
};
const fetchLowStockItems = async () => {
try {
const response = await inventoryService.getLowStockItems();
if (response.status === 'success' && response.data) {
setLowStockItems(response.data.items || []);
}
} catch (error) {
console.error('Error fetching low stock items:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
toast.error('Please enter an item name');
return;
}
try {
if (editingItem) {
await inventoryService.updateInventoryItem(editingItem.id, formData);
toast.success('Inventory item updated successfully');
} else {
await inventoryService.createInventoryItem(formData);
toast.success('Inventory item created successfully');
}
setShowModal(false);
resetForm();
fetchItems();
fetchLowStockItems();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to save inventory item');
}
};
const handleEdit = (item: InventoryItem) => {
setEditingItem(item);
setFormData({
name: item.name,
description: item.description || '',
category: item.category,
unit: item.unit,
minimum_quantity: item.minimum_quantity,
maximum_quantity: item.maximum_quantity || 0,
reorder_quantity: item.reorder_quantity || 0,
unit_cost: item.unit_cost || 0,
supplier: item.supplier || '',
supplier_contact: '',
storage_location: item.storage_location || '',
barcode: item.barcode || '',
sku: item.sku || '',
notes: '',
});
setShowModal(true);
};
const resetForm = () => {
setEditingItem(null);
setFormData({
name: '',
description: '',
category: 'cleaning_supplies',
unit: 'piece',
minimum_quantity: 0,
maximum_quantity: 0,
reorder_quantity: 0,
unit_cost: 0,
supplier: '',
supplier_contact: '',
storage_location: '',
barcode: '',
sku: '',
notes: '',
});
};
if (loading && items.length === 0) {
return <Loading fullScreen text="Loading inventory..." />;
}
const getLowStockCount = () => lowStockItems.length;
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-blue-400 to-blue-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Inventory View
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage inventory items and stock levels</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl font-semibold hover:from-blue-600 hover:to-blue-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="w-5 h-5" />
Add Item
</button>
</div>
{/* Filters */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-blue-500 transition-colors" />
<input
type="text"
placeholder="Search inventory..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.category}
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Categories</option>
{categories.map((category) => (
<option key={category.value} value={category.value}>
{category.label}
</option>
))}
</select>
<label className="flex items-center gap-2 px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl cursor-pointer hover:shadow-md transition-all">
<input
type="checkbox"
checked={filters.low_stock}
onChange={(e) => setFilters({ ...filters, low_stock: e.target.checked })}
className="w-5 h-5 text-blue-600 rounded"
/>
<span className="text-slate-700 font-medium">Low Stock Only</span>
</label>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Total Items</p>
<p className="text-3xl font-bold text-slate-900 mt-2">{totalItems}</p>
</div>
<Package className="w-12 h-12 text-blue-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Low Stock Alert</p>
<p className="text-3xl font-bold text-red-600 mt-2">{getLowStockCount()}</p>
</div>
<AlertTriangle className="w-12 h-12 text-red-500" />
</div>
</div>
</div>
{/* Items Table */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b-2 border-slate-200">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Item
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Category
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Current Stock
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Minimum
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
Location
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-slate-700 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{items.map((item) => {
const isLowStock = item.current_quantity !== null && item.minimum_quantity !== null && item.current_quantity <= item.minimum_quantity;
return (
<tr key={item.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div>
<div className="font-semibold text-slate-900">{item.name}</div>
{item.description && (
<div className="text-sm text-slate-500 mt-1 line-clamp-2">{item.description}</div>
)}
{item.sku && (
<div className="text-xs text-slate-400 mt-1">SKU: {item.sku}</div>
)}
</div>
</td>
<td className="px-6 py-4">
<span className="px-3 py-1 rounded-full text-sm font-medium bg-slate-100 text-slate-800">
{categories.find((c) => c.value === item.category)?.label || item.category}
</span>
</td>
<td className="px-6 py-4">
<div className="font-semibold text-slate-900">
{item.current_quantity !== null ? item.current_quantity : 'N/A'} {item.unit}
</div>
</td>
<td className="px-6 py-4">
<div className="text-slate-600">
{item.minimum_quantity !== null ? item.minimum_quantity : '—'} {item.unit}
</div>
</td>
<td className="px-6 py-4">
{isLowStock ? (
<span className="px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
Low Stock
</span>
) : (
<span className="px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
In Stock
</span>
)}
</td>
<td className="px-6 py-4 text-slate-600">
{item.storage_location || '—'}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(item)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => setViewingItem(item)}
className="p-2 text-gray-600 hover:bg-gray-50 rounded-lg transition-colors"
title="View Details"
>
<Eye className="w-5 h-5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{items.length === 0 && !loading && (
<div className="text-center py-12">
<Package className="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 text-lg">No inventory items found</p>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
{/* View Details Modal */}
{viewingItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">Item Details</h2>
<button
onClick={() => setViewingItem(null)}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<h3 className="text-xl font-bold text-slate-900 mb-2">{viewingItem.name}</h3>
{viewingItem.description && (
<p className="text-slate-600">{viewingItem.description}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-slate-600">Category</p>
<p className="font-semibold">{categories.find((c) => c.value === viewingItem.category)?.label || viewingItem.category}</p>
</div>
<div>
<p className="text-sm text-slate-600">Unit</p>
<p className="font-semibold">{viewingItem.unit}</p>
</div>
<div>
<p className="text-sm text-slate-600">Current Stock</p>
<p className="font-semibold text-xl">{viewingItem.current_quantity !== null ? viewingItem.current_quantity : 'N/A'} {viewingItem.unit}</p>
</div>
<div>
<p className="text-sm text-slate-600">Minimum Quantity</p>
<p className="font-semibold">{viewingItem.minimum_quantity !== null ? viewingItem.minimum_quantity : '—'} {viewingItem.unit}</p>
</div>
{viewingItem.storage_location && (
<div>
<p className="text-sm text-slate-600">Storage Location</p>
<p className="font-semibold">{viewingItem.storage_location}</p>
</div>
)}
{viewingItem.sku && (
<div>
<p className="text-sm text-slate-600">SKU</p>
<p className="font-semibold">{viewingItem.sku}</p>
</div>
)}
</div>
{viewingItem.supplier && (
<div>
<p className="text-sm text-slate-600 mb-2">Supplier</p>
<p className="font-semibold">{viewingItem.supplier}</p>
{viewingItem.supplier_contact && (
<p className="text-slate-600 text-sm mt-1">{viewingItem.supplier_contact}</p>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">
{editingItem ? 'Edit Inventory Item' : 'Add Inventory Item'}
</h2>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Item Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Category *</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
required
>
{categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
rows={3}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Unit *</label>
<select
value={formData.unit}
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
required
>
{units.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Min Quantity *</label>
<input
type="number"
value={formData.minimum_quantity}
onChange={(e) => setFormData({ ...formData, minimum_quantity: parseFloat(e.target.value) || 0 })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
min="0"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Storage Location</label>
<input
type="text"
value={formData.storage_location}
onChange={(e) => setFormData({ ...formData, storage_location: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Supplier</label>
<input
type="text"
value={formData.supplier}
onChange={(e) => setFormData({ ...formData, supplier: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">SKU</label>
<input
type="text"
value={formData.sku}
onChange={(e) => setFormData({ ...formData, sku: e.target.value })}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
/>
</div>
</div>
<div className="flex gap-4 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="flex-1 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-semibold hover:bg-slate-200 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl font-semibold hover:from-blue-600 hover:to-blue-700 transition-all"
>
{editingItem ? 'Update Item' : 'Create Item'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default InventoryViewPage;

View File

@@ -0,0 +1,232 @@
import React, { useEffect, useState } from 'react';
import { Calendar, Clock, CheckCircle, AlertCircle } from 'lucide-react';
import staffShiftService, { StaffShift, StaffTask } from '../../features/staffShifts/services/staffShiftService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
const StaffShiftViewPage: React.FC = () => {
const [shifts, setShifts] = useState<StaffShift[]>([]);
const [tasks, setTasks] = useState<StaffTask[]>([]);
const [loading, setLoading] = useState(true);
const [selectedDate, setSelectedDate] = useState(new Date());
useEffect(() => {
fetchShifts();
fetchTasks();
}, [selectedDate]);
const fetchShifts = async () => {
try {
setLoading(true);
const dateStr = selectedDate.toISOString().split('T')[0];
const response = await staffShiftService.getShifts({
shift_date: dateStr,
limit: 100,
});
if (response.status === 'success' && response.data) {
setShifts(response.data.shifts || []);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load shifts');
} finally {
setLoading(false);
}
};
const fetchTasks = async () => {
try {
const response = await staffShiftService.getTasks({
limit: 100,
});
if (response.status === 'success' && response.data) {
setTasks(response.data.tasks || []);
}
} catch (error) {
console.error('Error fetching tasks:', error);
}
};
const handleStatusUpdate = async (shiftId: number, newStatus: string) => {
try {
await staffShiftService.updateShift(shiftId, {
status: newStatus,
});
toast.success('Shift status updated successfully');
fetchShifts();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
}
};
if (loading && shifts.length === 0) {
return <Loading fullScreen text="Loading your shifts..." />;
}
const getUpcomingShifts = () => shifts.filter((s) => s.status === 'scheduled').length;
const getCompletedShifts = () => shifts.filter((s) => s.status === 'completed').length;
const getPendingTasks = () => tasks.filter((t) => ['pending', 'assigned'].includes(t.status)).length;
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-blue-400 to-blue-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
My Shifts
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">View and manage your shift schedule</p>
</div>
<input
type="date"
value={selectedDate.toISOString().split('T')[0]}
onChange={(e) => setSelectedDate(new Date(e.target.value))}
className="px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
/>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 animate-fade-in">
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Upcoming Shifts</p>
<p className="text-3xl font-bold text-blue-600 mt-2">{getUpcomingShifts()}</p>
</div>
<Calendar className="w-12 h-12 text-blue-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Completed</p>
<p className="text-3xl font-bold text-green-600 mt-2">{getCompletedShifts()}</p>
</div>
<CheckCircle className="w-12 h-12 text-green-500" />
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-slate-200/60 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-sm font-medium">Pending Tasks</p>
<p className="text-3xl font-bold text-orange-600 mt-2">{getPendingTasks()}</p>
</div>
<AlertCircle className="w-12 h-12 text-orange-500" />
</div>
</div>
</div>
{/* Shifts List */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="p-6">
<h2 className="text-xl font-bold text-slate-900 mb-4">My Shifts</h2>
{shifts.length === 0 ? (
<div className="text-center py-12">
<Calendar className="w-16 h-16 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 text-lg">No shifts scheduled</p>
</div>
) : (
<div className="space-y-4">
{shifts.map((shift) => (
<div
key={shift.id}
className="bg-gradient-to-r from-slate-50 to-white border border-slate-200 rounded-xl p-6 hover:shadow-lg transition-all"
>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold text-slate-900">
{formatDate(shift.shift_date)}
</h3>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
shift.status === 'completed' ? 'bg-green-100 text-green-800' :
shift.status === 'in_progress' ? 'bg-blue-100 text-blue-800' :
shift.status === 'scheduled' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{shift.status.replace('_', ' ').toUpperCase()}
</span>
</div>
<div className="flex items-center gap-4 text-slate-600">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>{shift.start_time} - {shift.end_time}</span>
</div>
{shift.department && (
<span className="px-2 py-1 bg-slate-100 rounded text-sm">{shift.department}</span>
)}
</div>
{shift.notes && (
<p className="text-slate-600 mt-2 text-sm">{shift.notes}</p>
)}
</div>
{shift.status === 'scheduled' && (
<select
value={shift.status}
onChange={(e) => handleStatusUpdate(shift.id, e.target.value)}
className="px-4 py-2 border-2 border-slate-200 rounded-xl focus:border-blue-400 cursor-pointer"
>
<option value="scheduled">Scheduled</option>
<option value="in_progress">Start Shift</option>
<option value="cancelled">Cancel</option>
</select>
)}
{shift.status === 'in_progress' && (
<button
onClick={() => handleStatusUpdate(shift.id, 'completed')}
className="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700 transition-colors"
>
End Shift
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Tasks List */}
{tasks.length > 0 && (
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in">
<div className="p-6">
<h2 className="text-xl font-bold text-slate-900 mb-4">My Tasks</h2>
<div className="space-y-3">
{tasks.slice(0, 5).map((task) => (
<div
key={task.id}
className="bg-slate-50 border border-slate-200 rounded-lg p-4"
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-slate-900">{task.title}</h4>
{task.description && (
<p className="text-sm text-slate-600 mt-1">{task.description}</p>
)}
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
task.priority === 'urgent' ? 'bg-red-100 text-red-800' :
task.priority === 'high' ? 'bg-orange-100 text-orange-800' :
'bg-blue-100 text-blue-800'
}`}>
{task.priority.toUpperCase()}
</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default StaffShiftViewPage;

View File

@@ -29,12 +29,17 @@ import CookiePreferencesLink from './CookiePreferencesLink';
import ChatWidget from '../../features/notifications/components/ChatWidget';
import pageContentService, { type PageContent } from '../../features/content/services/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
import apiClient from '../services/apiClient';
const Footer: React.FC = () => {
const { settings } = useCompanySettings();
const [pageContent, setPageContent] = useState<PageContent | null>(null);
const [homePageContent, setHomePageContent] = useState<PageContent | null>(null);
const [enabledPages, setEnabledPages] = useState<Set<string>>(new Set());
const [apiError, setApiError] = useState(false);
const [newsletterEmail, setNewsletterEmail] = useState('');
const [newsletterSubmitting, setNewsletterSubmitting] = useState(false);
const [newsletterMessage, setNewsletterMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
const fetchPageContent = async () => {
@@ -53,6 +58,32 @@ const Footer: React.FC = () => {
}
};
const fetchHomePageContent = async () => {
try {
const response = await pageContentService.getHomeContent();
if (response.status === 'success' && response.data?.page_content) {
let content = response.data.page_content;
// Parse sections_enabled if it's a string
if (typeof content.sections_enabled === 'string') {
try {
content.sections_enabled = JSON.parse(content.sections_enabled);
} catch (e) {
content.sections_enabled = {};
}
} else if (typeof content.sections_enabled !== 'object' || content.sections_enabled === null) {
content.sections_enabled = content.sections_enabled || {};
}
// Normalize boolean values
if (content.newsletter_enabled !== undefined) {
content.newsletter_enabled = Boolean(content.newsletter_enabled);
}
setHomePageContent(content);
}
} catch (err: any) {
console.error('Error fetching home content for newsletter:', err);
}
};
const checkEnabledPages = async () => {
const enabled = new Set<string>();
const policyPages = [
@@ -84,6 +115,7 @@ const Footer: React.FC = () => {
};
fetchPageContent();
fetchHomePageContent();
checkEnabledPages();
}, []);
@@ -174,9 +206,9 @@ const Footer: React.FC = () => {
<div className="relative container mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20 lg:py-24">
{/* Main Content Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-10 sm:gap-12 lg:gap-16 mb-16 sm:mb-20">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-8 sm:gap-10 lg:gap-8 mb-16 sm:mb-20">
{/* Brand Section */}
<div className="lg:col-span-2">
<div className="lg:col-span-4">
<div className="flex items-center space-x-4 mb-6 sm:mb-8">
{logoUrl ? (
<div className="relative group">
@@ -294,8 +326,8 @@ const Footer: React.FC = () => {
{/* Quick Links */}
{quickLinks.length > 0 && (
<div>
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-6 sm:mb-8 relative inline-block tracking-wide">
<div className="lg:col-span-2">
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-4 sm:mb-6 relative inline-block tracking-wide">
<span className="relative z-10">Quick Links</span>
<span className="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#d4af37] via-[#d4af37]/50 to-transparent"></span>
</h3>
@@ -317,8 +349,8 @@ const Footer: React.FC = () => {
{/* Guest Services */}
{supportLinks.length > 0 && (
<div>
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-6 sm:mb-8 relative inline-block tracking-wide">
<div className="lg:col-span-2">
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-4 sm:mb-6 relative inline-block tracking-wide">
<span className="relative z-10">Guest Services</span>
<span className="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#d4af37] via-[#d4af37]/50 to-transparent"></span>
</h3>
@@ -338,9 +370,78 @@ const Footer: React.FC = () => {
</div>
)}
{/* Contact Information */}
<div>
{/* Newsletter Subscription - Always Enabled */}
<div className="lg:col-span-2">
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-6 sm:mb-8 relative inline-block tracking-wide">
<span className="relative z-10">{homePageContent?.newsletter_section_title || 'Newsletter'}</span>
<span className="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#d4af37] via-[#d4af37]/50 to-transparent"></span>
</h3>
{homePageContent?.newsletter_section_subtitle && (
<p className="text-sm text-gray-400 mb-4 font-light leading-relaxed">
{homePageContent.newsletter_section_subtitle}
</p>
)}
<form
className="space-y-3"
onSubmit={async (e) => {
e.preventDefault();
if (!newsletterEmail || newsletterSubmitting) return;
setNewsletterSubmitting(true);
setNewsletterMessage(null);
try {
const response = await apiClient.post('/email-campaigns/newsletter/subscribe', {
email: newsletterEmail
});
if (response.data?.status === 'success') {
setNewsletterMessage({ type: 'success', text: 'Successfully subscribed!' });
setNewsletterEmail('');
setTimeout(() => setNewsletterMessage(null), 5000);
} else {
setNewsletterMessage({ type: 'error', text: 'Failed to subscribe. Please try again.' });
}
} catch (error: any) {
console.error('Newsletter subscription error:', error);
const errorMessage = error.response?.data?.detail || error.message || 'Failed to subscribe. Please try again.';
setNewsletterMessage({ type: 'error', text: errorMessage });
} finally {
setNewsletterSubmitting(false);
}
}}
>
<input
type="email"
value={newsletterEmail}
onChange={(e) => setNewsletterEmail(e.target.value)}
placeholder={homePageContent?.newsletter_placeholder || 'Enter your email'}
className="w-full px-4 py-2.5 rounded-lg border border-gray-700 bg-gray-800/50 text-white placeholder-gray-400 focus:border-[#d4af37] focus:ring-2 focus:ring-[#d4af37]/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed text-sm"
required
disabled={newsletterSubmitting}
/>
<button
type="submit"
disabled={newsletterSubmitting || !newsletterEmail}
className="w-full px-4 py-2.5 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-semibold hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 shadow-lg shadow-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/40 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{newsletterSubmitting ? 'Subscribing...' : (homePageContent?.newsletter_button_text || 'Subscribe')}
</button>
{newsletterMessage && (
<div className={`text-xs px-3 py-2 rounded-lg ${
newsletterMessage.type === 'success'
? 'bg-green-500/20 text-green-300 border border-green-500/30'
: 'bg-red-500/20 text-red-300 border border-red-500/30'
}`}>
{newsletterMessage.text}
</div>
)}
</form>
</div>
{/* Contact Information */}
<div className="lg:col-span-2">
<h3 className="text-white font-elegant font-semibold text-lg sm:text-xl mb-4 sm:mb-6 relative inline-block tracking-wide">
<span className="relative z-10">Contact</span>
<span className="absolute bottom-0 left-0 w-full h-[2px] bg-gradient-to-r from-[#d4af37] via-[#d4af37]/50 to-transparent"></span>
</h3>

View File

@@ -36,7 +36,9 @@ import {
Webhook,
Key,
HardDrive,
Activity
Activity,
Calendar,
Boxes
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useResponsive } from '../../hooks';
@@ -136,6 +138,16 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
icon: Hotel,
label: 'Room Management'
},
{
path: '/admin/inventory',
icon: Boxes,
label: 'Inventory'
},
{
path: '/admin/shifts',
icon: Calendar,
label: 'Staff Shifts'
},
]
},
{

View File

@@ -18,7 +18,9 @@ import {
Bell,
Mail,
AlertTriangle,
TrendingUp
TrendingUp,
Package,
Calendar
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useChatNotifications } from '../../features/notifications/contexts/ChatNotificationContext';
@@ -106,11 +108,6 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
icon: CreditCard,
label: 'Payments'
},
{
path: '/staff/loyalty',
icon: Award,
label: 'Loyalty Program'
},
{
path: '/staff/guest-profiles',
icon: Users,
@@ -141,6 +138,16 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
icon: Wrench,
label: 'Room Management'
},
{
path: '/staff/inventory',
icon: Package,
label: 'Inventory'
},
{
path: '/staff/shifts',
icon: Calendar,
label: 'My Shifts'
},
{
path: '/staff/chats',
icon: MessageCircle,

View File

@@ -43,12 +43,25 @@ export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({
? window.localStorage.getItem('cookieConsentDecided')
: null;
const decided =
(localFlag === 'true') || Boolean((data as any).has_decided);
(localFlag === 'true') || Boolean(data?.has_decided);
setHasDecided(decided);
} catch (error) {
if (import.meta.env.DEV) {
} catch (error: any) {
// If API fails, check localStorage for previous decision
const localFlag =
typeof window !== 'undefined'
? window.localStorage.getItem('cookieConsentDecided')
: null;
console.error('Failed to load cookie consent', error);
if (localFlag === 'true') {
// User has previously decided, don't show banner
setHasDecided(true);
} else {
// No previous decision, show banner (consent will be null)
setHasDecided(false);
}
if (import.meta.env.DEV) {
console.error('Failed to load cookie consent:', error?.message || error);
}
} finally {
if (isMounted) {