updates
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
73
Backend/alembic/versions/add_guest_requests_table.py
Normal file
73
Backend/alembic/versions/add_guest_requests_table.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""add_guest_requests_table
|
||||
|
||||
Revision ID: guest_requests_001
|
||||
Revises: inventory_management_001
|
||||
Create Date: 2025-01-02 14:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'guest_requests_001'
|
||||
down_revision = 'inventory_management_001'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create guest_requests table
|
||||
op.create_table(
|
||||
'guest_requests',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('booking_id', sa.Integer(), nullable=False),
|
||||
sa.Column('room_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('request_type', sa.Enum(
|
||||
'extra_towels', 'extra_pillows', 'room_cleaning', 'turndown_service',
|
||||
'amenities', 'maintenance', 'room_service', 'other',
|
||||
name='requesttype'
|
||||
), nullable=False),
|
||||
sa.Column('status', sa.Enum(
|
||||
'pending', 'in_progress', 'fulfilled', 'cancelled',
|
||||
name='requeststatus'
|
||||
), nullable=False, server_default='pending'),
|
||||
sa.Column('priority', sa.Enum(
|
||||
'low', 'normal', 'high', 'urgent',
|
||||
name='requestpriority'
|
||||
), nullable=False, server_default='normal'),
|
||||
sa.Column('title', sa.String(255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('assigned_to', sa.Integer(), nullable=True),
|
||||
sa.Column('fulfilled_by', sa.Integer(), nullable=True),
|
||||
sa.Column('requested_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('started_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('fulfilled_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('guest_notes', sa.Text(), nullable=True),
|
||||
sa.Column('staff_notes', sa.Text(), nullable=True),
|
||||
sa.Column('response_time_minutes', sa.Integer(), nullable=True),
|
||||
sa.Column('fulfillment_time_minutes', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ),
|
||||
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['assigned_to'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['fulfilled_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_guest_requests_id'), 'guest_requests', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_guest_requests_booking_id'), 'guest_requests', ['booking_id'], unique=False)
|
||||
op.create_index(op.f('ix_guest_requests_room_id'), 'guest_requests', ['room_id'], unique=False)
|
||||
op.create_index(op.f('ix_guest_requests_requested_at'), 'guest_requests', ['requested_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_guest_requests_requested_at'), table_name='guest_requests')
|
||||
op.drop_index(op.f('ix_guest_requests_room_id'), table_name='guest_requests')
|
||||
op.drop_index(op.f('ix_guest_requests_booking_id'), table_name='guest_requests')
|
||||
op.drop_index(op.f('ix_guest_requests_id'), table_name='guest_requests')
|
||||
op.drop_table('guest_requests')
|
||||
|
||||
171
Backend/alembic/versions/add_inventory_management_tables.py
Normal file
171
Backend/alembic/versions/add_inventory_management_tables.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""add_inventory_management_tables
|
||||
|
||||
Revision ID: inventory_management_001
|
||||
Revises: add_photos_to_housekeeping_tasks
|
||||
Create Date: 2025-01-02 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'inventory_management_001'
|
||||
down_revision = 'add_photos_housekeeping'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create inventory_items table
|
||||
op.create_table(
|
||||
'inventory_items',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('category', sa.Enum(
|
||||
'cleaning_supplies', 'linens', 'toiletries', 'amenities',
|
||||
'maintenance', 'food_beverage', 'other',
|
||||
name='inventorycategory'
|
||||
), nullable=False),
|
||||
sa.Column('unit', sa.Enum(
|
||||
'piece', 'box', 'bottle', 'roll', 'pack', 'liter',
|
||||
'kilogram', 'meter', 'other',
|
||||
name='inventoryunit'
|
||||
), nullable=False),
|
||||
sa.Column('current_quantity', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'),
|
||||
sa.Column('minimum_quantity', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'),
|
||||
sa.Column('maximum_quantity', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('reorder_quantity', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('unit_cost', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('supplier', sa.String(255), nullable=True),
|
||||
sa.Column('supplier_contact', sa.Text(), nullable=True),
|
||||
sa.Column('storage_location', sa.String(255), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('is_tracked', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('barcode', sa.String(100), nullable=True),
|
||||
sa.Column('sku', sa.String(100), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('created_by', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_inventory_items_id'), 'inventory_items', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_items_name'), 'inventory_items', ['name'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_items_barcode'), 'inventory_items', ['barcode'], unique=True)
|
||||
op.create_index(op.f('ix_inventory_items_sku'), 'inventory_items', ['sku'], unique=True)
|
||||
|
||||
# Create inventory_transactions table
|
||||
op.create_table(
|
||||
'inventory_transactions',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('item_id', sa.Integer(), nullable=False),
|
||||
sa.Column('transaction_type', sa.Enum(
|
||||
'consumption', 'adjustment', 'received', 'transfer',
|
||||
'damaged', 'returned',
|
||||
name='transactiontype'
|
||||
), nullable=False),
|
||||
sa.Column('quantity', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('quantity_before', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('quantity_after', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('reference_type', sa.String(50), nullable=True),
|
||||
sa.Column('reference_id', sa.Integer(), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('cost', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('performed_by', sa.Integer(), nullable=True),
|
||||
sa.Column('transaction_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['item_id'], ['inventory_items.id'], ),
|
||||
sa.ForeignKeyConstraint(['performed_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_inventory_transactions_id'), 'inventory_transactions', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_transactions_item_id'), 'inventory_transactions', ['item_id'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_transactions_reference_id'), 'inventory_transactions', ['reference_id'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_transactions_transaction_date'), 'inventory_transactions', ['transaction_date'], unique=False)
|
||||
|
||||
# Create inventory_reorder_requests table
|
||||
op.create_table(
|
||||
'inventory_reorder_requests',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('item_id', sa.Integer(), nullable=False),
|
||||
sa.Column('requested_quantity', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('current_quantity', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('minimum_quantity', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('status', sa.Enum(
|
||||
'pending', 'approved', 'ordered', 'received', 'cancelled',
|
||||
name='reorderstatus'
|
||||
), nullable=False, server_default='pending'),
|
||||
sa.Column('priority', sa.String(20), nullable=False, server_default='normal'),
|
||||
sa.Column('requested_by', sa.Integer(), nullable=False),
|
||||
sa.Column('requested_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('approved_by', sa.Integer(), nullable=True),
|
||||
sa.Column('approved_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('approval_notes', sa.Text(), nullable=True),
|
||||
sa.Column('order_number', sa.String(100), nullable=True),
|
||||
sa.Column('expected_delivery_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('received_quantity', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('received_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['item_id'], ['inventory_items.id'], ),
|
||||
sa.ForeignKeyConstraint(['requested_by'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['approved_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_inventory_reorder_requests_id'), 'inventory_reorder_requests', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_reorder_requests_item_id'), 'inventory_reorder_requests', ['item_id'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_reorder_requests_order_number'), 'inventory_reorder_requests', ['order_number'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_reorder_requests_requested_at'), 'inventory_reorder_requests', ['requested_at'], unique=False)
|
||||
|
||||
# Create inventory_task_consumptions table
|
||||
op.create_table(
|
||||
'inventory_task_consumptions',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('task_id', sa.Integer(), nullable=False),
|
||||
sa.Column('item_id', sa.Integer(), nullable=False),
|
||||
sa.Column('quantity', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('recorded_by', sa.Integer(), nullable=True),
|
||||
sa.Column('recorded_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['task_id'], ['housekeeping_tasks.id'], ),
|
||||
sa.ForeignKeyConstraint(['item_id'], ['inventory_items.id'], ),
|
||||
sa.ForeignKeyConstraint(['recorded_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_inventory_task_consumptions_id'), 'inventory_task_consumptions', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_task_consumptions_task_id'), 'inventory_task_consumptions', ['task_id'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_task_consumptions_item_id'), 'inventory_task_consumptions', ['item_id'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_task_consumptions_recorded_at'), 'inventory_task_consumptions', ['recorded_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_inventory_task_consumptions_recorded_at'), table_name='inventory_task_consumptions')
|
||||
op.drop_index(op.f('ix_inventory_task_consumptions_item_id'), table_name='inventory_task_consumptions')
|
||||
op.drop_index(op.f('ix_inventory_task_consumptions_task_id'), table_name='inventory_task_consumptions')
|
||||
op.drop_index(op.f('ix_inventory_task_consumptions_id'), table_name='inventory_task_consumptions')
|
||||
op.drop_table('inventory_task_consumptions')
|
||||
|
||||
op.drop_index(op.f('ix_inventory_reorder_requests_requested_at'), table_name='inventory_reorder_requests')
|
||||
op.drop_index(op.f('ix_inventory_reorder_requests_order_number'), table_name='inventory_reorder_requests')
|
||||
op.drop_index(op.f('ix_inventory_reorder_requests_item_id'), table_name='inventory_reorder_requests')
|
||||
op.drop_index(op.f('ix_inventory_reorder_requests_id'), table_name='inventory_reorder_requests')
|
||||
op.drop_table('inventory_reorder_requests')
|
||||
|
||||
op.drop_index(op.f('ix_inventory_transactions_transaction_date'), table_name='inventory_transactions')
|
||||
op.drop_index(op.f('ix_inventory_transactions_reference_id'), table_name='inventory_transactions')
|
||||
op.drop_index(op.f('ix_inventory_transactions_item_id'), table_name='inventory_transactions')
|
||||
op.drop_index(op.f('ix_inventory_transactions_id'), table_name='inventory_transactions')
|
||||
op.drop_table('inventory_transactions')
|
||||
|
||||
op.drop_index(op.f('ix_inventory_items_sku'), table_name='inventory_items')
|
||||
op.drop_index(op.f('ix_inventory_items_barcode'), table_name='inventory_items')
|
||||
op.drop_index(op.f('ix_inventory_items_name'), table_name='inventory_items')
|
||||
op.drop_index(op.f('ix_inventory_items_id'), table_name='inventory_items')
|
||||
op.drop_table('inventory_items')
|
||||
|
||||
28
Backend/alembic/versions/add_photos_to_housekeeping_tasks.py
Normal file
28
Backend/alembic/versions/add_photos_to_housekeeping_tasks.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""add_photos_to_housekeeping_tasks
|
||||
|
||||
Revision ID: add_photos_housekeeping
|
||||
Revises: d032f2351965
|
||||
Create Date: 2025-01-02 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_photos_housekeeping'
|
||||
down_revision = 'd032f2351965'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add photos column to housekeeping_tasks table
|
||||
op.add_column('housekeeping_tasks', sa.Column('photos', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove photos column
|
||||
op.drop_column('housekeeping_tasks', 'photos')
|
||||
|
||||
97
Backend/alembic/versions/add_staff_shifts_tables.py
Normal file
97
Backend/alembic/versions/add_staff_shifts_tables.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""add_staff_shifts_tables
|
||||
|
||||
Revision ID: staff_shifts_001
|
||||
Revises: guest_requests_001
|
||||
Create Date: 2025-01-02 16:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'staff_shifts_001'
|
||||
down_revision = 'guest_requests_001'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create staff_shifts table
|
||||
op.create_table(
|
||||
'staff_shifts',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('staff_id', sa.Integer(), nullable=False),
|
||||
sa.Column('shift_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('shift_type', sa.Enum('morning', 'afternoon', 'night', 'full_day', 'custom', name='shifttype'), nullable=False),
|
||||
sa.Column('start_time', sa.Time(), nullable=False),
|
||||
sa.Column('end_time', sa.Time(), nullable=False),
|
||||
sa.Column('status', sa.Enum('scheduled', 'in_progress', 'completed', 'cancelled', 'no_show', name='shiftstatus'), nullable=False, server_default='scheduled'),
|
||||
sa.Column('actual_start_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('actual_end_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('break_duration_minutes', sa.Integer(), nullable=True, server_default='30'),
|
||||
sa.Column('assigned_by', sa.Integer(), nullable=True),
|
||||
sa.Column('department', sa.String(length=100), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('handover_notes', sa.Text(), nullable=True),
|
||||
sa.Column('tasks_completed', sa.Integer(), nullable=True, server_default='0'),
|
||||
sa.Column('tasks_assigned', sa.Integer(), nullable=True, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['assigned_by'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['staff_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_staff_shifts_staff_id'), 'staff_shifts', ['staff_id'], unique=False)
|
||||
op.create_index(op.f('ix_staff_shifts_shift_date'), 'staff_shifts', ['shift_date'], unique=False)
|
||||
|
||||
# Create staff_tasks table
|
||||
op.create_table(
|
||||
'staff_tasks',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('shift_id', sa.Integer(), nullable=True),
|
||||
sa.Column('staff_id', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('task_type', sa.String(length=100), nullable=False),
|
||||
sa.Column('priority', sa.Enum('low', 'normal', 'high', 'urgent', name='stafftaskpriority'), nullable=False, server_default='normal'),
|
||||
sa.Column('status', sa.Enum('pending', 'assigned', 'in_progress', 'completed', 'cancelled', 'on_hold', name='stafftaskstatus'), nullable=False, server_default='pending'),
|
||||
sa.Column('scheduled_start', sa.DateTime(), nullable=True),
|
||||
sa.Column('scheduled_end', sa.DateTime(), nullable=True),
|
||||
sa.Column('actual_start', sa.DateTime(), nullable=True),
|
||||
sa.Column('actual_end', sa.DateTime(), nullable=True),
|
||||
sa.Column('estimated_duration_minutes', sa.Integer(), nullable=True),
|
||||
sa.Column('actual_duration_minutes', sa.Integer(), nullable=True),
|
||||
sa.Column('assigned_by', sa.Integer(), nullable=True),
|
||||
sa.Column('due_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('related_booking_id', sa.Integer(), nullable=True),
|
||||
sa.Column('related_room_id', sa.Integer(), nullable=True),
|
||||
sa.Column('related_guest_request_id', sa.Integer(), nullable=True),
|
||||
sa.Column('related_maintenance_id', sa.Integer(), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('completion_notes', sa.Text(), nullable=True),
|
||||
sa.Column('is_recurring', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('recurrence_pattern', sa.String(length=100), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['assigned_by'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['related_booking_id'], ['bookings.id'], ),
|
||||
sa.ForeignKeyConstraint(['related_guest_request_id'], ['guest_requests.id'], ),
|
||||
sa.ForeignKeyConstraint(['related_maintenance_id'], ['room_maintenance.id'], ),
|
||||
sa.ForeignKeyConstraint(['related_room_id'], ['rooms.id'], ),
|
||||
sa.ForeignKeyConstraint(['shift_id'], ['staff_shifts.id'], ),
|
||||
sa.ForeignKeyConstraint(['staff_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_staff_tasks_shift_id'), 'staff_tasks', ['shift_id'], unique=False)
|
||||
op.create_index(op.f('ix_staff_tasks_staff_id'), 'staff_tasks', ['staff_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_staff_tasks_staff_id'), table_name='staff_tasks')
|
||||
op.drop_index(op.f('ix_staff_tasks_shift_id'), table_name='staff_tasks')
|
||||
op.drop_table('staff_tasks')
|
||||
op.drop_index(op.f('ix_staff_shifts_shift_date'), table_name='staff_shifts')
|
||||
op.drop_index(op.f('ix_staff_shifts_staff_id'), table_name='staff_shifts')
|
||||
op.drop_table('staff_shifts')
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""add_financial_audit_trail_table
|
||||
|
||||
Revision ID: d032f2351965
|
||||
Revises: 6f7f8689fc98
|
||||
Create Date: 2025-12-03 23:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd032f2351965'
|
||||
down_revision = '6f7f8689fc98'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check if table already exists
|
||||
from sqlalchemy import inspect
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'financial_audit_trail' not in tables:
|
||||
# Create financial_audit_trail table
|
||||
op.create_table(
|
||||
'financial_audit_trail',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
|
||||
# Action details
|
||||
sa.Column('action_type', sa.Enum(
|
||||
'payment_created', 'payment_completed', 'payment_refunded',
|
||||
'payment_failed', 'invoice_created', 'invoice_updated',
|
||||
'invoice_paid', 'refund_processed', 'price_modified',
|
||||
'discount_applied', 'promotion_applied',
|
||||
name='financialactiontype'
|
||||
), nullable=False),
|
||||
sa.Column('action_description', sa.Text(), nullable=False),
|
||||
|
||||
# Related entities
|
||||
sa.Column('payment_id', sa.Integer(), nullable=True),
|
||||
sa.Column('invoice_id', sa.Integer(), nullable=True),
|
||||
sa.Column('booking_id', sa.Integer(), nullable=True),
|
||||
|
||||
# Financial details
|
||||
sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('previous_amount', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('currency', sa.String(length=3), nullable=True, server_default='USD'),
|
||||
|
||||
# User information
|
||||
sa.Column('performed_by', sa.Integer(), nullable=False),
|
||||
sa.Column('performed_by_email', sa.String(length=255), nullable=True),
|
||||
|
||||
# Additional context
|
||||
sa.Column('audit_metadata', sa.JSON(), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
|
||||
# Timestamp
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
|
||||
# Primary key
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
|
||||
# Foreign keys
|
||||
sa.ForeignKeyConstraint(['payment_id'], ['payments.id'], name='fk_financial_audit_payment'),
|
||||
sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], name='fk_financial_audit_invoice'),
|
||||
sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], name='fk_financial_audit_booking'),
|
||||
sa.ForeignKeyConstraint(['performed_by'], ['users.id'], name='fk_financial_audit_user')
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
op.create_index(op.f('ix_financial_audit_trail_id'), 'financial_audit_trail', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_financial_audit_trail_action_type'), 'financial_audit_trail', ['action_type'], unique=False)
|
||||
op.create_index(op.f('ix_financial_audit_trail_payment_id'), 'financial_audit_trail', ['payment_id'], unique=False)
|
||||
op.create_index(op.f('ix_financial_audit_trail_invoice_id'), 'financial_audit_trail', ['invoice_id'], unique=False)
|
||||
op.create_index(op.f('ix_financial_audit_trail_booking_id'), 'financial_audit_trail', ['booking_id'], unique=False)
|
||||
op.create_index(op.f('ix_financial_audit_trail_performed_by'), 'financial_audit_trail', ['performed_by'], unique=False)
|
||||
op.create_index(op.f('ix_financial_audit_trail_created_at'), 'financial_audit_trail', ['created_at'], unique=False)
|
||||
|
||||
# Create composite indexes
|
||||
op.create_index('idx_financial_audit_created', 'financial_audit_trail', ['created_at'], unique=False)
|
||||
op.create_index('idx_financial_audit_action', 'financial_audit_trail', ['action_type', 'created_at'], unique=False)
|
||||
op.create_index('idx_financial_audit_user', 'financial_audit_trail', ['performed_by', 'created_at'], unique=False)
|
||||
op.create_index('idx_financial_audit_booking', 'financial_audit_trail', ['booking_id', 'created_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop indexes first
|
||||
op.drop_index('idx_financial_audit_booking', table_name='financial_audit_trail')
|
||||
op.drop_index('idx_financial_audit_user', table_name='financial_audit_trail')
|
||||
op.drop_index('idx_financial_audit_action', table_name='financial_audit_trail')
|
||||
op.drop_index('idx_financial_audit_created', table_name='financial_audit_trail')
|
||||
|
||||
op.drop_index(op.f('ix_financial_audit_trail_created_at'), table_name='financial_audit_trail')
|
||||
op.drop_index(op.f('ix_financial_audit_trail_performed_by'), table_name='financial_audit_trail')
|
||||
op.drop_index(op.f('ix_financial_audit_trail_booking_id'), table_name='financial_audit_trail')
|
||||
op.drop_index(op.f('ix_financial_audit_trail_invoice_id'), table_name='financial_audit_trail')
|
||||
op.drop_index(op.f('ix_financial_audit_trail_payment_id'), table_name='financial_audit_trail')
|
||||
op.drop_index(op.f('ix_financial_audit_trail_action_type'), table_name='financial_audit_trail')
|
||||
op.drop_index(op.f('ix_financial_audit_trail_id'), table_name='financial_audit_trail')
|
||||
|
||||
# Drop table
|
||||
op.drop_table('financial_audit_trail')
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import or_
|
||||
from typing import Optional
|
||||
import bcrypt
|
||||
@@ -14,8 +14,8 @@ from ...analytics.services.audit_service import audit_service
|
||||
from ..schemas.user import CreateUserRequest, UpdateUserRequest
|
||||
router = APIRouter(prefix='/users', tags=['users'])
|
||||
|
||||
@router.get('/', dependencies=[Depends(authorize_roles('admin'))])
|
||||
async def get_users(search: Optional[str]=Query(None), role: Optional[str]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
@router.get('/')
|
||||
async def get_users(search: Optional[str]=Query(None), role: Optional[str]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)):
|
||||
try:
|
||||
query = db.query(User)
|
||||
if search:
|
||||
@@ -110,15 +110,25 @@ async def create_user(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put('/{id}')
|
||||
async def update_user(id: int, user_data: UpdateUserRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
async def update_user(id: int, request: Request, user_data: UpdateUserRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
"""Update a user with validated input using Pydantic schema."""
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
if not can_manage_users(current_user, db) and current_user.id != id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
user = db.query(User).filter(User.id == id).first()
|
||||
user = db.query(User).options(joinedload(User.role)).filter(User.id == id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail='User not found')
|
||||
|
||||
# Track changes for audit logging
|
||||
changes = {}
|
||||
old_role_id = user.role_id
|
||||
old_role_name = user.role.name if user.role else None
|
||||
old_is_active = user.is_active
|
||||
|
||||
# Check email uniqueness if being updated
|
||||
if user_data.email and user_data.email != user.email:
|
||||
existing = db.query(User).filter(User.email == user_data.email).first()
|
||||
@@ -129,15 +139,89 @@ async def update_user(id: int, user_data: UpdateUserRequest, current_user: User=
|
||||
if user_data.full_name is not None:
|
||||
user.full_name = user_data.full_name
|
||||
if user_data.email is not None and can_manage_users(current_user, db):
|
||||
if user_data.email != user.email:
|
||||
changes['email'] = {'old': user.email, 'new': user_data.email}
|
||||
user.email = user_data.email
|
||||
if user_data.phone_number is not None:
|
||||
user.phone = user_data.phone_number
|
||||
if user_data.role_id is not None and can_manage_users(current_user, db):
|
||||
# SECURITY: Prevent admin from changing their own role
|
||||
if current_user.id == id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='You cannot change your own role. Please ask another admin to do it.'
|
||||
)
|
||||
new_role = db.query(Role).filter(Role.id == user_data.role_id).first()
|
||||
new_role_name = new_role.name if new_role else None
|
||||
if user_data.role_id != old_role_id:
|
||||
changes['role'] = {'old': old_role_name, 'new': new_role_name, 'old_id': old_role_id, 'new_id': user_data.role_id}
|
||||
user.role_id = user_data.role_id
|
||||
if user_data.is_active is not None and can_manage_users(current_user, db):
|
||||
if user_data.is_active != old_is_active:
|
||||
changes['is_active'] = {'old': old_is_active, 'new': user_data.is_active}
|
||||
user.is_active = user_data.is_active
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# SECURITY: Log sensitive user changes for audit trail
|
||||
if changes and can_manage_users(current_user, db):
|
||||
try:
|
||||
audit_details = {
|
||||
'updated_user_id': user.id,
|
||||
'updated_user_email': user.email,
|
||||
'changes': changes
|
||||
}
|
||||
|
||||
# Log role changes separately as high-priority audit events
|
||||
if 'role' in changes:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='user_role_changed',
|
||||
resource_type='user',
|
||||
user_id=current_user.id,
|
||||
resource_id=user.id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details=audit_details,
|
||||
status='success'
|
||||
)
|
||||
|
||||
# Log account status changes (activation/deactivation)
|
||||
if 'is_active' in changes:
|
||||
action_name = 'user_deactivated' if not user_data.is_active else 'user_activated'
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action=action_name,
|
||||
resource_type='user',
|
||||
user_id=current_user.id,
|
||||
resource_id=user.id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details=audit_details,
|
||||
status='success'
|
||||
)
|
||||
|
||||
# Log other significant changes
|
||||
if 'email' in changes:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='user_email_changed',
|
||||
resource_type='user',
|
||||
user_id=current_user.id,
|
||||
resource_id=user.id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details=audit_details,
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f'Failed to log user update audit: {e}')
|
||||
|
||||
user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'currency': getattr(user, 'currency', 'VND'), 'role_id': user.role_id, 'is_active': user.is_active}
|
||||
return success_response(data={'user': user_dict}, message='User updated successfully')
|
||||
except HTTPException:
|
||||
@@ -147,16 +231,59 @@ async def update_user(id: int, user_data: UpdateUserRequest, current_user: User=
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
|
||||
async def delete_user(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
async def delete_user(id: int, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
user = db.query(User).filter(User.id == id).first()
|
||||
user = db.query(User).options(joinedload(User.role)).filter(User.id == id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail='User not found')
|
||||
|
||||
# SECURITY: Prevent admin from deleting themselves
|
||||
if current_user.id == id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='You cannot delete your own account. Please ask another admin to do it.'
|
||||
)
|
||||
|
||||
# Capture user info before deletion for audit
|
||||
deleted_user_info = {
|
||||
'user_id': user.id,
|
||||
'email': user.email,
|
||||
'full_name': user.full_name,
|
||||
'role': user.role.name if user.role else None,
|
||||
'role_id': user.role_id,
|
||||
'is_active': user.is_active
|
||||
}
|
||||
|
||||
active_bookings = db.query(Booking).filter(Booking.user_id == id, Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])).count()
|
||||
if active_bookings > 0:
|
||||
raise HTTPException(status_code=400, detail='Cannot delete user with active bookings')
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
# SECURITY: Log user deletion for audit trail
|
||||
try:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='user_deleted',
|
||||
resource_type='user',
|
||||
user_id=current_user.id,
|
||||
resource_id=id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details=deleted_user_info,
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f'Failed to log user deletion audit: {e}')
|
||||
|
||||
return success_response(message='User deleted successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -25,6 +25,7 @@ from ...shared.utils.email_templates import booking_confirmation_email_template,
|
||||
from ...loyalty.services.loyalty_service import LoyaltyService
|
||||
from ...shared.utils.currency_helpers import get_currency_symbol
|
||||
from ...shared.utils.response_helpers import success_response
|
||||
from ...analytics.services.audit_service import audit_service
|
||||
from ..schemas.booking import CreateBookingRequest, UpdateBookingRequest
|
||||
from ..schemas.admin_booking import AdminCreateBookingRequest
|
||||
router = APIRouter(prefix='/bookings', tags=['bookings'])
|
||||
@@ -609,8 +610,12 @@ async def get_booking_by_id(id: int, request: Request, current_user: User=Depend
|
||||
raise HTTPException(status_code=500, detail='An error occurred while fetching booking')
|
||||
|
||||
@router.patch('/{id}/cancel')
|
||||
async def cancel_booking(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
async def cancel_booking(id: int, request: Request, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
try:
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
booking = db.query(Booking).filter(Booking.id == id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
@@ -618,6 +623,8 @@ async def cancel_booking(id: int, current_user: User=Depends(get_current_user),
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
if booking.status == BookingStatus.cancelled:
|
||||
raise HTTPException(status_code=400, detail='Booking already cancelled')
|
||||
|
||||
old_status = booking.status.value if hasattr(booking.status, 'value') else str(booking.status)
|
||||
# Customers can only cancel pending bookings
|
||||
# Admin/Staff can cancel any booking via update_booking endpoint
|
||||
if booking.status != BookingStatus.pending:
|
||||
@@ -674,6 +681,34 @@ async def cancel_booking(id: int, current_user: User=Depends(get_current_user),
|
||||
if payments_updated:
|
||||
db.flush()
|
||||
db.commit()
|
||||
|
||||
# SECURITY: Log booking cancellation for audit trail
|
||||
try:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='booking_cancelled',
|
||||
resource_type='booking',
|
||||
user_id=current_user.id,
|
||||
resource_id=booking.id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details={
|
||||
'booking_number': booking.booking_number,
|
||||
'old_status': old_status,
|
||||
'new_status': 'cancelled',
|
||||
'booking_user_id': booking.user_id,
|
||||
'room_id': booking.room_id if booking.room else None,
|
||||
'total_price': float(booking.total_price) if booking.total_price else 0.0,
|
||||
'payments_updated': payments_updated
|
||||
},
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f'Failed to log booking cancellation audit: {e}')
|
||||
|
||||
try:
|
||||
from ...system.models.system_settings import SystemSettings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
|
||||
@@ -692,7 +727,11 @@ async def cancel_booking(id: int, current_user: User=Depends(get_current_user),
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put('/{id}', dependencies=[Depends(authorize_roles('admin', 'staff'))])
|
||||
async def update_booking(id: int, booking_data: UpdateBookingRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
async def update_booking(id: int, request: Request, booking_data: UpdateBookingRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
booking = db.query(Booking).options(
|
||||
selectinload(Booking.payments),
|
||||
@@ -701,6 +740,7 @@ async def update_booking(id: int, booking_data: UpdateBookingRequest, current_us
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
old_status = booking.status
|
||||
old_status_value = old_status.value if hasattr(old_status, 'value') else str(old_status)
|
||||
status_value = booking_data.status
|
||||
room = booking.room
|
||||
new_status = None
|
||||
@@ -869,6 +909,39 @@ async def update_booking(id: int, booking_data: UpdateBookingRequest, current_us
|
||||
|
||||
db.commit()
|
||||
|
||||
# SECURITY: Log booking status changes for audit trail (especially cancellations)
|
||||
if status_value and old_status != booking.status:
|
||||
try:
|
||||
new_status_value = booking.status.value if hasattr(booking.status, 'value') else str(booking.status)
|
||||
action_name = 'booking_status_changed'
|
||||
if new_status == BookingStatus.cancelled:
|
||||
action_name = 'booking_cancelled_by_admin'
|
||||
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action=action_name,
|
||||
resource_type='booking',
|
||||
user_id=current_user.id,
|
||||
resource_id=booking.id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details={
|
||||
'booking_number': booking.booking_number,
|
||||
'old_status': old_status_value,
|
||||
'new_status': new_status_value,
|
||||
'booking_user_id': booking.user_id,
|
||||
'room_id': booking.room_id if booking.room else None,
|
||||
'total_price': float(booking.total_price) if booking.total_price else 0.0,
|
||||
'changed_by': 'admin' if current_user.role and current_user.role.name == 'admin' else 'staff'
|
||||
},
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f'Failed to log booking status change audit: {e}')
|
||||
|
||||
# Send booking confirmation notification if status changed to confirmed
|
||||
if new_status == BookingStatus.confirmed:
|
||||
try:
|
||||
|
||||
93
Backend/src/bookings/routes/upsell_routes.py
Normal file
93
Backend/src/bookings/routes/upsell_routes.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from ...shared.config.database import get_db
|
||||
from ...shared.config.logging_config import get_logger
|
||||
from ...security.middleware.auth import authorize_roles, get_current_user
|
||||
from ...auth.models.user import User
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
from ...hotel_services.models.service_usage import ServiceUsage
|
||||
from ...hotel_services.models.service import Service
|
||||
from ...rooms.models.room import Room
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix='/bookings', tags=['upsells'])
|
||||
|
||||
|
||||
class AddServiceUsageRequest(BaseModel):
|
||||
booking_id: int
|
||||
service_id: int
|
||||
quantity: int = 1
|
||||
unit_price: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@router.post('/service-usage')
|
||||
async def add_service_usage(
|
||||
request: AddServiceUsageRequest,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add a service usage to an existing booking"""
|
||||
try:
|
||||
# Verify booking exists and is checked in
|
||||
booking = db.query(Booking).filter(Booking.id == request.booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
|
||||
if booking.status != BookingStatus.checked_in:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='Services can only be added to checked-in bookings'
|
||||
)
|
||||
|
||||
# Verify service exists and is active
|
||||
service = db.query(Service).filter(Service.id == request.service_id).first()
|
||||
if not service:
|
||||
raise HTTPException(status_code=404, detail='Service not found')
|
||||
|
||||
if not service.is_active:
|
||||
raise HTTPException(status_code=400, detail='Service is not active')
|
||||
|
||||
# Use provided unit_price or service price
|
||||
unit_price = request.unit_price if request.unit_price is not None else float(service.price)
|
||||
total_price = unit_price * request.quantity
|
||||
|
||||
# Create service usage
|
||||
service_usage = ServiceUsage(
|
||||
booking_id=request.booking_id,
|
||||
service_id=request.service_id,
|
||||
quantity=request.quantity,
|
||||
unit_price=unit_price,
|
||||
total_price=total_price,
|
||||
notes=request.notes,
|
||||
usage_date=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(service_usage)
|
||||
|
||||
# Update booking total price
|
||||
booking.total_price = (float(booking.total_price) if booking.total_price else 0) + total_price
|
||||
|
||||
db.commit()
|
||||
db.refresh(service_usage)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Service added to booking successfully',
|
||||
'data': {
|
||||
'id': service_usage.id,
|
||||
'service_name': service.name,
|
||||
'quantity': service_usage.quantity,
|
||||
'total_price': float(service_usage.total_price)
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error adding service usage: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f'Failed to add service: {str(e)}')
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Routes for guest complaint management.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_, func
|
||||
from typing import Optional
|
||||
@@ -18,6 +18,7 @@ from ..schemas.complaint import (
|
||||
AddComplaintUpdateRequest, ResolveComplaintRequest
|
||||
)
|
||||
from ...shared.utils.response_helpers import success_response
|
||||
from ...analytics.services.audit_service import audit_service
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix='/complaints', tags=['complaints'])
|
||||
@@ -26,10 +27,15 @@ router = APIRouter(prefix='/complaints', tags=['complaints'])
|
||||
@router.post('/')
|
||||
async def create_complaint(
|
||||
complaint_data: CreateComplaintRequest,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new guest complaint."""
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
# Verify booking ownership if booking_id provided
|
||||
if complaint_data.booking_id:
|
||||
@@ -74,6 +80,31 @@ async def create_complaint(
|
||||
db.commit()
|
||||
db.refresh(complaint)
|
||||
|
||||
# SECURITY: Log complaint creation for audit trail
|
||||
try:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='complaint_created',
|
||||
resource_type='complaint',
|
||||
user_id=current_user.id,
|
||||
resource_id=complaint.id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details={
|
||||
'complaint_id': complaint.id,
|
||||
'title': complaint.title,
|
||||
'category': complaint.category.value,
|
||||
'priority': complaint.priority.value,
|
||||
'status': complaint.status.value,
|
||||
'booking_id': complaint.booking_id,
|
||||
'room_id': complaint.room_id
|
||||
},
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log complaint creation audit: {e}')
|
||||
|
||||
return success_response(
|
||||
data={'complaint': {
|
||||
'id': complaint.id,
|
||||
@@ -127,6 +158,15 @@ async def get_complaints(
|
||||
# Staff/admin can filter by assignee
|
||||
query = query.filter(GuestComplaint.assigned_to == assigned_to)
|
||||
|
||||
from sqlalchemy.orm import joinedload
|
||||
from ...rooms.models.room import Room
|
||||
|
||||
# Eager load relationships
|
||||
query = query.options(
|
||||
joinedload(GuestComplaint.guest),
|
||||
joinedload(GuestComplaint.assignee)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if status:
|
||||
try:
|
||||
@@ -151,16 +191,29 @@ async def get_complaints(
|
||||
|
||||
complaints_data = []
|
||||
for complaint in complaints:
|
||||
# Get room number if room_id exists
|
||||
room_number = None
|
||||
if complaint.room_id:
|
||||
room = db.query(Room).filter(Room.id == complaint.room_id).first()
|
||||
if room:
|
||||
room_number = room.room_number
|
||||
|
||||
complaints_data.append({
|
||||
'id': complaint.id,
|
||||
'title': complaint.title,
|
||||
'description': complaint.description,
|
||||
'category': complaint.category.value,
|
||||
'priority': complaint.priority.value,
|
||||
'status': complaint.status.value,
|
||||
'guest_id': complaint.guest_id,
|
||||
'guest_name': complaint.guest.full_name if complaint.guest else None,
|
||||
'booking_id': complaint.booking_id,
|
||||
'room_id': complaint.room_id,
|
||||
'room_number': room_number,
|
||||
'assigned_to': complaint.assigned_to,
|
||||
'assigned_staff_name': complaint.assignee.full_name if complaint.assignee else None,
|
||||
'resolution_notes': complaint.resolution,
|
||||
'resolved_at': complaint.resolved_at.isoformat() if complaint.resolved_at else None,
|
||||
'created_at': complaint.created_at.isoformat(),
|
||||
'updated_at': complaint.updated_at.isoformat()
|
||||
})
|
||||
@@ -192,21 +245,37 @@ async def get_complaint(
|
||||
):
|
||||
"""Get a specific complaint with details."""
|
||||
try:
|
||||
complaint = db.query(GuestComplaint).filter(GuestComplaint.id == complaint_id).first()
|
||||
from sqlalchemy.orm import joinedload
|
||||
from ...rooms.models.room import Room
|
||||
|
||||
complaint = db.query(GuestComplaint).options(
|
||||
joinedload(GuestComplaint.guest),
|
||||
joinedload(GuestComplaint.assignee),
|
||||
joinedload(GuestComplaint.room)
|
||||
).filter(GuestComplaint.id == complaint_id).first()
|
||||
if not complaint:
|
||||
raise HTTPException(status_code=404, detail='Complaint not found')
|
||||
|
||||
# Check access
|
||||
from ...shared.utils.role_helpers import is_admin, is_staff
|
||||
if not (is_admin(current_user, db) or is_staff(current_user, db)):
|
||||
is_admin_user = is_admin(current_user, db)
|
||||
is_staff_user = is_staff(current_user, db)
|
||||
if not (is_admin_user or is_staff_user):
|
||||
if complaint.guest_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Access denied')
|
||||
|
||||
# Get updates
|
||||
updates = db.query(ComplaintUpdate).filter(
|
||||
updates = db.query(ComplaintUpdate).options(
|
||||
joinedload(ComplaintUpdate.updater)
|
||||
).filter(
|
||||
ComplaintUpdate.complaint_id == complaint_id
|
||||
).order_by(ComplaintUpdate.created_at.asc()).all()
|
||||
|
||||
# Get room number
|
||||
room_number = None
|
||||
if complaint.room:
|
||||
room_number = complaint.room.room_number
|
||||
|
||||
complaint_data = {
|
||||
'id': complaint.id,
|
||||
'title': complaint.title,
|
||||
@@ -215,15 +284,18 @@ async def get_complaint(
|
||||
'priority': complaint.priority.value,
|
||||
'status': complaint.status.value,
|
||||
'guest_id': complaint.guest_id,
|
||||
'guest_name': complaint.guest.full_name if complaint.guest else None,
|
||||
'booking_id': complaint.booking_id,
|
||||
'room_id': complaint.room_id,
|
||||
'room_number': room_number,
|
||||
'assigned_to': complaint.assigned_to,
|
||||
'resolution': complaint.resolution,
|
||||
'assigned_staff_name': complaint.assignee.full_name if complaint.assignee else None,
|
||||
'resolution_notes': complaint.resolution,
|
||||
'resolved_at': complaint.resolved_at.isoformat() if complaint.resolved_at else None,
|
||||
'resolved_by': complaint.resolved_by,
|
||||
'guest_satisfaction_rating': complaint.guest_satisfaction_rating,
|
||||
'guest_feedback': complaint.guest_feedback,
|
||||
'internal_notes': complaint.internal_notes if (is_admin(current_user, db) or is_staff(current_user, db)) else None,
|
||||
'internal_notes': complaint.internal_notes if (is_admin_user or is_staff_user) else None,
|
||||
'attachments': complaint.attachments,
|
||||
'requires_follow_up': complaint.requires_follow_up,
|
||||
'follow_up_date': complaint.follow_up_date.isoformat() if complaint.follow_up_date else None,
|
||||
@@ -234,6 +306,7 @@ async def get_complaint(
|
||||
'update_type': u.update_type,
|
||||
'description': u.description,
|
||||
'updated_by': u.updated_by,
|
||||
'updated_by_name': u.updater.full_name if u.updater else None,
|
||||
'created_at': u.created_at.isoformat()
|
||||
} for u in updates]
|
||||
}
|
||||
@@ -253,17 +326,27 @@ async def get_complaint(
|
||||
async def update_complaint(
|
||||
complaint_id: int,
|
||||
update_data: UpdateComplaintRequest,
|
||||
request: Request,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a complaint (admin/staff only)."""
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
complaint = db.query(GuestComplaint).filter(GuestComplaint.id == complaint_id).first()
|
||||
if not complaint:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=404, detail='Complaint not found')
|
||||
|
||||
# Track changes
|
||||
# Track changes for audit
|
||||
old_values = {
|
||||
'status': complaint.status.value,
|
||||
'priority': complaint.priority.value,
|
||||
'assigned_to': complaint.assigned_to
|
||||
}
|
||||
changes = []
|
||||
|
||||
if update_data.status:
|
||||
@@ -308,6 +391,53 @@ async def update_complaint(
|
||||
db.add(update)
|
||||
|
||||
db.commit()
|
||||
|
||||
# SECURITY: Log complaint status change for audit trail
|
||||
if update_data.status and old_values['status'] != update_data.status:
|
||||
try:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='complaint_status_changed',
|
||||
resource_type='complaint',
|
||||
user_id=current_user.id,
|
||||
resource_id=complaint.id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details={
|
||||
'complaint_id': complaint.id,
|
||||
'old_status': old_values['status'],
|
||||
'new_status': update_data.status,
|
||||
'guest_id': complaint.guest_id,
|
||||
'resolved_by': current_user.id if update_data.status == 'resolved' else None
|
||||
},
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log complaint status change audit: {e}')
|
||||
|
||||
# SECURITY: Log complaint assignment change for audit trail
|
||||
if update_data.assigned_to is not None and old_values['assigned_to'] != update_data.assigned_to:
|
||||
try:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='complaint_assigned',
|
||||
resource_type='complaint',
|
||||
user_id=current_user.id,
|
||||
resource_id=complaint.id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details={
|
||||
'complaint_id': complaint.id,
|
||||
'old_assigned_to': old_values['assigned_to'],
|
||||
'new_assigned_to': update_data.assigned_to,
|
||||
'guest_id': complaint.guest_id
|
||||
},
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log complaint assignment audit: {e}')
|
||||
db.refresh(complaint)
|
||||
|
||||
return success_response(
|
||||
@@ -332,16 +462,23 @@ async def update_complaint(
|
||||
async def resolve_complaint(
|
||||
complaint_id: int,
|
||||
resolve_data: ResolveComplaintRequest,
|
||||
request: Request,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Resolve a complaint."""
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
complaint = db.query(GuestComplaint).filter(GuestComplaint.id == complaint_id).first()
|
||||
if not complaint:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=404, detail='Complaint not found')
|
||||
|
||||
old_status = complaint.status.value
|
||||
|
||||
complaint.status = ComplaintStatus.resolved
|
||||
complaint.resolution = resolve_data.resolution
|
||||
complaint.resolved_at = datetime.utcnow()
|
||||
@@ -366,6 +503,32 @@ async def resolve_complaint(
|
||||
db.add(update)
|
||||
|
||||
db.commit()
|
||||
|
||||
# SECURITY: Log complaint resolution for audit trail
|
||||
try:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='complaint_resolved',
|
||||
resource_type='complaint',
|
||||
user_id=current_user.id,
|
||||
resource_id=complaint.id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details={
|
||||
'complaint_id': complaint.id,
|
||||
'old_status': old_status,
|
||||
'new_status': 'resolved',
|
||||
'guest_id': complaint.guest_id,
|
||||
'resolved_by': current_user.id,
|
||||
'satisfaction_rating': resolve_data.guest_satisfaction_rating,
|
||||
'has_feedback': bool(resolve_data.guest_feedback)
|
||||
},
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log complaint resolution audit: {e}')
|
||||
|
||||
db.refresh(complaint)
|
||||
|
||||
return success_response(
|
||||
|
||||
@@ -378,6 +378,70 @@ async def remove_tag_from_guest(
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Get Communications
|
||||
@router.get('/communications')
|
||||
async def get_communications(
|
||||
user_id: Optional[int] = Query(None),
|
||||
communication_type: Optional[str] = Query(None),
|
||||
direction: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get guest communications with filtering"""
|
||||
try:
|
||||
from sqlalchemy import or_, func, desc
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
query = db.query(GuestCommunication).options(
|
||||
joinedload(GuestCommunication.user),
|
||||
joinedload(GuestCommunication.staff)
|
||||
)
|
||||
|
||||
if user_id:
|
||||
query = query.filter(GuestCommunication.user_id == user_id)
|
||||
|
||||
if communication_type:
|
||||
query = query.filter(GuestCommunication.communication_type == CommunicationType(communication_type))
|
||||
|
||||
if direction:
|
||||
query = query.filter(GuestCommunication.direction == CommunicationDirection(direction))
|
||||
|
||||
total = query.count()
|
||||
communications = query.order_by(desc(GuestCommunication.created_at)).offset((page - 1) * limit).limit(limit).all()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'communications': [
|
||||
{
|
||||
'id': comm.id,
|
||||
'user_id': comm.user_id,
|
||||
'guest_name': comm.user.full_name if comm.user else None,
|
||||
'communication_type': comm.communication_type.value,
|
||||
'direction': comm.direction.value,
|
||||
'subject': comm.subject,
|
||||
'content': comm.content,
|
||||
'booking_id': comm.booking_id,
|
||||
'is_automated': comm.is_automated,
|
||||
'created_at': comm.created_at.isoformat() if comm.created_at else None,
|
||||
'staff_name': comm.staff.full_name if comm.staff else None,
|
||||
}
|
||||
for comm in communications
|
||||
],
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total_pages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching communications: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f'Failed to fetch communications: {str(e)}')
|
||||
|
||||
# Create Communication Record
|
||||
@router.post('/{user_id}/communications')
|
||||
async def create_communication(
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from .housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
|
||||
from .inventory_item import InventoryItem, InventoryCategory, InventoryUnit
|
||||
from .inventory_transaction import InventoryTransaction, TransactionType
|
||||
from .inventory_reorder_request import InventoryReorderRequest, ReorderStatus
|
||||
from .inventory_task_consumption import InventoryTaskConsumption
|
||||
from .guest_request import GuestRequest, RequestType, RequestStatus, RequestPriority
|
||||
from .staff_shift import StaffShift, StaffTask, ShiftType, ShiftStatus, StaffTaskPriority, StaffTaskStatus
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
70
Backend/src/hotel_services/models/guest_request.py
Normal file
70
Backend/src/hotel_services/models/guest_request.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Enum, ForeignKey, DateTime, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ...shared.config.database import Base
|
||||
|
||||
class RequestType(str, enum.Enum):
|
||||
extra_towels = 'extra_towels'
|
||||
extra_pillows = 'extra_pillows'
|
||||
room_cleaning = 'room_cleaning'
|
||||
turndown_service = 'turndown_service'
|
||||
amenities = 'amenities'
|
||||
maintenance = 'maintenance'
|
||||
room_service = 'room_service'
|
||||
other = 'other'
|
||||
|
||||
class RequestStatus(str, enum.Enum):
|
||||
pending = 'pending'
|
||||
in_progress = 'in_progress'
|
||||
fulfilled = 'fulfilled'
|
||||
cancelled = 'cancelled'
|
||||
|
||||
class RequestPriority(str, enum.Enum):
|
||||
low = 'low'
|
||||
normal = 'normal'
|
||||
high = 'high'
|
||||
urgent = 'urgent'
|
||||
|
||||
class GuestRequest(Base):
|
||||
__tablename__ = 'guest_requests'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False, index=True)
|
||||
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False) # Guest who made the request
|
||||
|
||||
request_type = Column(Enum(RequestType), nullable=False)
|
||||
status = Column(Enum(RequestStatus), nullable=False, default=RequestStatus.pending)
|
||||
priority = Column(Enum(RequestPriority), nullable=False, default=RequestPriority.normal)
|
||||
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Assignment
|
||||
assigned_to = Column(Integer, ForeignKey('users.id'), nullable=True) # Housekeeping staff
|
||||
fulfilled_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
requested_at = Column(DateTime, nullable=False, default=datetime.utcnow, index=True)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
fulfilled_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Notes
|
||||
guest_notes = Column(Text, nullable=True)
|
||||
staff_notes = Column(Text, nullable=True)
|
||||
|
||||
# Response time tracking
|
||||
response_time_minutes = Column(Integer, nullable=True) # Time from request to start
|
||||
fulfillment_time_minutes = Column(Integer, nullable=True) # Time from start to fulfillment
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
booking = relationship('Booking')
|
||||
room = relationship('Room')
|
||||
guest = relationship('User', foreign_keys=[user_id])
|
||||
assigned_staff = relationship('User', foreign_keys=[assigned_to])
|
||||
fulfilled_staff = relationship('User', foreign_keys=[fulfilled_by])
|
||||
|
||||
@@ -41,6 +41,7 @@ class HousekeepingTask(Base):
|
||||
checklist_items = Column(JSON, nullable=True) # Array of {item: string, completed: bool, notes: string}
|
||||
notes = Column(Text, nullable=True)
|
||||
issues_found = Column(Text, nullable=True)
|
||||
photos = Column(JSON, nullable=True) # Array of photo URLs: ["/uploads/housekeeping/photo1.jpg", ...]
|
||||
|
||||
# Quality control
|
||||
inspected_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
|
||||
67
Backend/src/hotel_services/models/inventory_item.py
Normal file
67
Backend/src/hotel_services/models/inventory_item.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, Enum, ForeignKey, DateTime, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ...shared.config.database import Base
|
||||
|
||||
class InventoryCategory(str, enum.Enum):
|
||||
cleaning_supplies = 'cleaning_supplies'
|
||||
linens = 'linens'
|
||||
toiletries = 'toiletries'
|
||||
amenities = 'amenities'
|
||||
maintenance = 'maintenance'
|
||||
food_beverage = 'food_beverage'
|
||||
other = 'other'
|
||||
|
||||
class InventoryUnit(str, enum.Enum):
|
||||
piece = 'piece'
|
||||
box = 'box'
|
||||
bottle = 'bottle'
|
||||
roll = 'roll'
|
||||
pack = 'pack'
|
||||
liter = 'liter'
|
||||
kilogram = 'kilogram'
|
||||
meter = 'meter'
|
||||
other = 'other'
|
||||
|
||||
class InventoryItem(Base):
|
||||
__tablename__ = 'inventory_items'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(255), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
category = Column(Enum(InventoryCategory), nullable=False, default=InventoryCategory.other)
|
||||
unit = Column(Enum(InventoryUnit), nullable=False, default=InventoryUnit.piece)
|
||||
|
||||
# Stock tracking
|
||||
current_quantity = Column(Numeric(10, 2), nullable=False, default=0)
|
||||
minimum_quantity = Column(Numeric(10, 2), nullable=False, default=0) # Reorder threshold
|
||||
maximum_quantity = Column(Numeric(10, 2), nullable=True) # Max stock level
|
||||
reorder_quantity = Column(Numeric(10, 2), nullable=True) # Suggested reorder amount
|
||||
|
||||
# Pricing
|
||||
unit_cost = Column(Numeric(10, 2), nullable=True)
|
||||
supplier = Column(String(255), nullable=True)
|
||||
supplier_contact = Column(Text, nullable=True)
|
||||
|
||||
# Location
|
||||
storage_location = Column(String(255), nullable=True) # e.g., "Housekeeping Storage Room A"
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
is_tracked = Column(Boolean, nullable=False, default=True) # Whether to track consumption
|
||||
|
||||
# Metadata
|
||||
barcode = Column(String(100), nullable=True, unique=True, index=True)
|
||||
sku = Column(String(100), nullable=True, unique=True, index=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
transactions = relationship('InventoryTransaction', back_populates='item', cascade='all, delete-orphan')
|
||||
reorder_requests = relationship('InventoryReorderRequest', back_populates='item', cascade='all, delete-orphan')
|
||||
task_consumptions = relationship('InventoryTaskConsumption', back_populates='item', cascade='all, delete-orphan')
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Enum, ForeignKey, DateTime, Numeric, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ...shared.config.database import Base
|
||||
|
||||
class ReorderStatus(str, enum.Enum):
|
||||
pending = 'pending'
|
||||
approved = 'approved'
|
||||
ordered = 'ordered'
|
||||
received = 'received'
|
||||
cancelled = 'cancelled'
|
||||
|
||||
class InventoryReorderRequest(Base):
|
||||
__tablename__ = 'inventory_reorder_requests'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
item_id = Column(Integer, ForeignKey('inventory_items.id'), nullable=False, index=True)
|
||||
|
||||
# Request details
|
||||
requested_quantity = Column(Numeric(10, 2), nullable=False)
|
||||
current_quantity = Column(Numeric(10, 2), nullable=False) # Stock at time of request
|
||||
minimum_quantity = Column(Numeric(10, 2), nullable=False) # Threshold
|
||||
|
||||
# Status
|
||||
status = Column(Enum(ReorderStatus), nullable=False, default=ReorderStatus.pending)
|
||||
priority = Column(String(20), nullable=False, default='normal') # low, normal, high, urgent
|
||||
|
||||
# Request info
|
||||
requested_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
requested_at = Column(DateTime, nullable=False, default=datetime.utcnow, index=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
# Approval
|
||||
approved_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
approved_at = Column(DateTime, nullable=True)
|
||||
approval_notes = Column(Text, nullable=True)
|
||||
|
||||
# Order tracking
|
||||
order_number = Column(String(100), nullable=True, index=True)
|
||||
expected_delivery_date = Column(DateTime, nullable=True)
|
||||
received_quantity = Column(Numeric(10, 2), nullable=True)
|
||||
received_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
item = relationship('InventoryItem', back_populates='reorder_requests')
|
||||
requester = relationship('User', foreign_keys=[requested_by])
|
||||
approver = relationship('User', foreign_keys=[approved_by])
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Column, Integer, Numeric, ForeignKey, DateTime, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ...shared.config.database import Base
|
||||
|
||||
class InventoryTaskConsumption(Base):
|
||||
__tablename__ = 'inventory_task_consumptions'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
task_id = Column(Integer, ForeignKey('housekeeping_tasks.id'), nullable=False, index=True)
|
||||
item_id = Column(Integer, ForeignKey('inventory_items.id'), nullable=False, index=True)
|
||||
|
||||
quantity = Column(Numeric(10, 2), nullable=False)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
recorded_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow, index=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
task = relationship('HousekeepingTask')
|
||||
item = relationship('InventoryItem', back_populates='task_consumptions')
|
||||
|
||||
43
Backend/src/hotel_services/models/inventory_transaction.py
Normal file
43
Backend/src/hotel_services/models/inventory_transaction.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Text, Enum, ForeignKey, DateTime, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ...shared.config.database import Base
|
||||
|
||||
class TransactionType(str, enum.Enum):
|
||||
consumption = 'consumption' # Used in tasks
|
||||
adjustment = 'adjustment' # Manual adjustment (correction)
|
||||
received = 'received' # Stock received from supplier
|
||||
transfer = 'transfer' # Transfer between locations
|
||||
damaged = 'damaged' # Damaged/lost items
|
||||
returned = 'returned' # Returned to supplier
|
||||
|
||||
class InventoryTransaction(Base):
|
||||
__tablename__ = 'inventory_transactions'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
item_id = Column(Integer, ForeignKey('inventory_items.id'), nullable=False, index=True)
|
||||
|
||||
transaction_type = Column(Enum(TransactionType), nullable=False)
|
||||
quantity = Column(Numeric(10, 2), nullable=False) # Positive for received, negative for consumption
|
||||
quantity_before = Column(Numeric(10, 2), nullable=False) # Stock before transaction
|
||||
quantity_after = Column(Numeric(10, 2), nullable=False) # Stock after transaction
|
||||
|
||||
# Reference
|
||||
reference_type = Column(String(50), nullable=True) # e.g., 'housekeeping_task', 'purchase_order'
|
||||
reference_id = Column(Integer, nullable=True, index=True) # ID of related record
|
||||
|
||||
# Details
|
||||
notes = Column(Text, nullable=True)
|
||||
cost = Column(Numeric(10, 2), nullable=True) # Cost for this transaction
|
||||
|
||||
# User tracking
|
||||
performed_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Timestamp
|
||||
transaction_date = Column(DateTime, nullable=False, default=datetime.utcnow, index=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
item = relationship('InventoryItem', back_populates='transactions')
|
||||
|
||||
122
Backend/src/hotel_services/models/staff_shift.py
Normal file
122
Backend/src/hotel_services/models/staff_shift.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Enum, ForeignKey, DateTime, Boolean, Time, Numeric
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, date, time
|
||||
import enum
|
||||
from ...shared.config.database import Base
|
||||
|
||||
class ShiftType(str, enum.Enum):
|
||||
morning = 'morning' # 6 AM - 2 PM
|
||||
afternoon = 'afternoon' # 2 PM - 10 PM
|
||||
night = 'night' # 10 PM - 6 AM
|
||||
full_day = 'full_day' # 8 AM - 8 PM
|
||||
custom = 'custom' # Custom hours
|
||||
|
||||
class ShiftStatus(str, enum.Enum):
|
||||
scheduled = 'scheduled'
|
||||
in_progress = 'in_progress'
|
||||
completed = 'completed'
|
||||
cancelled = 'cancelled'
|
||||
no_show = 'no_show'
|
||||
|
||||
class StaffShift(Base):
|
||||
__tablename__ = 'staff_shifts'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
staff_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
|
||||
# Shift timing
|
||||
shift_date = Column(DateTime, nullable=False, index=True) # Date of the shift
|
||||
shift_type = Column(Enum(ShiftType), nullable=False)
|
||||
start_time = Column(Time, nullable=False)
|
||||
end_time = Column(Time, nullable=False)
|
||||
|
||||
# Status
|
||||
status = Column(Enum(ShiftStatus), nullable=False, default=ShiftStatus.scheduled)
|
||||
|
||||
# Actual times (for tracking)
|
||||
actual_start_time = Column(DateTime, nullable=True)
|
||||
actual_end_time = Column(DateTime, nullable=True)
|
||||
|
||||
# Break times
|
||||
break_duration_minutes = Column(Integer, nullable=True, default=30) # Total break time
|
||||
|
||||
# Assignment
|
||||
assigned_by = Column(Integer, ForeignKey('users.id'), nullable=True) # Admin/staff who assigned
|
||||
department = Column(String(100), nullable=True) # reception, housekeeping, maintenance, etc.
|
||||
|
||||
# Notes
|
||||
notes = Column(Text, nullable=True)
|
||||
handover_notes = Column(Text, nullable=True) # Notes for next shift
|
||||
|
||||
# Performance tracking
|
||||
tasks_completed = Column(Integer, nullable=True, default=0)
|
||||
tasks_assigned = Column(Integer, nullable=True, default=0)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
staff = relationship('User', foreign_keys=[staff_id])
|
||||
assigner = relationship('User', foreign_keys=[assigned_by])
|
||||
tasks = relationship('StaffTask', back_populates='shift', cascade='all, delete-orphan')
|
||||
|
||||
class StaffTaskPriority(str, enum.Enum):
|
||||
low = 'low'
|
||||
normal = 'normal'
|
||||
high = 'high'
|
||||
urgent = 'urgent'
|
||||
|
||||
class StaffTaskStatus(str, enum.Enum):
|
||||
pending = 'pending'
|
||||
assigned = 'assigned'
|
||||
in_progress = 'in_progress'
|
||||
completed = 'completed'
|
||||
cancelled = 'cancelled'
|
||||
on_hold = 'on_hold'
|
||||
|
||||
class StaffTask(Base):
|
||||
__tablename__ = 'staff_tasks'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
shift_id = Column(Integer, ForeignKey('staff_shifts.id'), nullable=True, index=True)
|
||||
staff_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
|
||||
# Task details
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
task_type = Column(String(100), nullable=False) # housekeeping, maintenance, guest_service, etc.
|
||||
priority = Column(Enum(StaffTaskPriority), nullable=False, default=StaffTaskPriority.normal)
|
||||
status = Column(Enum(StaffTaskStatus), nullable=False, default=StaffTaskStatus.pending)
|
||||
|
||||
# Timing
|
||||
scheduled_start = Column(DateTime, nullable=True)
|
||||
scheduled_end = Column(DateTime, nullable=True)
|
||||
actual_start = Column(DateTime, nullable=True)
|
||||
actual_end = Column(DateTime, nullable=True)
|
||||
estimated_duration_minutes = Column(Integer, nullable=True)
|
||||
actual_duration_minutes = Column(Integer, nullable=True)
|
||||
|
||||
# Assignment
|
||||
assigned_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
due_date = Column(DateTime, nullable=True)
|
||||
|
||||
# Related entities
|
||||
related_booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True)
|
||||
related_room_id = Column(Integer, ForeignKey('rooms.id'), nullable=True)
|
||||
related_guest_request_id = Column(Integer, ForeignKey('guest_requests.id'), nullable=True)
|
||||
related_maintenance_id = Column(Integer, ForeignKey('room_maintenance.id'), nullable=True)
|
||||
|
||||
# Notes and completion
|
||||
notes = Column(Text, nullable=True)
|
||||
completion_notes = Column(Text, nullable=True)
|
||||
is_recurring = Column(Boolean, nullable=False, default=False)
|
||||
recurrence_pattern = Column(String(100), nullable=True) # daily, weekly, monthly
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
staff = relationship('User', foreign_keys=[staff_id])
|
||||
assigner = relationship('User', foreign_keys=[assigned_by])
|
||||
shift = relationship('StaffShift', back_populates='tasks')
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import inventory_routes, guest_request_routes, staff_shift_routes
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
401
Backend/src/hotel_services/routes/guest_request_routes.py
Normal file
401
Backend/src/hotel_services/routes/guest_request_routes.py
Normal file
@@ -0,0 +1,401 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_, or_, desc
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from ...shared.config.database import get_db
|
||||
from ...shared.config.logging_config import get_logger
|
||||
from ...security.middleware.auth import get_current_user, authorize_roles
|
||||
from ...auth.models.user import User
|
||||
from ...auth.models.role import Role
|
||||
from ..models.guest_request import GuestRequest, RequestType, RequestStatus, RequestPriority
|
||||
from ...bookings.models.booking import Booking, BookingStatus
|
||||
from ...rooms.models.room import Room
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix='/guest-requests', tags=['guest-requests'])
|
||||
|
||||
|
||||
# ==================== Pydantic Schemas ====================
|
||||
|
||||
class GuestRequestCreate(BaseModel):
|
||||
booking_id: int
|
||||
room_id: int
|
||||
request_type: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
priority: str = 'normal'
|
||||
guest_notes: Optional[str] = None
|
||||
|
||||
class GuestRequestUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
assigned_to: Optional[int] = None
|
||||
staff_notes: Optional[str] = None
|
||||
|
||||
|
||||
# ==================== Guest Requests ====================
|
||||
|
||||
@router.get('/')
|
||||
async def get_guest_requests(
|
||||
status: Optional[str] = Query(None),
|
||||
request_type: Optional[str] = Query(None),
|
||||
room_id: Optional[int] = Query(None),
|
||||
assigned_to: Optional[int] = Query(None),
|
||||
priority: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get guest requests with filtering"""
|
||||
try:
|
||||
query = db.query(GuestRequest).options(
|
||||
joinedload(GuestRequest.booking),
|
||||
joinedload(GuestRequest.room),
|
||||
joinedload(GuestRequest.guest)
|
||||
)
|
||||
|
||||
# Check if user is housekeeping - they can only see requests assigned to them or unassigned
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_housekeeping = role and role.name == 'housekeeping'
|
||||
|
||||
if is_housekeeping:
|
||||
query = query.filter(
|
||||
or_(
|
||||
GuestRequest.assigned_to == current_user.id,
|
||||
GuestRequest.assigned_to.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
if status:
|
||||
query = query.filter(GuestRequest.status == status)
|
||||
|
||||
if request_type:
|
||||
query = query.filter(GuestRequest.request_type == request_type)
|
||||
|
||||
if room_id:
|
||||
query = query.filter(GuestRequest.room_id == room_id)
|
||||
|
||||
if assigned_to:
|
||||
query = query.filter(GuestRequest.assigned_to == assigned_to)
|
||||
|
||||
if priority:
|
||||
query = query.filter(GuestRequest.priority == priority)
|
||||
|
||||
# Only show requests for checked-in bookings (guests must be in the room)
|
||||
query = query.join(Booking).filter(
|
||||
Booking.status == BookingStatus.checked_in
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
requests = query.order_by(
|
||||
desc(GuestRequest.priority == RequestPriority.urgent),
|
||||
desc(GuestRequest.priority == RequestPriority.high),
|
||||
desc(GuestRequest.requested_at)
|
||||
).offset((page - 1) * limit).limit(limit).all()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'requests': [
|
||||
{
|
||||
'id': req.id,
|
||||
'booking_id': req.booking_id,
|
||||
'room_id': req.room_id,
|
||||
'room_number': req.room.room_number if req.room else None,
|
||||
'user_id': req.user_id,
|
||||
'guest_name': req.guest.full_name if req.guest else None,
|
||||
'request_type': req.request_type.value,
|
||||
'status': req.status.value,
|
||||
'priority': req.priority.value,
|
||||
'title': req.title,
|
||||
'description': req.description,
|
||||
'guest_notes': req.guest_notes,
|
||||
'staff_notes': req.staff_notes,
|
||||
'assigned_to': req.assigned_to,
|
||||
'assigned_staff_name': req.assigned_staff.full_name if req.assigned_staff else None,
|
||||
'fulfilled_by': req.fulfilled_by,
|
||||
'requested_at': req.requested_at.isoformat() if req.requested_at else None,
|
||||
'started_at': req.started_at.isoformat() if req.started_at else None,
|
||||
'fulfilled_at': req.fulfilled_at.isoformat() if req.fulfilled_at else None,
|
||||
'response_time_minutes': req.response_time_minutes,
|
||||
'fulfillment_time_minutes': req.fulfillment_time_minutes,
|
||||
}
|
||||
for req in requests
|
||||
],
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total_pages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching guest requests: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='Failed to fetch guest requests')
|
||||
|
||||
@router.post('/')
|
||||
async def create_guest_request(
|
||||
request_data: GuestRequestCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new guest request"""
|
||||
try:
|
||||
# Verify booking belongs to user
|
||||
booking = db.query(Booking).filter(Booking.id == request_data.booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
|
||||
if booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='You can only create requests for your own bookings')
|
||||
|
||||
# Guests can only create requests when they are checked in (in the room)
|
||||
if booking.status != BookingStatus.checked_in:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='You can only create requests when you are checked in. Please check in first or contact reception.'
|
||||
)
|
||||
|
||||
# Verify room matches booking
|
||||
if booking.room_id != request_data.room_id:
|
||||
raise HTTPException(status_code=400, detail='Room ID does not match booking')
|
||||
|
||||
guest_request = GuestRequest(
|
||||
booking_id=request_data.booking_id,
|
||||
room_id=request_data.room_id,
|
||||
user_id=current_user.id,
|
||||
request_type=RequestType(request_data.request_type),
|
||||
priority=RequestPriority(request_data.priority),
|
||||
title=request_data.title,
|
||||
description=request_data.description,
|
||||
guest_notes=request_data.guest_notes,
|
||||
)
|
||||
|
||||
db.add(guest_request)
|
||||
db.commit()
|
||||
db.refresh(guest_request)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Guest request created successfully',
|
||||
'data': {'id': guest_request.id}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating guest request: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail='Failed to create guest request')
|
||||
|
||||
@router.put('/{request_id}')
|
||||
async def update_guest_request(
|
||||
request_id: int,
|
||||
request_data: GuestRequestUpdate,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a guest request (assign, update status, add notes)"""
|
||||
try:
|
||||
guest_request = db.query(GuestRequest).filter(GuestRequest.id == request_id).first()
|
||||
if not guest_request:
|
||||
raise HTTPException(status_code=404, detail='Guest request not found')
|
||||
|
||||
# Check permissions
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_housekeeping = role and role.name == 'housekeeping'
|
||||
|
||||
if is_housekeeping:
|
||||
# Housekeeping can only update requests assigned to them or unassigned
|
||||
if guest_request.assigned_to and guest_request.assigned_to != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='You can only update requests assigned to you')
|
||||
|
||||
update_data = request_data.dict(exclude_unset=True)
|
||||
|
||||
# Handle status changes
|
||||
if 'status' in update_data:
|
||||
new_status = RequestStatus(update_data['status'])
|
||||
old_status = guest_request.status
|
||||
|
||||
# Track timestamps
|
||||
if new_status == RequestStatus.in_progress and old_status == RequestStatus.pending:
|
||||
guest_request.started_at = datetime.utcnow()
|
||||
if guest_request.requested_at:
|
||||
delta = datetime.utcnow() - guest_request.requested_at
|
||||
guest_request.response_time_minutes = int(delta.total_seconds() / 60)
|
||||
# Auto-assign if not assigned
|
||||
if not guest_request.assigned_to:
|
||||
guest_request.assigned_to = current_user.id
|
||||
|
||||
elif new_status == RequestStatus.fulfilled and old_status != RequestStatus.fulfilled:
|
||||
guest_request.fulfilled_at = datetime.utcnow()
|
||||
guest_request.fulfilled_by = current_user.id
|
||||
if guest_request.started_at:
|
||||
delta = datetime.utcnow() - guest_request.started_at
|
||||
guest_request.fulfillment_time_minutes = int(delta.total_seconds() / 60)
|
||||
|
||||
update_data['status'] = new_status
|
||||
|
||||
if 'assigned_to' in update_data:
|
||||
update_data['assigned_to'] = update_data['assigned_to'] if update_data['assigned_to'] else None
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(guest_request, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(guest_request)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Guest request updated successfully'
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating guest request: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail='Failed to update guest request')
|
||||
|
||||
@router.get('/{request_id}')
|
||||
async def get_guest_request(
|
||||
request_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a single guest request"""
|
||||
try:
|
||||
request = db.query(GuestRequest).options(
|
||||
joinedload(GuestRequest.booking),
|
||||
joinedload(GuestRequest.room),
|
||||
joinedload(GuestRequest.guest),
|
||||
joinedload(GuestRequest.assigned_staff),
|
||||
joinedload(GuestRequest.fulfilled_staff)
|
||||
).filter(GuestRequest.id == request_id).first()
|
||||
|
||||
if not request:
|
||||
raise HTTPException(status_code=404, detail='Guest request not found')
|
||||
|
||||
# Check permissions
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_housekeeping = role and role.name == 'housekeeping'
|
||||
|
||||
if is_housekeeping and request.assigned_to and request.assigned_to != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='You can only view requests assigned to you')
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'id': request.id,
|
||||
'booking_id': request.booking_id,
|
||||
'room_id': request.room_id,
|
||||
'room_number': request.room.room_number if request.room else None,
|
||||
'user_id': request.user_id,
|
||||
'guest_name': request.guest.full_name if request.guest else None,
|
||||
'guest_email': request.guest.email if request.guest else None,
|
||||
'request_type': request.request_type.value,
|
||||
'status': request.status.value,
|
||||
'priority': request.priority.value,
|
||||
'title': request.title,
|
||||
'description': request.description,
|
||||
'guest_notes': request.guest_notes,
|
||||
'staff_notes': request.staff_notes,
|
||||
'assigned_to': request.assigned_to,
|
||||
'assigned_staff_name': request.assigned_staff.full_name if request.assigned_staff else None,
|
||||
'fulfilled_by': request.fulfilled_by,
|
||||
'fulfilled_staff_name': request.fulfilled_staff.full_name if request.fulfilled_staff else None,
|
||||
'requested_at': request.requested_at.isoformat() if request.requested_at else None,
|
||||
'started_at': request.started_at.isoformat() if request.started_at else None,
|
||||
'fulfilled_at': request.fulfilled_at.isoformat() if request.fulfilled_at else None,
|
||||
'response_time_minutes': request.response_time_minutes,
|
||||
'fulfillment_time_minutes': request.fulfillment_time_minutes,
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching guest request: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='Failed to fetch guest request')
|
||||
|
||||
@router.post('/{request_id}/assign')
|
||||
async def assign_request(
|
||||
request_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Assign a request to the current user (housekeeping)"""
|
||||
try:
|
||||
request = db.query(GuestRequest).filter(GuestRequest.id == request_id).first()
|
||||
if not request:
|
||||
raise HTTPException(status_code=404, detail='Guest request not found')
|
||||
|
||||
if request.status == RequestStatus.fulfilled:
|
||||
raise HTTPException(status_code=400, detail='Cannot assign a fulfilled request')
|
||||
|
||||
request.assigned_to = current_user.id
|
||||
if request.status == RequestStatus.pending:
|
||||
request.status = RequestStatus.in_progress
|
||||
request.started_at = datetime.utcnow()
|
||||
if request.requested_at:
|
||||
delta = datetime.utcnow() - request.requested_at
|
||||
request.response_time_minutes = int(delta.total_seconds() / 60)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Request assigned successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error assigning request: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail='Failed to assign request')
|
||||
|
||||
@router.post('/{request_id}/fulfill')
|
||||
async def fulfill_request(
|
||||
request_id: int,
|
||||
staff_notes: Optional[str] = None,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Mark a request as fulfilled"""
|
||||
try:
|
||||
request = db.query(GuestRequest).filter(GuestRequest.id == request_id).first()
|
||||
if not request:
|
||||
raise HTTPException(status_code=404, detail='Guest request not found')
|
||||
|
||||
# Check permissions
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_housekeeping = role and role.name == 'housekeeping'
|
||||
|
||||
if is_housekeeping and request.assigned_to != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='You can only fulfill requests assigned to you')
|
||||
|
||||
if request.status == RequestStatus.fulfilled:
|
||||
raise HTTPException(status_code=400, detail='Request is already fulfilled')
|
||||
|
||||
request.status = RequestStatus.fulfilled
|
||||
request.fulfilled_by = current_user.id
|
||||
request.fulfilled_at = datetime.utcnow()
|
||||
|
||||
if staff_notes:
|
||||
request.staff_notes = (request.staff_notes or '') + f'\n{staff_notes}' if request.staff_notes else staff_notes
|
||||
|
||||
if request.started_at:
|
||||
delta = datetime.utcnow() - request.started_at
|
||||
request.fulfillment_time_minutes = int(delta.total_seconds() / 60)
|
||||
elif request.requested_at:
|
||||
# If never started, calculate from request time
|
||||
delta = datetime.utcnow() - request.requested_at
|
||||
request.fulfillment_time_minutes = int(delta.total_seconds() / 60)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Request marked as fulfilled'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error fulfilling request: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail='Failed to fulfill request')
|
||||
|
||||
565
Backend/src/hotel_services/routes/inventory_routes.py
Normal file
565
Backend/src/hotel_services/routes/inventory_routes.py
Normal file
@@ -0,0 +1,565 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_, func, desc
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from ...shared.config.database import get_db
|
||||
from ...shared.config.logging_config import get_logger
|
||||
from ...security.middleware.auth import get_current_user, authorize_roles
|
||||
from ...auth.models.user import User
|
||||
from ...auth.models.role import Role
|
||||
from ..models.inventory_item import InventoryItem, InventoryCategory, InventoryUnit
|
||||
from ..models.inventory_transaction import InventoryTransaction, TransactionType
|
||||
from ..models.inventory_reorder_request import InventoryReorderRequest, ReorderStatus
|
||||
from ..models.inventory_task_consumption import InventoryTaskConsumption
|
||||
from ...hotel_services.models.housekeeping_task import HousekeepingTask
|
||||
from pydantic import BaseModel
|
||||
from decimal import Decimal
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix='/inventory', tags=['inventory-management'])
|
||||
|
||||
|
||||
# ==================== Pydantic Schemas ====================
|
||||
|
||||
class InventoryItemCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
category: str
|
||||
unit: str
|
||||
minimum_quantity: Decimal
|
||||
maximum_quantity: Optional[Decimal] = None
|
||||
reorder_quantity: Optional[Decimal] = None
|
||||
unit_cost: Optional[Decimal] = None
|
||||
supplier: Optional[str] = None
|
||||
supplier_contact: Optional[str] = None
|
||||
storage_location: Optional[str] = None
|
||||
barcode: Optional[str] = None
|
||||
sku: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
class InventoryItemUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
minimum_quantity: Optional[Decimal] = None
|
||||
maximum_quantity: Optional[Decimal] = None
|
||||
reorder_quantity: Optional[Decimal] = None
|
||||
unit_cost: Optional[Decimal] = None
|
||||
supplier: Optional[str] = None
|
||||
supplier_contact: Optional[str] = None
|
||||
storage_location: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_tracked: Optional[bool] = None
|
||||
barcode: Optional[str] = None
|
||||
sku: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
class InventoryTransactionCreate(BaseModel):
|
||||
item_id: int
|
||||
transaction_type: str
|
||||
quantity: Decimal
|
||||
notes: Optional[str] = None
|
||||
cost: Optional[Decimal] = None
|
||||
reference_type: Optional[str] = None
|
||||
reference_id: Optional[int] = None
|
||||
|
||||
class ReorderRequestCreate(BaseModel):
|
||||
item_id: int
|
||||
requested_quantity: Decimal
|
||||
priority: str = 'normal'
|
||||
notes: Optional[str] = None
|
||||
|
||||
class TaskConsumptionCreate(BaseModel):
|
||||
task_id: int
|
||||
item_id: int
|
||||
quantity: Decimal
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
# ==================== Inventory Items ====================
|
||||
|
||||
@router.get('/items')
|
||||
async def get_inventory_items(
|
||||
category: Optional[str] = Query(None),
|
||||
low_stock: Optional[bool] = Query(None),
|
||||
is_active: Optional[bool] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get inventory items with filtering"""
|
||||
try:
|
||||
query = db.query(InventoryItem)
|
||||
|
||||
if category:
|
||||
query = query.filter(InventoryItem.category == category)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(InventoryItem.is_active == is_active)
|
||||
|
||||
if low_stock:
|
||||
query = query.filter(InventoryItem.current_quantity <= InventoryItem.minimum_quantity)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(desc(InventoryItem.created_at)).offset((page - 1) * limit).limit(limit).all()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'items': [
|
||||
{
|
||||
'id': item.id,
|
||||
'name': item.name,
|
||||
'description': item.description,
|
||||
'category': item.category.value,
|
||||
'unit': item.unit.value,
|
||||
'current_quantity': float(item.current_quantity),
|
||||
'minimum_quantity': float(item.minimum_quantity),
|
||||
'maximum_quantity': float(item.maximum_quantity) if item.maximum_quantity else None,
|
||||
'reorder_quantity': float(item.reorder_quantity) if item.reorder_quantity else None,
|
||||
'unit_cost': float(item.unit_cost) if item.unit_cost else None,
|
||||
'supplier': item.supplier,
|
||||
'storage_location': item.storage_location,
|
||||
'is_active': item.is_active,
|
||||
'is_tracked': item.is_tracked,
|
||||
'barcode': item.barcode,
|
||||
'sku': item.sku,
|
||||
'is_low_stock': item.current_quantity <= item.minimum_quantity,
|
||||
'created_at': item.created_at.isoformat() if item.created_at else None,
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total_pages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching inventory items: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='Failed to fetch inventory items')
|
||||
|
||||
@router.post('/items')
|
||||
async def create_inventory_item(
|
||||
item_data: InventoryItemCreate,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new inventory item"""
|
||||
try:
|
||||
item = InventoryItem(
|
||||
name=item_data.name,
|
||||
description=item_data.description,
|
||||
category=InventoryCategory(item_data.category),
|
||||
unit=InventoryUnit(item_data.unit),
|
||||
minimum_quantity=item_data.minimum_quantity,
|
||||
maximum_quantity=item_data.maximum_quantity,
|
||||
reorder_quantity=item_data.reorder_quantity,
|
||||
unit_cost=item_data.unit_cost,
|
||||
supplier=item_data.supplier,
|
||||
supplier_contact=item_data.supplier_contact,
|
||||
storage_location=item_data.storage_location,
|
||||
barcode=item_data.barcode,
|
||||
sku=item_data.sku,
|
||||
notes=item_data.notes,
|
||||
current_quantity=Decimal('0'),
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Inventory item created successfully',
|
||||
'data': {'id': item.id}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating inventory item: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail='Failed to create inventory item')
|
||||
|
||||
@router.put('/items/{item_id}')
|
||||
async def update_inventory_item(
|
||||
item_id: int,
|
||||
item_data: InventoryItemUpdate,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update an inventory item"""
|
||||
try:
|
||||
item = db.query(InventoryItem).filter(InventoryItem.id == item_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail='Inventory item not found')
|
||||
|
||||
update_data = item_data.dict(exclude_unset=True)
|
||||
if 'category' in update_data:
|
||||
update_data['category'] = InventoryCategory(update_data['category'])
|
||||
if 'unit' in update_data:
|
||||
update_data['unit'] = InventoryUnit(update_data['unit'])
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(item, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Inventory item updated successfully'
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating inventory item: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail='Failed to update inventory item')
|
||||
|
||||
@router.get('/items/{item_id}')
|
||||
async def get_inventory_item(
|
||||
item_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a single inventory item"""
|
||||
try:
|
||||
item = db.query(InventoryItem).filter(InventoryItem.id == item_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail='Inventory item not found')
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'id': item.id,
|
||||
'name': item.name,
|
||||
'description': item.description,
|
||||
'category': item.category.value,
|
||||
'unit': item.unit.value,
|
||||
'current_quantity': float(item.current_quantity),
|
||||
'minimum_quantity': float(item.minimum_quantity),
|
||||
'maximum_quantity': float(item.maximum_quantity) if item.maximum_quantity else None,
|
||||
'reorder_quantity': float(item.reorder_quantity) if item.reorder_quantity else None,
|
||||
'unit_cost': float(item.unit_cost) if item.unit_cost else None,
|
||||
'supplier': item.supplier,
|
||||
'supplier_contact': item.supplier_contact,
|
||||
'storage_location': item.storage_location,
|
||||
'is_active': item.is_active,
|
||||
'is_tracked': item.is_tracked,
|
||||
'barcode': item.barcode,
|
||||
'sku': item.sku,
|
||||
'notes': item.notes,
|
||||
'is_low_stock': item.current_quantity <= item.minimum_quantity,
|
||||
'created_at': item.created_at.isoformat() if item.created_at else None,
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching inventory item: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='Failed to fetch inventory item')
|
||||
|
||||
|
||||
# ==================== Inventory Transactions ====================
|
||||
|
||||
@router.post('/transactions')
|
||||
async def create_transaction(
|
||||
transaction_data: InventoryTransactionCreate,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create an inventory transaction (consumption, adjustment, received, etc.)"""
|
||||
try:
|
||||
item = db.query(InventoryItem).filter(InventoryItem.id == transaction_data.item_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail='Inventory item not found')
|
||||
|
||||
quantity_before = item.current_quantity
|
||||
transaction_type = TransactionType(transaction_data.transaction_type)
|
||||
|
||||
# Calculate new quantity based on transaction type
|
||||
if transaction_type in [TransactionType.consumption, TransactionType.damaged, TransactionType.transfer]:
|
||||
quantity_change = -abs(transaction_data.quantity)
|
||||
elif transaction_type in [TransactionType.received, TransactionType.returned]:
|
||||
quantity_change = abs(transaction_data.quantity)
|
||||
else: # adjustment
|
||||
quantity_change = transaction_data.quantity
|
||||
|
||||
quantity_after = quantity_before + quantity_change
|
||||
|
||||
# Create transaction
|
||||
transaction = InventoryTransaction(
|
||||
item_id=transaction_data.item_id,
|
||||
transaction_type=transaction_type,
|
||||
quantity=quantity_change,
|
||||
quantity_before=quantity_before,
|
||||
quantity_after=quantity_after,
|
||||
reference_type=transaction_data.reference_type,
|
||||
reference_id=transaction_data.reference_id,
|
||||
notes=transaction_data.notes,
|
||||
cost=transaction_data.cost,
|
||||
performed_by=current_user.id,
|
||||
transaction_date=datetime.utcnow(),
|
||||
)
|
||||
|
||||
# Update item quantity
|
||||
item.current_quantity = quantity_after
|
||||
|
||||
db.add(transaction)
|
||||
db.commit()
|
||||
db.refresh(transaction)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Transaction created successfully',
|
||||
'data': {'id': transaction.id, 'new_quantity': float(quantity_after)}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid transaction type: {str(e)}')
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating transaction: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail='Failed to create transaction')
|
||||
|
||||
@router.get('/items/{item_id}/transactions')
|
||||
async def get_item_transactions(
|
||||
item_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get transactions for a specific item"""
|
||||
try:
|
||||
query = db.query(InventoryTransaction).filter(InventoryTransaction.item_id == item_id)
|
||||
total = query.count()
|
||||
transactions = query.order_by(desc(InventoryTransaction.transaction_date)).offset((page - 1) * limit).limit(limit).all()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'transactions': [
|
||||
{
|
||||
'id': t.id,
|
||||
'transaction_type': t.transaction_type.value,
|
||||
'quantity': float(t.quantity),
|
||||
'quantity_before': float(t.quantity_before),
|
||||
'quantity_after': float(t.quantity_after),
|
||||
'notes': t.notes,
|
||||
'cost': float(t.cost) if t.cost else None,
|
||||
'transaction_date': t.transaction_date.isoformat() if t.transaction_date else None,
|
||||
}
|
||||
for t in transactions
|
||||
],
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total_pages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching transactions: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='Failed to fetch transactions')
|
||||
|
||||
|
||||
# ==================== Reorder Requests ====================
|
||||
|
||||
@router.post('/reorder-requests')
|
||||
async def create_reorder_request(
|
||||
request_data: ReorderRequestCreate,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a reorder request for low stock items"""
|
||||
try:
|
||||
item = db.query(InventoryItem).filter(InventoryItem.id == request_data.item_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail='Inventory item not found')
|
||||
|
||||
reorder_request = InventoryReorderRequest(
|
||||
item_id=request_data.item_id,
|
||||
requested_quantity=request_data.requested_quantity,
|
||||
current_quantity=item.current_quantity,
|
||||
minimum_quantity=item.minimum_quantity,
|
||||
priority=request_data.priority,
|
||||
notes=request_data.notes,
|
||||
requested_by=current_user.id,
|
||||
)
|
||||
|
||||
db.add(reorder_request)
|
||||
db.commit()
|
||||
db.refresh(reorder_request)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Reorder request created successfully',
|
||||
'data': {'id': reorder_request.id}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating reorder request: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail='Failed to create reorder request')
|
||||
|
||||
@router.get('/reorder-requests')
|
||||
async def get_reorder_requests(
|
||||
status: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get reorder requests"""
|
||||
try:
|
||||
query = db.query(InventoryReorderRequest).join(InventoryItem)
|
||||
|
||||
# Check if user is housekeeping - they can only see their own requests
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_housekeeping = role and role.name == 'housekeeping'
|
||||
|
||||
if is_housekeeping:
|
||||
query = query.filter(InventoryReorderRequest.requested_by == current_user.id)
|
||||
|
||||
if status:
|
||||
query = query.filter(InventoryReorderRequest.status == status)
|
||||
|
||||
total = query.count()
|
||||
requests = query.order_by(desc(InventoryReorderRequest.requested_at)).offset((page - 1) * limit).limit(limit).all()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'requests': [
|
||||
{
|
||||
'id': r.id,
|
||||
'item_id': r.item_id,
|
||||
'item_name': r.item.name,
|
||||
'requested_quantity': float(r.requested_quantity),
|
||||
'current_quantity': float(r.current_quantity),
|
||||
'minimum_quantity': float(r.minimum_quantity),
|
||||
'status': r.status.value,
|
||||
'priority': r.priority,
|
||||
'notes': r.notes,
|
||||
'requested_at': r.requested_at.isoformat() if r.requested_at else None,
|
||||
}
|
||||
for r in requests
|
||||
],
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total_pages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching reorder requests: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='Failed to fetch reorder requests')
|
||||
|
||||
|
||||
# ==================== Task Consumption ====================
|
||||
|
||||
@router.post('/task-consumption')
|
||||
async def record_task_consumption(
|
||||
consumption_data: TaskConsumptionCreate,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Record inventory consumption for a housekeeping task"""
|
||||
try:
|
||||
task = db.query(HousekeepingTask).filter(HousekeepingTask.id == consumption_data.task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail='Housekeeping task not found')
|
||||
|
||||
item = db.query(InventoryItem).filter(InventoryItem.id == consumption_data.item_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail='Inventory item not found')
|
||||
|
||||
# Check if user is housekeeping - they can only record for their own tasks
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_housekeeping = role and role.name == 'housekeeping'
|
||||
|
||||
if is_housekeeping and task.assigned_to != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='You can only record consumption for your assigned tasks')
|
||||
|
||||
# Create consumption record
|
||||
consumption = InventoryTaskConsumption(
|
||||
task_id=consumption_data.task_id,
|
||||
item_id=consumption_data.item_id,
|
||||
quantity=consumption_data.quantity,
|
||||
notes=consumption_data.notes,
|
||||
recorded_by=current_user.id,
|
||||
recorded_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
# Create transaction and update stock
|
||||
quantity_before = item.current_quantity
|
||||
quantity_after = quantity_before - abs(consumption_data.quantity)
|
||||
|
||||
transaction = InventoryTransaction(
|
||||
item_id=consumption_data.item_id,
|
||||
transaction_type=TransactionType.consumption,
|
||||
quantity=-abs(consumption_data.quantity),
|
||||
quantity_before=quantity_before,
|
||||
quantity_after=quantity_after,
|
||||
reference_type='housekeeping_task',
|
||||
reference_id=consumption_data.task_id,
|
||||
notes=f'Consumed for task {consumption_data.task_id}',
|
||||
performed_by=current_user.id,
|
||||
transaction_date=datetime.utcnow(),
|
||||
)
|
||||
|
||||
item.current_quantity = quantity_after
|
||||
|
||||
db.add(consumption)
|
||||
db.add(transaction)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Consumption recorded successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error recording consumption: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail='Failed to record consumption')
|
||||
|
||||
@router.get('/low-stock')
|
||||
async def get_low_stock_items(
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all items with low stock (current_quantity <= minimum_quantity)"""
|
||||
try:
|
||||
items = db.query(InventoryItem).filter(
|
||||
InventoryItem.is_active == True,
|
||||
InventoryItem.current_quantity <= InventoryItem.minimum_quantity
|
||||
).all()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'items': [
|
||||
{
|
||||
'id': item.id,
|
||||
'name': item.name,
|
||||
'category': item.category.value,
|
||||
'current_quantity': float(item.current_quantity),
|
||||
'minimum_quantity': float(item.minimum_quantity),
|
||||
'unit': item.unit.value,
|
||||
'reorder_quantity': float(item.reorder_quantity) if item.reorder_quantity else None,
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
'count': len(items)
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching low stock items: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='Failed to fetch low stock items')
|
||||
|
||||
464
Backend/src/hotel_services/routes/staff_shift_routes.py
Normal file
464
Backend/src/hotel_services/routes/staff_shift_routes.py
Normal file
@@ -0,0 +1,464 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_, or_, func, desc
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, date, time, timedelta
|
||||
from ...shared.config.database import get_db
|
||||
from ...shared.config.logging_config import get_logger
|
||||
from ...security.middleware.auth import get_current_user, authorize_roles
|
||||
from ...auth.models.user import User
|
||||
from ...auth.models.role import Role
|
||||
from ..models.staff_shift import (
|
||||
StaffShift, StaffTask, ShiftType, ShiftStatus,
|
||||
StaffTaskPriority, StaffTaskStatus
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix='/staff-shifts', tags=['staff-shifts'])
|
||||
|
||||
|
||||
@router.get('/')
|
||||
async def get_shifts(
|
||||
staff_id: Optional[int] = Query(None),
|
||||
shift_date: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
department: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get staff shifts with filtering"""
|
||||
try:
|
||||
query = db.query(StaffShift).options(
|
||||
joinedload(StaffShift.staff),
|
||||
joinedload(StaffShift.assigner)
|
||||
)
|
||||
|
||||
# Check if user is staff (not admin) - staff should only see their own shifts
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_staff = role and role.name == 'staff'
|
||||
|
||||
if is_staff:
|
||||
query = query.filter(StaffShift.staff_id == current_user.id)
|
||||
elif staff_id:
|
||||
query = query.filter(StaffShift.staff_id == staff_id)
|
||||
|
||||
if shift_date:
|
||||
try:
|
||||
date_obj = datetime.fromisoformat(shift_date.replace('Z', '+00:00')).date()
|
||||
query = query.filter(func.date(StaffShift.shift_date) == date_obj)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Please use YYYY-MM-DD.")
|
||||
|
||||
if status:
|
||||
query = query.filter(StaffShift.status == ShiftStatus(status))
|
||||
|
||||
if department:
|
||||
query = query.filter(StaffShift.department == department)
|
||||
|
||||
total = query.count()
|
||||
shifts = query.order_by(desc(StaffShift.shift_date), desc(StaffShift.start_time)).offset((page - 1) * limit).limit(limit).all()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'shifts': [
|
||||
{
|
||||
'id': shift.id,
|
||||
'staff_id': shift.staff_id,
|
||||
'staff_name': shift.staff.full_name if shift.staff else None,
|
||||
'shift_date': shift.shift_date.isoformat() if shift.shift_date else None,
|
||||
'shift_type': shift.shift_type.value,
|
||||
'start_time': shift.start_time.isoformat() if shift.start_time else None,
|
||||
'end_time': shift.end_time.isoformat() if shift.end_time else None,
|
||||
'status': shift.status.value,
|
||||
'actual_start_time': shift.actual_start_time.isoformat() if shift.actual_start_time else None,
|
||||
'actual_end_time': shift.actual_end_time.isoformat() if shift.actual_end_time else None,
|
||||
'break_duration_minutes': shift.break_duration_minutes,
|
||||
'department': shift.department,
|
||||
'notes': shift.notes,
|
||||
'handover_notes': shift.handover_notes,
|
||||
'tasks_completed': shift.tasks_completed,
|
||||
'tasks_assigned': shift.tasks_assigned,
|
||||
'assigned_by_name': shift.assigner.full_name if shift.assigner else None,
|
||||
}
|
||||
for shift in shifts
|
||||
],
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total_pages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching shifts: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f'Failed to fetch shifts: {str(e)}')
|
||||
|
||||
|
||||
@router.post('/')
|
||||
async def create_shift(
|
||||
shift_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new staff shift"""
|
||||
try:
|
||||
# Only admin can create shifts for other staff
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_admin = role and role.name == 'admin'
|
||||
|
||||
staff_id = shift_data.get('staff_id')
|
||||
if not is_admin and staff_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='You can only create shifts for yourself')
|
||||
|
||||
if not staff_id:
|
||||
staff_id = current_user.id
|
||||
|
||||
shift = StaffShift(
|
||||
staff_id=staff_id,
|
||||
shift_date=datetime.fromisoformat(shift_data['shift_date'].replace('Z', '+00:00')),
|
||||
shift_type=ShiftType(shift_data.get('shift_type', 'custom')),
|
||||
start_time=time.fromisoformat(shift_data['start_time']) if isinstance(shift_data.get('start_time'), str) else shift_data.get('start_time'),
|
||||
end_time=time.fromisoformat(shift_data['end_time']) if isinstance(shift_data.get('end_time'), str) else shift_data.get('end_time'),
|
||||
status=ShiftStatus(shift_data.get('status', 'scheduled')),
|
||||
break_duration_minutes=shift_data.get('break_duration_minutes', 30),
|
||||
department=shift_data.get('department'),
|
||||
notes=shift_data.get('notes'),
|
||||
assigned_by=current_user.id if is_admin else None,
|
||||
)
|
||||
|
||||
db.add(shift)
|
||||
db.commit()
|
||||
db.refresh(shift)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Shift created successfully',
|
||||
'data': {'id': shift.id}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating shift: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f'Failed to create shift: {str(e)}')
|
||||
|
||||
|
||||
@router.put('/{shift_id}')
|
||||
async def update_shift(
|
||||
shift_id: int,
|
||||
shift_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a staff shift"""
|
||||
try:
|
||||
shift = db.query(StaffShift).filter(StaffShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail='Shift not found')
|
||||
|
||||
# Check permissions
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_admin = role and role.name == 'admin'
|
||||
|
||||
if not is_admin and shift.staff_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='You can only update your own shifts')
|
||||
|
||||
# Update fields
|
||||
if 'shift_date' in shift_data:
|
||||
shift.shift_date = datetime.fromisoformat(shift_data['shift_date'].replace('Z', '+00:00'))
|
||||
if 'shift_type' in shift_data:
|
||||
shift.shift_type = ShiftType(shift_data['shift_type'])
|
||||
if 'start_time' in shift_data:
|
||||
shift.start_time = time.fromisoformat(shift_data['start_time']) if isinstance(shift_data['start_time'], str) else shift_data['start_time']
|
||||
if 'end_time' in shift_data:
|
||||
shift.end_time = time.fromisoformat(shift_data['end_time']) if isinstance(shift_data['end_time'], str) else shift_data['end_time']
|
||||
if 'status' in shift_data:
|
||||
shift.status = ShiftStatus(shift_data['status'])
|
||||
if shift_data['status'] == 'in_progress' and not shift.actual_start_time:
|
||||
shift.actual_start_time = datetime.utcnow()
|
||||
elif shift_data['status'] == 'completed' and not shift.actual_end_time:
|
||||
shift.actual_end_time = datetime.utcnow()
|
||||
if 'break_duration_minutes' in shift_data:
|
||||
shift.break_duration_minutes = shift_data['break_duration_minutes']
|
||||
if 'department' in shift_data:
|
||||
shift.department = shift_data['department']
|
||||
if 'notes' in shift_data:
|
||||
shift.notes = shift_data['notes']
|
||||
if 'handover_notes' in shift_data:
|
||||
shift.handover_notes = shift_data['handover_notes']
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Shift updated successfully'
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating shift: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f'Failed to update shift: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/tasks')
|
||||
async def get_staff_tasks(
|
||||
staff_id: Optional[int] = Query(None),
|
||||
shift_id: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
priority: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get staff tasks with filtering"""
|
||||
try:
|
||||
query = db.query(StaffTask).options(
|
||||
joinedload(StaffTask.staff),
|
||||
joinedload(StaffTask.assigner),
|
||||
joinedload(StaffTask.shift)
|
||||
)
|
||||
|
||||
# Check if user is staff (not admin) - staff should only see their own tasks
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_staff = role and role.name == 'staff'
|
||||
|
||||
if is_staff:
|
||||
query = query.filter(StaffTask.staff_id == current_user.id)
|
||||
elif staff_id:
|
||||
query = query.filter(StaffTask.staff_id == staff_id)
|
||||
|
||||
if shift_id:
|
||||
query = query.filter(StaffTask.shift_id == shift_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(StaffTask.status == StaffTaskStatus(status))
|
||||
|
||||
if priority:
|
||||
query = query.filter(StaffTask.priority == StaffTaskPriority(priority))
|
||||
|
||||
total = query.count()
|
||||
tasks = query.order_by(
|
||||
desc(StaffTask.priority == StaffTaskPriority.urgent),
|
||||
desc(StaffTask.priority == StaffTaskPriority.high),
|
||||
desc(StaffTask.due_date)
|
||||
).offset((page - 1) * limit).limit(limit).all()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'tasks': [
|
||||
{
|
||||
'id': task.id,
|
||||
'shift_id': task.shift_id,
|
||||
'staff_id': task.staff_id,
|
||||
'staff_name': task.staff.full_name if task.staff else None,
|
||||
'title': task.title,
|
||||
'description': task.description,
|
||||
'task_type': task.task_type,
|
||||
'priority': task.priority.value,
|
||||
'status': task.status.value,
|
||||
'scheduled_start': task.scheduled_start.isoformat() if task.scheduled_start else None,
|
||||
'scheduled_end': task.scheduled_end.isoformat() if task.scheduled_end else None,
|
||||
'actual_start': task.actual_start.isoformat() if task.actual_start else None,
|
||||
'actual_end': task.actual_end.isoformat() if task.actual_end else None,
|
||||
'estimated_duration_minutes': task.estimated_duration_minutes,
|
||||
'actual_duration_minutes': task.actual_duration_minutes,
|
||||
'due_date': task.due_date.isoformat() if task.due_date else None,
|
||||
'related_booking_id': task.related_booking_id,
|
||||
'related_room_id': task.related_room_id,
|
||||
'related_guest_request_id': task.related_guest_request_id,
|
||||
'related_maintenance_id': task.related_maintenance_id,
|
||||
'notes': task.notes,
|
||||
'completion_notes': task.completion_notes,
|
||||
'assigned_by_name': task.assigner.full_name if task.assigner else None,
|
||||
}
|
||||
for task in tasks
|
||||
],
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total_pages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching tasks: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f'Failed to fetch tasks: {str(e)}')
|
||||
|
||||
|
||||
@router.post('/tasks')
|
||||
async def create_task(
|
||||
task_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new staff task"""
|
||||
try:
|
||||
task = StaffTask(
|
||||
shift_id=task_data.get('shift_id'),
|
||||
staff_id=task_data.get('staff_id', current_user.id),
|
||||
title=task_data['title'],
|
||||
description=task_data.get('description'),
|
||||
task_type=task_data.get('task_type', 'general'),
|
||||
priority=StaffTaskPriority(task_data.get('priority', 'normal')),
|
||||
status=StaffTaskStatus(task_data.get('status', 'pending')),
|
||||
scheduled_start=datetime.fromisoformat(task_data['scheduled_start'].replace('Z', '+00:00')) if task_data.get('scheduled_start') else None,
|
||||
scheduled_end=datetime.fromisoformat(task_data['scheduled_end'].replace('Z', '+00:00')) if task_data.get('scheduled_end') else None,
|
||||
estimated_duration_minutes=task_data.get('estimated_duration_minutes'),
|
||||
due_date=datetime.fromisoformat(task_data['due_date'].replace('Z', '+00:00')) if task_data.get('due_date') else None,
|
||||
related_booking_id=task_data.get('related_booking_id'),
|
||||
related_room_id=task_data.get('related_room_id'),
|
||||
related_guest_request_id=task_data.get('related_guest_request_id'),
|
||||
related_maintenance_id=task_data.get('related_maintenance_id'),
|
||||
notes=task_data.get('notes'),
|
||||
assigned_by=current_user.id,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
|
||||
# Update shift task count if shift_id is provided
|
||||
if task.shift_id:
|
||||
shift = db.query(StaffShift).filter(StaffShift.id == task.shift_id).first()
|
||||
if shift:
|
||||
shift.tasks_assigned = (shift.tasks_assigned or 0) + 1
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Task created successfully',
|
||||
'data': {'id': task.id}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating task: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f'Failed to create task: {str(e)}')
|
||||
|
||||
|
||||
@router.put('/tasks/{task_id}')
|
||||
async def update_task(
|
||||
task_id: int,
|
||||
task_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a staff task"""
|
||||
try:
|
||||
task = db.query(StaffTask).filter(StaffTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail='Task not found')
|
||||
|
||||
# Check permissions
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_admin = role and role.name == 'admin'
|
||||
|
||||
if not is_admin and task.staff_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='You can only update your own tasks')
|
||||
|
||||
# Update fields
|
||||
if 'title' in task_data:
|
||||
task.title = task_data['title']
|
||||
if 'description' in task_data:
|
||||
task.description = task_data['description']
|
||||
if 'priority' in task_data:
|
||||
task.priority = StaffTaskPriority(task_data['priority'])
|
||||
if 'status' in task_data:
|
||||
old_status = task.status
|
||||
task.status = StaffTaskStatus(task_data['status'])
|
||||
|
||||
# Track timestamps
|
||||
if task_data['status'] == 'in_progress' and old_status != StaffTaskStatus.in_progress:
|
||||
task.actual_start = datetime.utcnow()
|
||||
elif task_data['status'] == 'completed' and old_status != StaffTaskStatus.completed:
|
||||
task.actual_end = datetime.utcnow()
|
||||
if task.actual_start:
|
||||
delta = task.actual_end - task.actual_start
|
||||
task.actual_duration_minutes = int(delta.total_seconds() / 60)
|
||||
|
||||
# Update shift task count
|
||||
if task.shift_id:
|
||||
shift = db.query(StaffShift).filter(StaffShift.id == task.shift_id).first()
|
||||
if shift:
|
||||
shift.tasks_completed = (shift.tasks_completed or 0) + 1
|
||||
if 'completion_notes' in task_data:
|
||||
task.completion_notes = task_data['completion_notes']
|
||||
if 'notes' in task_data:
|
||||
task.notes = task_data['notes']
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Task updated successfully'
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating task: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f'Failed to update task: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/workload')
|
||||
async def get_workload_summary(
|
||||
date: Optional[str] = Query(None),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get workload summary for staff members"""
|
||||
try:
|
||||
query_date = datetime.utcnow().date()
|
||||
if date:
|
||||
try:
|
||||
query_date = datetime.fromisoformat(date.replace('Z', '+00:00')).date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Please use YYYY-MM-DD.")
|
||||
|
||||
# Get all active staff
|
||||
staff_query = db.query(User).join(Role).filter(Role.name == 'staff')
|
||||
|
||||
workload_summary = []
|
||||
for staff in staff_query.all():
|
||||
# Get shifts for the date
|
||||
shifts = db.query(StaffShift).filter(
|
||||
StaffShift.staff_id == staff.id,
|
||||
func.date(StaffShift.shift_date) == query_date
|
||||
).all()
|
||||
|
||||
# Get tasks
|
||||
tasks = db.query(StaffTask).filter(
|
||||
StaffTask.staff_id == staff.id,
|
||||
func.date(StaffTask.scheduled_start) == query_date if StaffTask.scheduled_start else False
|
||||
).all()
|
||||
|
||||
workload_summary.append({
|
||||
'staff_id': staff.id,
|
||||
'staff_name': staff.full_name,
|
||||
'shifts_count': len(shifts),
|
||||
'tasks_count': len(tasks),
|
||||
'tasks_completed': len([t for t in tasks if t.status == StaffTaskStatus.completed]),
|
||||
'tasks_pending': len([t for t in tasks if t.status == StaffTaskStatus.pending]),
|
||||
'tasks_in_progress': len([t for t in tasks if t.status == StaffTaskStatus.in_progress]),
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'date': query_date.isoformat(),
|
||||
'workload': workload_summary
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching workload: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f'Failed to fetch workload: {str(e)}')
|
||||
|
||||
@@ -289,8 +289,9 @@ app.mount('/uploads-static', StaticFiles(directory=str(uploads_dir)), name='uplo
|
||||
from .auth.routes import auth_routes, user_routes
|
||||
from .rooms.routes import room_routes, advanced_room_routes, rate_plan_routes
|
||||
from .bookings.routes import booking_routes, group_booking_routes
|
||||
from .bookings.routes.upsell_routes import router as upsell_routes
|
||||
from .payments.routes import payment_routes, invoice_routes, financial_routes, audit_trail_routes
|
||||
from .hotel_services.routes import service_routes, service_booking_routes
|
||||
from .hotel_services.routes import service_routes, service_booking_routes, inventory_routes, guest_request_routes, staff_shift_routes
|
||||
from .content.routes import (
|
||||
banner_routes, page_content_routes, home_routes, about_routes,
|
||||
contact_routes, contact_content_routes, footer_routes, privacy_routes,
|
||||
@@ -318,6 +319,7 @@ app.include_router(auth_routes.router, prefix=api_prefix)
|
||||
app.include_router(room_routes.router, prefix=api_prefix)
|
||||
app.include_router(booking_routes.router, prefix=api_prefix)
|
||||
app.include_router(group_booking_routes.router, prefix=api_prefix)
|
||||
app.include_router(upsell_routes, prefix=api_prefix)
|
||||
app.include_router(payment_routes.router, prefix=api_prefix)
|
||||
app.include_router(invoice_routes.router, prefix=api_prefix)
|
||||
app.include_router(financial_routes.router, prefix=api_prefix)
|
||||
@@ -353,6 +355,9 @@ app.include_router(workflow_routes.router, prefix=api_prefix)
|
||||
app.include_router(task_routes.router, prefix=api_prefix)
|
||||
app.include_router(notification_routes.router, prefix=api_prefix)
|
||||
app.include_router(advanced_room_routes.router, prefix=api_prefix)
|
||||
app.include_router(inventory_routes.router, prefix=api_prefix)
|
||||
app.include_router(guest_request_routes.router, prefix=api_prefix)
|
||||
app.include_router(staff_shift_routes.router, prefix=api_prefix)
|
||||
app.include_router(rate_plan_routes.router, prefix=api_prefix)
|
||||
app.include_router(package_routes.router, prefix=api_prefix)
|
||||
app.include_router(security_routes.router, prefix=api_prefix)
|
||||
|
||||
@@ -17,6 +17,13 @@ from ..rooms.models.room_maintenance import RoomMaintenance, MaintenanceType, Ma
|
||||
from ..rooms.models.room_inspection import RoomInspection, InspectionType, InspectionStatus
|
||||
from ..rooms.models.rate_plan import RatePlan, RatePlanRule, RatePlanType, RatePlanStatus
|
||||
|
||||
# Hotel Services models
|
||||
from ..hotel_services.models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
|
||||
from ..hotel_services.models.inventory_item import InventoryItem, InventoryCategory, InventoryUnit
|
||||
from ..hotel_services.models.inventory_transaction import InventoryTransaction, TransactionType
|
||||
from ..hotel_services.models.inventory_reorder_request import InventoryReorderRequest, ReorderStatus
|
||||
from ..hotel_services.models.inventory_task_consumption import InventoryTaskConsumption
|
||||
|
||||
# Booking models
|
||||
from ..bookings.models.booking import Booking
|
||||
from ..bookings.models.checkin_checkout import CheckInCheckOut
|
||||
@@ -34,6 +41,12 @@ from ..hotel_services.models.service import Service
|
||||
from ..hotel_services.models.service_usage import ServiceUsage
|
||||
from ..hotel_services.models.service_booking import ServiceBooking, ServiceBookingItem, ServicePayment, ServiceBookingStatus, ServicePaymentStatus, ServicePaymentMethod
|
||||
from ..hotel_services.models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
|
||||
from ..hotel_services.models.inventory_item import InventoryItem, InventoryCategory, InventoryUnit
|
||||
from ..hotel_services.models.inventory_transaction import InventoryTransaction, TransactionType
|
||||
from ..hotel_services.models.inventory_reorder_request import InventoryReorderRequest, ReorderStatus
|
||||
from ..hotel_services.models.inventory_task_consumption import InventoryTaskConsumption
|
||||
from ..hotel_services.models.guest_request import GuestRequest, RequestType, RequestStatus, RequestPriority
|
||||
from ..hotel_services.models.staff_shift import StaffShift, StaffTask, ShiftType, ShiftStatus, StaffTaskPriority, StaffTaskStatus
|
||||
|
||||
# Content models
|
||||
from ..content.models.banner import Banner
|
||||
@@ -98,6 +111,12 @@ __all__ = [
|
||||
# Hotel Services
|
||||
'Service', 'ServiceUsage', 'ServiceBooking', 'ServiceBookingItem', 'ServicePayment', 'ServiceBookingStatus', 'ServicePaymentStatus', 'ServicePaymentMethod',
|
||||
'HousekeepingTask', 'HousekeepingStatus', 'HousekeepingType',
|
||||
'InventoryItem', 'InventoryCategory', 'InventoryUnit',
|
||||
'InventoryTransaction', 'TransactionType',
|
||||
'InventoryReorderRequest', 'ReorderStatus',
|
||||
'InventoryTaskConsumption',
|
||||
'GuestRequest', 'RequestType', 'RequestStatus', 'RequestPriority',
|
||||
'StaffShift', 'StaffTask', 'ShiftType', 'ShiftStatus', 'StaffTaskPriority', 'StaffTaskStatus',
|
||||
# Content
|
||||
'Banner', 'BlogPost', 'PageContent', 'PageType', 'CookiePolicy', 'CookieIntegrationConfig',
|
||||
# Reviews
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,6 +3,7 @@ Routes for financial audit trail access.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import ProgrammingError, OperationalError
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from ...shared.config.database import get_db
|
||||
@@ -87,24 +88,29 @@ async def get_financial_audit_trail(
|
||||
})
|
||||
|
||||
# Get total count for pagination
|
||||
total_query = db.query(FinancialAuditTrail)
|
||||
if payment_id:
|
||||
total_query = total_query.filter(FinancialAuditTrail.payment_id == payment_id)
|
||||
if invoice_id:
|
||||
total_query = total_query.filter(FinancialAuditTrail.invoice_id == invoice_id)
|
||||
if booking_id:
|
||||
total_query = total_query.filter(FinancialAuditTrail.booking_id == booking_id)
|
||||
if action_type_enum:
|
||||
total_query = total_query.filter(FinancialAuditTrail.action_type == action_type_enum)
|
||||
if user_id:
|
||||
total_query = total_query.filter(FinancialAuditTrail.performed_by == user_id)
|
||||
if start:
|
||||
total_query = total_query.filter(FinancialAuditTrail.created_at >= start)
|
||||
if end:
|
||||
total_query = total_query.filter(FinancialAuditTrail.created_at <= end)
|
||||
|
||||
total_count = total_query.count()
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
try:
|
||||
total_query = db.query(FinancialAuditTrail)
|
||||
if payment_id:
|
||||
total_query = total_query.filter(FinancialAuditTrail.payment_id == payment_id)
|
||||
if invoice_id:
|
||||
total_query = total_query.filter(FinancialAuditTrail.invoice_id == invoice_id)
|
||||
if booking_id:
|
||||
total_query = total_query.filter(FinancialAuditTrail.booking_id == booking_id)
|
||||
if action_type_enum:
|
||||
total_query = total_query.filter(FinancialAuditTrail.action_type == action_type_enum)
|
||||
if user_id:
|
||||
total_query = total_query.filter(FinancialAuditTrail.performed_by == user_id)
|
||||
if start:
|
||||
total_query = total_query.filter(FinancialAuditTrail.created_at >= start)
|
||||
if end:
|
||||
total_query = total_query.filter(FinancialAuditTrail.created_at <= end)
|
||||
|
||||
total_count = total_query.count()
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
except (ProgrammingError, OperationalError):
|
||||
# If table doesn't exist, count is 0
|
||||
total_count = 0
|
||||
total_pages = 0
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -120,6 +126,26 @@ async def get_financial_audit_trail(
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except (ProgrammingError, OperationalError) as e:
|
||||
# Handle case where financial_audit_trail table doesn't exist
|
||||
error_msg = str(e)
|
||||
if 'doesn\'t exist' in error_msg or 'Table' in error_msg:
|
||||
logger.warning(f'Financial audit trail table not found: {error_msg}')
|
||||
return success_response(
|
||||
data={
|
||||
'audit_trail': [],
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total': 0,
|
||||
'total_pages': 0
|
||||
},
|
||||
'note': 'Financial audit trail table not yet created. Please run database migrations.'
|
||||
},
|
||||
message='Financial audit trail is not available (table not created)'
|
||||
)
|
||||
logger.error(f'Database error retrieving financial audit trail: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='Database error occurred while retrieving audit trail')
|
||||
except Exception as e:
|
||||
logger.error(f'Error retrieving financial audit trail: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='An error occurred while retrieving audit trail')
|
||||
@@ -158,6 +184,14 @@ async def get_audit_record(
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except (ProgrammingError, OperationalError) as e:
|
||||
# Handle case where financial_audit_trail table doesn't exist
|
||||
error_msg = str(e)
|
||||
if 'doesn\'t exist' in error_msg or 'Table' in error_msg:
|
||||
logger.warning(f'Financial audit trail table not found: {error_msg}')
|
||||
raise HTTPException(status_code=404, detail='Financial audit trail table not yet created. Please run database migrations.')
|
||||
logger.error(f'Database error retrieving audit record: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='Database error occurred while retrieving audit record')
|
||||
except Exception as e:
|
||||
logger.error(f'Error retrieving audit record: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='An error occurred while retrieving audit record')
|
||||
|
||||
@@ -24,6 +24,14 @@ router = APIRouter(prefix='/invoices', tags=['invoices'])
|
||||
@router.get('/')
|
||||
async def get_invoices(request: Request, booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
try:
|
||||
# SECURITY: Verify booking ownership when booking_id is provided
|
||||
if booking_id and not can_access_all_invoices(current_user, db):
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
if booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden: You do not have permission to access invoices for this booking')
|
||||
|
||||
user_id = None if can_access_all_invoices(current_user, db) else current_user.id
|
||||
result = InvoiceService.get_invoices(db=db, user_id=user_id, booking_id=booking_id, status=status_filter, page=page, limit=limit)
|
||||
return success_response(data=result)
|
||||
@@ -38,8 +46,10 @@ async def get_invoice_by_id(id: int, current_user: User=Depends(get_current_user
|
||||
invoice = InvoiceService.get_invoice(id, db)
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail='Invoice not found')
|
||||
if not can_access_all_invoices(current_user, db) and invoice['user_id'] != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
# SECURITY: Verify invoice ownership for non-admin/accountant users
|
||||
if not can_access_all_invoices(current_user, db):
|
||||
if 'user_id' not in invoice or invoice['user_id'] != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden: You do not have permission to access this invoice')
|
||||
return success_response(data={'invoice': invoice})
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -48,9 +58,10 @@ async def get_invoice_by_id(id: int, current_user: User=Depends(get_current_user
|
||||
logger.error(f'Error fetching invoice by id: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/')
|
||||
async def create_invoice(request: Request, invoice_data: CreateInvoiceRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
@router.post('/', dependencies=[Depends(authorize_roles('admin', 'staff', 'accountant'))])
|
||||
async def create_invoice(request: Request, invoice_data: CreateInvoiceRequest, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)):
|
||||
try:
|
||||
# Defense in depth: Additional business logic check (authorize_roles already verified)
|
||||
if not can_create_invoices(current_user, db):
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
booking_id = invoice_data.booking_id
|
||||
@@ -137,13 +148,46 @@ async def mark_invoice_as_paid(request: Request, id: int, payment_data: MarkInvo
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete('/{id}')
|
||||
async def delete_invoice(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
async def delete_invoice(id: int, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = get_request_id(request)
|
||||
|
||||
try:
|
||||
invoice = db.query(Invoice).filter(Invoice.id == id).first()
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail='Invoice not found')
|
||||
|
||||
# Capture invoice info before deletion for audit
|
||||
deleted_invoice_info = {
|
||||
'invoice_id': invoice.id,
|
||||
'invoice_number': invoice.invoice_number,
|
||||
'user_id': invoice.user_id,
|
||||
'booking_id': invoice.booking_id,
|
||||
'total_amount': float(invoice.total_amount) if invoice.total_amount else 0.0,
|
||||
'status': invoice.status.value if hasattr(invoice.status, 'value') else str(invoice.status),
|
||||
}
|
||||
|
||||
db.delete(invoice)
|
||||
db.commit()
|
||||
|
||||
# SECURITY: Log invoice deletion for audit trail
|
||||
try:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='invoice_deleted',
|
||||
resource_type='invoice',
|
||||
user_id=current_user.id,
|
||||
resource_id=id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details=deleted_invoice_info,
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log invoice deletion audit: {e}')
|
||||
|
||||
return success_response(message='Invoice deleted successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -79,6 +79,14 @@ async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reaso
|
||||
@router.get('/')
|
||||
async def get_payments(booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
try:
|
||||
# SECURITY: Verify booking ownership when booking_id is provided
|
||||
if booking_id and not can_access_all_payments(current_user, db):
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
if booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden: You do not have permission to access payments for this booking')
|
||||
|
||||
if booking_id:
|
||||
query = db.query(Payment).filter(Payment.booking_id == booking_id)
|
||||
else:
|
||||
@@ -168,12 +176,16 @@ async def get_payments_by_booking_id(booking_id: int, current_user: User=Depends
|
||||
@router.get('/{id}')
|
||||
async def get_payment_by_id(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
try:
|
||||
payment = db.query(Payment).filter(Payment.id == id).first()
|
||||
# SECURITY: Load booking relationship to verify ownership
|
||||
payment = db.query(Payment).options(joinedload(Payment.booking)).filter(Payment.id == id).first()
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail='Payment not found')
|
||||
# SECURITY: Verify payment ownership for non-admin/accountant users
|
||||
if not can_access_all_payments(current_user, db):
|
||||
if payment.booking and payment.booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
if not payment.booking:
|
||||
raise HTTPException(status_code=403, detail='Forbidden: Payment does not belong to any booking')
|
||||
if payment.booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden: You do not have permission to access this payment')
|
||||
payment_dict = {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, 'payment_type': payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, 'deposit_percentage': payment.deposit_percentage, 'related_payment_id': payment.related_payment_id, 'payment_status': payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None, 'notes': payment.notes, 'created_at': payment.created_at.isoformat() if payment.created_at else None}
|
||||
if payment.booking:
|
||||
payment_dict['booking'] = {'id': payment.booking.id, 'booking_number': payment.booking.booking_number}
|
||||
|
||||
Binary file not shown.
@@ -2,6 +2,7 @@
|
||||
Service for creating and managing financial audit trail records.
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import ProgrammingError, OperationalError
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from ..models.financial_audit_trail import FinancialAuditTrail, FinancialActionType
|
||||
@@ -56,6 +57,15 @@ class FinancialAuditService:
|
||||
|
||||
logger.info(f"Financial audit trail created: {action_type.value} by user {performed_by}")
|
||||
return audit_record
|
||||
except (ProgrammingError, OperationalError) as e:
|
||||
error_msg = str(e)
|
||||
if 'doesn\'t exist' in error_msg or 'Table' in error_msg:
|
||||
logger.warning(f"Financial audit trail table not found, skipping audit logging: {error_msg}")
|
||||
# Don't fail the main operation if audit table doesn't exist
|
||||
return None
|
||||
logger.error(f"Database error creating financial audit trail: {str(e)}", exc_info=True)
|
||||
# Don't fail the main operation if audit logging fails
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating financial audit trail: {str(e)}", exc_info=True)
|
||||
# Don't fail the main operation if audit logging fails
|
||||
@@ -95,7 +105,14 @@ class FinancialAuditService:
|
||||
query = query.order_by(FinancialAuditTrail.created_at.desc())
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
return query.all()
|
||||
try:
|
||||
return query.all()
|
||||
except (ProgrammingError, OperationalError) as e:
|
||||
error_msg = str(e)
|
||||
if 'doesn\'t exist' in error_msg or 'Table' in error_msg:
|
||||
logger.warning(f"Financial audit trail table not found: {error_msg}")
|
||||
return []
|
||||
raise
|
||||
|
||||
|
||||
financial_audit_service = FinancialAuditService()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -13,8 +13,15 @@ router = APIRouter(prefix='/favorites', tags=['favorites'])
|
||||
|
||||
@router.get('/')
|
||||
async def get_favorites(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if role and role.name in ['admin', 'staff', 'accountant']:
|
||||
# PERFORMANCE: Use eager-loaded role relationship if available
|
||||
if hasattr(current_user, 'role') and current_user.role is not None:
|
||||
role_name = current_user.role.name
|
||||
else:
|
||||
# Fallback: query if relationship wasn't loaded
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
role_name = role.name if role else 'customer'
|
||||
|
||||
if role_name in ['admin', 'staff', 'accountant']:
|
||||
raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot have favorites')
|
||||
try:
|
||||
favorites = db.query(Favorite).filter(Favorite.user_id == current_user.id).order_by(Favorite.created_at.desc()).all()
|
||||
@@ -35,8 +42,15 @@ async def get_favorites(current_user: User=Depends(get_current_user), db: Sessio
|
||||
|
||||
@router.post('/{room_id}')
|
||||
async def add_favorite(room_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if role and role.name in ['admin', 'staff', 'accountant']:
|
||||
# PERFORMANCE: Use eager-loaded role relationship if available
|
||||
if hasattr(current_user, 'role') and current_user.role is not None:
|
||||
role_name = current_user.role.name
|
||||
else:
|
||||
# Fallback: query if relationship wasn't loaded
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
role_name = role.name if role else 'customer'
|
||||
|
||||
if role_name in ['admin', 'staff', 'accountant']:
|
||||
raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot add favorites')
|
||||
try:
|
||||
room = db.query(Room).filter(Room.id == room_id).first()
|
||||
@@ -58,8 +72,15 @@ async def add_favorite(room_id: int, current_user: User=Depends(get_current_user
|
||||
|
||||
@router.delete('/{room_id}')
|
||||
async def remove_favorite(room_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if role and role.name in ['admin', 'staff', 'accountant']:
|
||||
# PERFORMANCE: Use eager-loaded role relationship if available
|
||||
if hasattr(current_user, 'role') and current_user.role is not None:
|
||||
role_name = current_user.role.name
|
||||
else:
|
||||
# Fallback: query if relationship wasn't loaded
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
role_name = role.name if role else 'customer'
|
||||
|
||||
if role_name in ['admin', 'staff', 'accountant']:
|
||||
raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot remove favorites')
|
||||
try:
|
||||
favorite = db.query(Favorite).filter(Favorite.user_id == current_user.id, Favorite.room_id == room_id).first()
|
||||
@@ -76,8 +97,15 @@ async def remove_favorite(room_id: int, current_user: User=Depends(get_current_u
|
||||
|
||||
@router.get('/check/{room_id}')
|
||||
async def check_favorite(room_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if role and role.name in ['admin', 'staff', 'accountant']:
|
||||
# PERFORMANCE: Use eager-loaded role relationship if available
|
||||
if hasattr(current_user, 'role') and current_user.role is not None:
|
||||
role_name = current_user.role.name
|
||||
else:
|
||||
# Fallback: query if relationship wasn't loaded
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
role_name = role.name if role else 'customer'
|
||||
|
||||
if role_name in ['admin', 'staff', 'accountant']:
|
||||
return {'status': 'success', 'data': {'isFavorited': False}}
|
||||
try:
|
||||
favorite = db.query(Favorite).filter(Favorite.user_id == current_user.id, Favorite.room_id == room_id).first()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
from ...shared.utils.response_helpers import success_response, error_response
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import func, and_
|
||||
from typing import Optional
|
||||
from ...shared.config.database import get_db
|
||||
from ...shared.config.logging_config import get_logger
|
||||
@@ -9,7 +9,9 @@ from ...security.middleware.auth import get_current_user, authorize_roles
|
||||
from ...auth.models.user import User
|
||||
from ..models.review import Review, ReviewStatus
|
||||
from ...rooms.models.room import Room
|
||||
from ...bookings.models.booking import Booking, BookingStatus
|
||||
from ..schemas.review import CreateReviewRequest
|
||||
from ...analytics.services.audit_service import audit_service
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix='/reviews', tags=['reviews'])
|
||||
@@ -124,7 +126,11 @@ async def get_all_reviews(status_filter: Optional[str]=Query(None, alias='status
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/')
|
||||
async def create_review(review_data: CreateReviewRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
async def create_review(review_data: CreateReviewRequest, request: Request, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
room_id = review_data.room_id
|
||||
rating = review_data.rating
|
||||
@@ -145,6 +151,25 @@ async def create_review(review_data: CreateReviewRequest, current_user: User=Dep
|
||||
detail=error_response(message='You have already reviewed this room')
|
||||
)
|
||||
|
||||
# BUSINESS RULE: Verify user has actually stayed in this room
|
||||
# Users can only review rooms they've booked and checked out from
|
||||
from ...shared.utils.role_helpers import is_admin, is_staff
|
||||
if not (is_admin(current_user, db) or is_staff(current_user, db)):
|
||||
# Check if user has a checked-out booking for this room
|
||||
past_booking = db.query(Booking).filter(
|
||||
and_(
|
||||
Booking.user_id == current_user.id,
|
||||
Booking.room_id == room_id,
|
||||
Booking.status == BookingStatus.checked_out
|
||||
)
|
||||
).first()
|
||||
|
||||
if not past_booking:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=error_response(message='You can only review rooms you have stayed in. Please complete a booking and check out before leaving a review.')
|
||||
)
|
||||
|
||||
review = Review(
|
||||
user_id=current_user.id,
|
||||
room_id=room_id,
|
||||
@@ -156,6 +181,28 @@ async def create_review(review_data: CreateReviewRequest, current_user: User=Dep
|
||||
db.commit()
|
||||
db.refresh(review)
|
||||
|
||||
# SECURITY: Log review creation for audit trail
|
||||
try:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='review_created',
|
||||
resource_type='review',
|
||||
user_id=current_user.id,
|
||||
resource_id=review.id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details={
|
||||
'room_id': review.room_id,
|
||||
'rating': review.rating,
|
||||
'status': 'pending',
|
||||
'comment_length': len(review.comment) if review.comment else 0
|
||||
},
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log review creation audit: {e}')
|
||||
|
||||
review_dict = {
|
||||
'id': review.id,
|
||||
'user_id': review.user_id,
|
||||
@@ -181,18 +228,47 @@ async def create_review(review_data: CreateReviewRequest, current_user: User=Dep
|
||||
)
|
||||
|
||||
@router.patch('/{id}/approve', dependencies=[Depends(authorize_roles('admin'))])
|
||||
async def approve_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
async def approve_review(id: int, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
review = db.query(Review).filter(Review.id == id).first()
|
||||
review = db.query(Review).options(joinedload(Review.room)).filter(Review.id == id).first()
|
||||
if not review:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=error_response(message='Review not found')
|
||||
)
|
||||
old_status = review.status.value if hasattr(review.status, 'value') else str(review.status)
|
||||
review.status = ReviewStatus.approved
|
||||
db.commit()
|
||||
db.refresh(review)
|
||||
|
||||
# SECURITY: Log review approval for audit trail
|
||||
try:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='review_approved',
|
||||
resource_type='review',
|
||||
user_id=current_user.id,
|
||||
resource_id=review.id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details={
|
||||
'review_user_id': review.user_id,
|
||||
'room_id': review.room_id,
|
||||
'room_number': review.room.room_number if review.room else None,
|
||||
'rating': review.rating,
|
||||
'old_status': old_status,
|
||||
'new_status': 'approved'
|
||||
},
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log review approval audit: {e}')
|
||||
|
||||
review_dict = {
|
||||
'id': review.id,
|
||||
'user_id': review.user_id,
|
||||
@@ -218,18 +294,47 @@ async def approve_review(id: int, current_user: User=Depends(authorize_roles('ad
|
||||
)
|
||||
|
||||
@router.patch('/{id}/reject', dependencies=[Depends(authorize_roles('admin'))])
|
||||
async def reject_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
async def reject_review(id: int, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
review = db.query(Review).filter(Review.id == id).first()
|
||||
review = db.query(Review).options(joinedload(Review.room)).filter(Review.id == id).first()
|
||||
if not review:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=error_response(message='Review not found')
|
||||
)
|
||||
old_status = review.status.value if hasattr(review.status, 'value') else str(review.status)
|
||||
review.status = ReviewStatus.rejected
|
||||
db.commit()
|
||||
db.refresh(review)
|
||||
|
||||
# SECURITY: Log review rejection for audit trail
|
||||
try:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='review_rejected',
|
||||
resource_type='review',
|
||||
user_id=current_user.id,
|
||||
resource_id=review.id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details={
|
||||
'review_user_id': review.user_id,
|
||||
'room_id': review.room_id,
|
||||
'room_number': review.room.room_number if review.room else None,
|
||||
'rating': review.rating,
|
||||
'old_status': old_status,
|
||||
'new_status': 'rejected'
|
||||
},
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log review rejection audit: {e}')
|
||||
|
||||
review_dict = {
|
||||
'id': review.id,
|
||||
'user_id': review.user_id,
|
||||
@@ -255,13 +360,47 @@ async def reject_review(id: int, current_user: User=Depends(authorize_roles('adm
|
||||
)
|
||||
|
||||
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
|
||||
async def delete_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
async def delete_review(id: int, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
review = db.query(Review).filter(Review.id == id).first()
|
||||
review = db.query(Review).options(joinedload(Review.room)).filter(Review.id == id).first()
|
||||
if not review:
|
||||
raise HTTPException(status_code=404, detail='Review not found')
|
||||
|
||||
# Capture review details before deletion for audit
|
||||
review_details = {
|
||||
'review_id': review.id,
|
||||
'review_user_id': review.user_id,
|
||||
'room_id': review.room_id,
|
||||
'room_number': review.room.room_number if review.room else None,
|
||||
'rating': review.rating,
|
||||
'status': review.status.value if hasattr(review.status, 'value') else str(review.status),
|
||||
'comment_length': len(review.comment) if review.comment else 0
|
||||
}
|
||||
|
||||
db.delete(review)
|
||||
db.commit()
|
||||
|
||||
# SECURITY: Log review deletion for audit trail
|
||||
try:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='review_deleted',
|
||||
resource_type='review',
|
||||
user_id=current_user.id,
|
||||
resource_id=id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details=review_details,
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log review deletion audit: {e}')
|
||||
|
||||
return {'status': 'success', 'message': 'Review deleted successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,8 +1,11 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, UploadFile, File
|
||||
from sqlalchemy.orm import Session, joinedload, load_only
|
||||
from sqlalchemy import and_, or_, func, desc
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
import hashlib
|
||||
from ...shared.config.database import get_db
|
||||
from ...shared.config.logging_config import get_logger
|
||||
from ...security.middleware.auth import get_current_user, authorize_roles
|
||||
@@ -119,15 +122,23 @@ async def get_maintenance_records(
|
||||
):
|
||||
"""Get maintenance records with filtering"""
|
||||
try:
|
||||
# Check if user is staff (not admin) - staff should only see their assigned records
|
||||
# Check if user is staff (not admin) - staff should see their assigned records AND unassigned records
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_staff = role and role.name == 'staff'
|
||||
|
||||
query = db.query(RoomMaintenance)
|
||||
query = db.query(RoomMaintenance).options(
|
||||
joinedload(RoomMaintenance.room),
|
||||
joinedload(RoomMaintenance.assigned_staff)
|
||||
)
|
||||
|
||||
# Filter by assigned_to for staff users
|
||||
# Filter by assigned_to for staff users - include unassigned records so they can pick them up
|
||||
if is_staff:
|
||||
query = query.filter(RoomMaintenance.assigned_to == current_user.id)
|
||||
query = query.filter(
|
||||
or_(
|
||||
RoomMaintenance.assigned_to == current_user.id,
|
||||
RoomMaintenance.assigned_to.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
if room_id:
|
||||
query = query.filter(RoomMaintenance.room_id == room_id)
|
||||
@@ -144,6 +155,13 @@ async def get_maintenance_records(
|
||||
|
||||
result = []
|
||||
for record in records:
|
||||
# Get reported by user info
|
||||
reported_by_name = None
|
||||
if record.reported_by:
|
||||
reported_by_user = db.query(User).filter(User.id == record.reported_by).first()
|
||||
if reported_by_user:
|
||||
reported_by_name = reported_by_user.full_name
|
||||
|
||||
result.append({
|
||||
'id': record.id,
|
||||
'room_id': record.room_id,
|
||||
@@ -158,11 +176,16 @@ async def get_maintenance_records(
|
||||
'actual_end': record.actual_end.isoformat() if record.actual_end else None,
|
||||
'assigned_to': record.assigned_to,
|
||||
'assigned_staff_name': record.assigned_staff.full_name if record.assigned_staff else None,
|
||||
'reported_by': record.reported_by,
|
||||
'reported_by_name': reported_by_name,
|
||||
'priority': record.priority,
|
||||
'blocks_room': record.blocks_room,
|
||||
'estimated_cost': float(record.estimated_cost) if record.estimated_cost else None,
|
||||
'actual_cost': float(record.actual_cost) if record.actual_cost else None,
|
||||
'created_at': record.created_at.isoformat() if record.created_at else None
|
||||
'notes': record.notes,
|
||||
'completion_notes': record.completion_notes if hasattr(record, 'completion_notes') else None,
|
||||
'created_at': record.created_at.isoformat() if record.created_at else None,
|
||||
'updated_at': record.updated_at.isoformat() if record.updated_at else None,
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -184,16 +207,34 @@ async def get_maintenance_records(
|
||||
@router.post('/maintenance')
|
||||
async def create_maintenance_record(
|
||||
maintenance_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new maintenance record"""
|
||||
try:
|
||||
# Check user role - housekeeping users can only report issues, not create full maintenance records
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_housekeeping = role and role.name == 'housekeeping'
|
||||
|
||||
room = db.query(Room).filter(Room.id == maintenance_data.get('room_id')).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail='Room not found')
|
||||
|
||||
scheduled_start = datetime.fromisoformat(maintenance_data['scheduled_start'].replace('Z', '+00:00'))
|
||||
# For housekeeping users, set defaults for quick issue reporting
|
||||
if is_housekeeping:
|
||||
# Housekeeping users can only create corrective/emergency maintenance
|
||||
maintenance_type = maintenance_data.get('maintenance_type', 'corrective')
|
||||
if maintenance_type not in ['corrective', 'emergency']:
|
||||
maintenance_type = 'corrective'
|
||||
maintenance_data['maintenance_type'] = maintenance_type
|
||||
# Default to high priority for housekeeping-reported issues
|
||||
if 'priority' not in maintenance_data:
|
||||
maintenance_data['priority'] = 'high'
|
||||
# Default to blocking room
|
||||
if 'blocks_room' not in maintenance_data:
|
||||
maintenance_data['blocks_room'] = True
|
||||
|
||||
scheduled_start = datetime.fromisoformat(maintenance_data.get('scheduled_start', datetime.utcnow().isoformat()).replace('Z', '+00:00'))
|
||||
scheduled_end = None
|
||||
if maintenance_data.get('scheduled_end'):
|
||||
scheduled_end = datetime.fromisoformat(maintenance_data['scheduled_end'].replace('Z', '+00:00'))
|
||||
@@ -357,12 +398,13 @@ async def get_housekeeping_tasks(
|
||||
is_admin = role and role.name == 'admin'
|
||||
is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff')
|
||||
|
||||
query = db.query(HousekeepingTask)
|
||||
# Build base query for filtering
|
||||
base_query = db.query(HousekeepingTask)
|
||||
|
||||
# Filter by assigned_to for housekeeping and staff users (not admin)
|
||||
# But also include unassigned tasks so they can pick them up
|
||||
if is_housekeeping_or_staff:
|
||||
query = query.filter(
|
||||
base_query = base_query.filter(
|
||||
or_(
|
||||
HousekeepingTask.assigned_to == current_user.id,
|
||||
HousekeepingTask.assigned_to.is_(None)
|
||||
@@ -370,17 +412,31 @@ async def get_housekeeping_tasks(
|
||||
)
|
||||
|
||||
if room_id:
|
||||
query = query.filter(HousekeepingTask.room_id == room_id)
|
||||
base_query = base_query.filter(HousekeepingTask.room_id == room_id)
|
||||
if status:
|
||||
query = query.filter(HousekeepingTask.status == HousekeepingStatus(status))
|
||||
base_query = base_query.filter(HousekeepingTask.status == HousekeepingStatus(status))
|
||||
if task_type:
|
||||
query = query.filter(HousekeepingTask.task_type == HousekeepingType(task_type))
|
||||
base_query = base_query.filter(HousekeepingTask.task_type == HousekeepingType(task_type))
|
||||
if date:
|
||||
date_obj = datetime.fromisoformat(date.replace('Z', '+00:00')).date()
|
||||
query = query.filter(func.date(HousekeepingTask.scheduled_time) == date_obj)
|
||||
try:
|
||||
# Handle different date formats
|
||||
if 'T' in date:
|
||||
date_obj = datetime.fromisoformat(date.replace('Z', '+00:00')).date()
|
||||
else:
|
||||
date_obj = datetime.strptime(date, '%Y-%m-%d').date()
|
||||
base_query = base_query.filter(func.date(HousekeepingTask.scheduled_time) == date_obj)
|
||||
except (ValueError, AttributeError) as date_error:
|
||||
logger.error(f'Error parsing date {date}: {str(date_error)}')
|
||||
raise HTTPException(status_code=400, detail=f'Invalid date format: {date}')
|
||||
|
||||
total = query.count()
|
||||
query = query.order_by(HousekeepingTask.scheduled_time)
|
||||
# Get count before adding joins (to avoid duplicate counting)
|
||||
total = base_query.count()
|
||||
|
||||
# Add eager loading and ordering for the actual data query
|
||||
query = base_query.options(
|
||||
joinedload(HousekeepingTask.room),
|
||||
joinedload(HousekeepingTask.assigned_staff)
|
||||
).order_by(HousekeepingTask.scheduled_time)
|
||||
|
||||
offset = (page - 1) * limit
|
||||
tasks = query.offset(offset).limit(limit).all()
|
||||
@@ -390,26 +446,43 @@ async def get_housekeeping_tasks(
|
||||
|
||||
# Process existing tasks
|
||||
for task in tasks:
|
||||
task_room_ids.add(task.room_id)
|
||||
result.append({
|
||||
'id': task.id,
|
||||
'room_id': task.room_id,
|
||||
'room_number': task.room.room_number if task.room else None,
|
||||
'booking_id': task.booking_id,
|
||||
'task_type': task.task_type.value,
|
||||
'status': task.status.value,
|
||||
'scheduled_time': task.scheduled_time.isoformat() if task.scheduled_time else None,
|
||||
'started_at': task.started_at.isoformat() if task.started_at else None,
|
||||
'completed_at': task.completed_at.isoformat() if task.completed_at else None,
|
||||
'assigned_to': task.assigned_to,
|
||||
'assigned_staff_name': task.assigned_staff.full_name if task.assigned_staff else None,
|
||||
'checklist_items': task.checklist_items,
|
||||
'notes': task.notes,
|
||||
'quality_score': task.quality_score,
|
||||
'estimated_duration_minutes': task.estimated_duration_minutes,
|
||||
'actual_duration_minutes': task.actual_duration_minutes,
|
||||
'room_status': task.room.status.value if task.room else None
|
||||
})
|
||||
try:
|
||||
task_room_ids.add(task.room_id)
|
||||
|
||||
# Safely get room status
|
||||
room_status = None
|
||||
if task.room and hasattr(task.room, 'status') and task.room.status:
|
||||
room_status = task.room.status.value if hasattr(task.room.status, 'value') else str(task.room.status)
|
||||
|
||||
# Safely get assigned staff name
|
||||
assigned_staff_name = None
|
||||
if task.assigned_staff and hasattr(task.assigned_staff, 'full_name'):
|
||||
assigned_staff_name = task.assigned_staff.full_name
|
||||
|
||||
result.append({
|
||||
'id': task.id,
|
||||
'room_id': task.room_id,
|
||||
'room_number': task.room.room_number if task.room and hasattr(task.room, 'room_number') else None,
|
||||
'booking_id': task.booking_id,
|
||||
'task_type': task.task_type.value if hasattr(task.task_type, 'value') else str(task.task_type),
|
||||
'status': task.status.value if hasattr(task.status, 'value') else str(task.status),
|
||||
'scheduled_time': task.scheduled_time.isoformat() if task.scheduled_time else None,
|
||||
'started_at': task.started_at.isoformat() if task.started_at else None,
|
||||
'completed_at': task.completed_at.isoformat() if task.completed_at else None,
|
||||
'assigned_to': task.assigned_to,
|
||||
'assigned_staff_name': assigned_staff_name,
|
||||
'checklist_items': task.checklist_items if task.checklist_items else [],
|
||||
'notes': task.notes,
|
||||
'quality_score': task.quality_score,
|
||||
'estimated_duration_minutes': task.estimated_duration_minutes,
|
||||
'actual_duration_minutes': task.actual_duration_minutes,
|
||||
'room_status': room_status,
|
||||
'photos': task.photos if task.photos else []
|
||||
})
|
||||
except Exception as task_error:
|
||||
logger.error(f'Error processing task {task.id if task else "unknown"}: {str(task_error)}', exc_info=True)
|
||||
# Continue with next task instead of failing completely
|
||||
continue
|
||||
|
||||
# Include rooms in cleaning status that don't have tasks (or have unassigned tasks for housekeeping users)
|
||||
if include_cleaning_rooms:
|
||||
@@ -478,7 +551,8 @@ async def get_housekeeping_tasks(
|
||||
'estimated_duration_minutes': None,
|
||||
'actual_duration_minutes': None,
|
||||
'room_status': room.status.value,
|
||||
'is_room_status_only': True # Flag to indicate this is from room status, not a task
|
||||
'is_room_status_only': True, # Flag to indicate this is from room status, not a task
|
||||
'photos': []
|
||||
})
|
||||
|
||||
# Update total count to include cleaning rooms
|
||||
@@ -498,7 +572,8 @@ async def get_housekeeping_tasks(
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error(f'Error fetching housekeeping tasks: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='Failed to fetch housekeeping tasks')
|
||||
|
||||
|
||||
@router.post('/housekeeping')
|
||||
@@ -709,6 +784,8 @@ async def update_housekeeping_task(
|
||||
task.inspected_at = datetime.utcnow()
|
||||
if 'inspection_notes' in task_data:
|
||||
task.inspection_notes = task_data['inspection_notes']
|
||||
if 'photos' in task_data:
|
||||
task.photos = task_data['photos']
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
@@ -746,6 +823,177 @@ async def update_housekeeping_task(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/housekeeping/{task_id}/upload-photo')
|
||||
async def upload_housekeeping_task_photo(
|
||||
task_id: int,
|
||||
request: Request,
|
||||
image: UploadFile = File(...),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Upload a photo for a housekeeping task"""
|
||||
try:
|
||||
task = db.query(HousekeepingTask).filter(HousekeepingTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail='Housekeeping task not found')
|
||||
|
||||
# Check permissions - housekeeping users can only upload photos to their assigned tasks
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff')
|
||||
|
||||
if is_housekeeping_or_staff:
|
||||
if task.assigned_to != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='You can only upload photos to tasks assigned to you')
|
||||
|
||||
# Validate and process image
|
||||
from ...shared.utils.file_validation import validate_uploaded_image
|
||||
from ...shared.config.settings import settings
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
max_size = 5 * 1024 * 1024 # 5MB
|
||||
content = await validate_uploaded_image(image, max_size)
|
||||
|
||||
# Optimize image
|
||||
img = Image.open(io.BytesIO(content))
|
||||
if img.mode in ('RGBA', 'LA', 'P'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Generate unique filename
|
||||
file_ext = Path(image.filename).suffix.lower() if image.filename else '.jpg'
|
||||
if file_ext not in ['.jpg', '.jpeg', '.png', '.webp']:
|
||||
file_ext = '.jpg'
|
||||
|
||||
filename = f"housekeeping_task_{task_id}_{uuid.uuid4().hex[:8]}{file_ext}"
|
||||
upload_dir = Path(__file__).parent.parent.parent.parent / 'uploads' / 'housekeeping'
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_path = upload_dir / filename
|
||||
|
||||
# Save optimized image
|
||||
img.save(file_path, 'JPEG', quality=85, optimize=True)
|
||||
|
||||
image_url = f'/uploads/housekeeping/{filename}'
|
||||
|
||||
# Update task photos
|
||||
if task.photos is None:
|
||||
task.photos = []
|
||||
if not isinstance(task.photos, list):
|
||||
task.photos = []
|
||||
|
||||
task.photos.append(image_url)
|
||||
db.commit()
|
||||
|
||||
# Get full URL
|
||||
base_url = str(request.base_url).rstrip('/')
|
||||
full_url = f"{base_url}{image_url}"
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Photo uploaded successfully',
|
||||
'data': {
|
||||
'photo_url': image_url,
|
||||
'full_url': full_url,
|
||||
'photos': task.photos
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f'Error uploading housekeeping task photo: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f'Error uploading photo: {str(e)}')
|
||||
|
||||
|
||||
@router.post('/housekeeping/{task_id}/report-maintenance-issue')
|
||||
async def report_maintenance_issue_from_task(
|
||||
task_id: int,
|
||||
issue_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Quick maintenance issue reporting from housekeeping task"""
|
||||
try:
|
||||
task = db.query(HousekeepingTask).filter(HousekeepingTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail='Housekeeping task not found')
|
||||
|
||||
# Check permissions - housekeeping users can only report issues for their assigned tasks
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_housekeeping = role and role.name == 'housekeeping'
|
||||
|
||||
if is_housekeeping:
|
||||
if task.assigned_to != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='You can only report issues for tasks assigned to you')
|
||||
|
||||
room = db.query(Room).filter(Room.id == task.room_id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail='Room not found')
|
||||
|
||||
# Create maintenance record
|
||||
title = issue_data.get('title', f'Issue reported from Room {room.room_number}')
|
||||
description = issue_data.get('description', '')
|
||||
if task.notes:
|
||||
description = f"Reported from housekeeping task.\n\nTask Notes: {task.notes}\n\nIssue Description: {description}".strip()
|
||||
else:
|
||||
description = f"Reported from housekeeping task.\n\nIssue Description: {description}".strip()
|
||||
|
||||
maintenance = RoomMaintenance(
|
||||
room_id=task.room_id,
|
||||
maintenance_type=MaintenanceType(issue_data.get('maintenance_type', 'corrective')),
|
||||
status=MaintenanceStatus('scheduled'),
|
||||
title=title,
|
||||
description=description,
|
||||
scheduled_start=datetime.utcnow(),
|
||||
assigned_to=None, # Will be assigned by admin/staff
|
||||
reported_by=current_user.id,
|
||||
priority=issue_data.get('priority', 'high'),
|
||||
blocks_room=issue_data.get('blocks_room', True),
|
||||
notes=issue_data.get('notes', f'Reported from housekeeping task #{task_id}')
|
||||
)
|
||||
|
||||
# Update room status if blocking
|
||||
if maintenance.blocks_room and room.status == RoomStatus.available:
|
||||
room.status = RoomStatus.maintenance
|
||||
|
||||
db.add(maintenance)
|
||||
db.commit()
|
||||
db.refresh(maintenance)
|
||||
|
||||
# Send notification to admin/staff
|
||||
try:
|
||||
from ...notifications.routes.notification_routes import notification_manager
|
||||
notification_data = {
|
||||
'type': 'maintenance_request_created',
|
||||
'data': {
|
||||
'maintenance_id': maintenance.id,
|
||||
'room_id': room.id,
|
||||
'room_number': room.room_number,
|
||||
'title': maintenance.title,
|
||||
'priority': maintenance.priority,
|
||||
'reported_by': current_user.full_name,
|
||||
'reported_at': maintenance.created_at.isoformat() if maintenance.created_at else None
|
||||
}
|
||||
}
|
||||
# Send to admin and staff roles
|
||||
await notification_manager.send_to_role('admin', notification_data)
|
||||
await notification_manager.send_to_role('staff', notification_data)
|
||||
except Exception as e:
|
||||
logger.error(f'Error sending maintenance notification: {str(e)}', exc_info=True)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Maintenance issue reported successfully',
|
||||
'data': {'maintenance_id': maintenance.id}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f'Error reporting maintenance issue: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f'Error reporting issue: {str(e)}')
|
||||
|
||||
|
||||
# ==================== Room Inspections ====================
|
||||
|
||||
@router.get('/inspections')
|
||||
@@ -755,19 +1003,19 @@ async def get_room_inspections(
|
||||
status: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get room inspections with filtering"""
|
||||
try:
|
||||
# Check if user is staff (not admin) - staff should only see their assigned inspections
|
||||
# Check if user is staff or housekeeping (not admin) - they should only see their assigned inspections
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_staff = role and role.name == 'staff'
|
||||
is_staff_or_housekeeping = role and role.name in ('staff', 'housekeeping')
|
||||
|
||||
query = db.query(RoomInspection)
|
||||
|
||||
# Filter by inspected_by for staff users
|
||||
if is_staff:
|
||||
# Filter by inspected_by for staff and housekeeping users
|
||||
if is_staff_or_housekeeping:
|
||||
query = query.filter(RoomInspection.inspected_by == current_user.id)
|
||||
|
||||
if room_id:
|
||||
@@ -824,16 +1072,27 @@ async def get_room_inspections(
|
||||
@router.post('/inspections')
|
||||
async def create_room_inspection(
|
||||
inspection_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new room inspection"""
|
||||
try:
|
||||
# Check user role - housekeeping users can only create inspections for themselves
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_housekeeping = role and role.name == 'housekeeping'
|
||||
|
||||
room = db.query(Room).filter(Room.id == inspection_data.get('room_id')).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail='Room not found')
|
||||
|
||||
scheduled_at = datetime.fromisoformat(inspection_data['scheduled_at'].replace('Z', '+00:00'))
|
||||
inspected_by = inspection_data.get('inspected_by')
|
||||
|
||||
# Housekeeping users can only assign inspections to themselves
|
||||
if is_housekeeping:
|
||||
if inspected_by and inspected_by != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Housekeeping users can only create inspections for themselves')
|
||||
inspected_by = current_user.id
|
||||
|
||||
inspection = RoomInspection(
|
||||
room_id=inspection_data['room_id'],
|
||||
@@ -841,7 +1100,7 @@ async def create_room_inspection(
|
||||
inspection_type=InspectionType(inspection_data.get('inspection_type', 'routine')),
|
||||
status=InspectionStatus(inspection_data.get('status', 'pending')),
|
||||
scheduled_at=scheduled_at,
|
||||
inspected_by=inspection_data.get('inspected_by'),
|
||||
inspected_by=inspected_by,
|
||||
created_by=current_user.id,
|
||||
checklist_items=inspection_data.get('checklist_items', []),
|
||||
checklist_template_id=inspection_data.get('checklist_template_id')
|
||||
@@ -865,7 +1124,7 @@ async def create_room_inspection(
|
||||
async def update_room_inspection(
|
||||
inspection_id: int,
|
||||
inspection_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a room inspection"""
|
||||
@@ -874,16 +1133,16 @@ async def update_room_inspection(
|
||||
if not inspection:
|
||||
raise HTTPException(status_code=404, detail='Room inspection not found')
|
||||
|
||||
# Check if user is staff (not admin) - staff can only update their own assigned inspections
|
||||
# Check if user is staff or housekeeping (not admin) - they can only update their own assigned inspections
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_staff = role and role.name == 'staff'
|
||||
is_staff_or_housekeeping = role and role.name in ('staff', 'housekeeping')
|
||||
|
||||
if is_staff:
|
||||
# Staff can only update inspections assigned to them
|
||||
if is_staff_or_housekeeping:
|
||||
# Staff and housekeeping can only update inspections assigned to them
|
||||
if inspection.inspected_by != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='You can only update inspections assigned to you')
|
||||
# Staff can only update status and inspection results
|
||||
allowed_fields = {'status', 'checklist_items', 'overall_score', 'overall_notes', 'issues_found', 'requires_followup', 'followup_notes'}
|
||||
# Staff and housekeeping can only update status and inspection results
|
||||
allowed_fields = {'status', 'checklist_items', 'overall_score', 'overall_notes', 'issues_found', 'requires_followup', 'followup_notes', 'photos'}
|
||||
if any(key not in allowed_fields for key in inspection_data.keys()):
|
||||
raise HTTPException(status_code=403, detail='You can only update status and inspection results')
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from ...shared.config.logging_config import get_logger
|
||||
from ...security.middleware.auth import get_current_user, authorize_roles
|
||||
from ...auth.models.user import User
|
||||
from ..models.room import Room, RoomStatus
|
||||
from ...analytics.services.audit_service import audit_service
|
||||
from ..models.room_type import RoomType
|
||||
from ..schemas.room import CreateRoomRequest, UpdateRoomRequest, BulkDeleteRoomsRequest, UpdateAmenityRequest
|
||||
from ...shared.utils.response_helpers import success_response
|
||||
@@ -522,13 +523,67 @@ async def update_room(id: int, room_data: UpdateRoomRequest, request: Request, c
|
||||
raise HTTPException(status_code=500, detail='An error occurred while updating the room')
|
||||
|
||||
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
|
||||
async def delete_room(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
async def delete_room(id: int, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
room = db.query(Room).filter(Room.id == id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail='Room not found')
|
||||
|
||||
# SECURITY: Check for active bookings before deletion
|
||||
from ...bookings.models.booking import Booking, BookingStatus
|
||||
active_bookings = db.query(Booking).filter(
|
||||
Booking.room_id == id,
|
||||
Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])
|
||||
).count()
|
||||
|
||||
if active_bookings > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Cannot delete room with {active_bookings} active booking(s). Please cancel or complete bookings first.'
|
||||
)
|
||||
|
||||
# Check for historical bookings - prevent deletion to maintain data integrity
|
||||
historical_bookings = db.query(Booking).filter(Booking.room_id == id).count()
|
||||
if historical_bookings > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Cannot delete room with {historical_bookings} historical booking(s). Consider marking the room as inactive or under maintenance instead.'
|
||||
)
|
||||
|
||||
# Capture room info before deletion for audit
|
||||
deleted_room_info = {
|
||||
'room_id': room.id,
|
||||
'room_number': room.room_number,
|
||||
'floor': room.floor,
|
||||
'room_type_id': room.room_type_id,
|
||||
'status': room.status.value if isinstance(room.status, RoomStatus) else str(room.status),
|
||||
'price': float(room.price) if room.price else None,
|
||||
}
|
||||
|
||||
db.delete(room)
|
||||
db.commit()
|
||||
|
||||
# SECURITY: Log room deletion for audit trail
|
||||
try:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='room_deleted',
|
||||
resource_type='room',
|
||||
user_id=current_user.id,
|
||||
resource_id=id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details=deleted_room_info,
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log room deletion audit: {e}')
|
||||
|
||||
return {'status': 'success', 'message': 'Room deleted successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -537,8 +592,11 @@ async def delete_room(id: int, current_user: User=Depends(authorize_roles('admin
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/bulk-delete', dependencies=[Depends(authorize_roles('admin'))])
|
||||
async def bulk_delete_rooms(room_ids: BulkDeleteRoomsRequest, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
async def bulk_delete_rooms(room_ids: BulkDeleteRoomsRequest, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
|
||||
"""Bulk delete rooms with validated input using Pydantic schema."""
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
try:
|
||||
ids = room_ids.room_ids
|
||||
|
||||
@@ -550,11 +608,72 @@ async def bulk_delete_rooms(room_ids: BulkDeleteRoomsRequest, current_user: User
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=404, detail=f'Rooms with IDs {not_found_ids} not found')
|
||||
|
||||
# SECURITY: Check for active bookings before deletion
|
||||
from ...bookings.models.booking import Booking, BookingStatus
|
||||
rooms_with_active_bookings = db.query(Booking.room_id).filter(
|
||||
Booking.room_id.in_(ids),
|
||||
Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])
|
||||
).distinct().all()
|
||||
|
||||
if rooms_with_active_bookings:
|
||||
room_ids_with_bookings = [r[0] for r in rooms_with_active_bookings]
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Cannot delete rooms with IDs {room_ids_with_bookings} - they have active bookings. Please cancel or complete bookings first.'
|
||||
)
|
||||
|
||||
# Check for historical bookings - prevent deletion to maintain data integrity
|
||||
rooms_with_historical_bookings = db.query(Booking.room_id).filter(
|
||||
Booking.room_id.in_(ids)
|
||||
).distinct().all()
|
||||
|
||||
if rooms_with_historical_bookings:
|
||||
room_ids_with_bookings = [r[0] for r in rooms_with_historical_bookings]
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Cannot delete rooms with IDs {room_ids_with_bookings} - they have historical bookings. Consider marking them as inactive or under maintenance instead.'
|
||||
)
|
||||
|
||||
# Capture room info before deletion for audit
|
||||
deleted_rooms_info = []
|
||||
for room in rooms:
|
||||
deleted_rooms_info.append({
|
||||
'room_id': room.id,
|
||||
'room_number': room.room_number,
|
||||
'floor': room.floor,
|
||||
'room_type_id': room.room_type_id,
|
||||
'status': room.status.value if isinstance(room.status, RoomStatus) else str(room.status),
|
||||
})
|
||||
|
||||
# Delete rooms
|
||||
deleted_count = db.query(Room).filter(Room.id.in_(ids)).delete(synchronize_session=False)
|
||||
|
||||
# Commit transaction
|
||||
db.commit()
|
||||
|
||||
# SECURITY: Log bulk room deletion for audit trail
|
||||
try:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='rooms_bulk_deleted',
|
||||
resource_type='room',
|
||||
user_id=current_user.id,
|
||||
resource_id=None, # Multiple resources
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details={
|
||||
'deleted_count': deleted_count,
|
||||
'deleted_room_ids': ids,
|
||||
'deleted_rooms': deleted_rooms_info
|
||||
},
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log bulk room deletion audit: {e}')
|
||||
|
||||
return success_response(
|
||||
data={'deleted_count': deleted_count, 'deleted_ids': ids},
|
||||
message=f'Successfully deleted {deleted_count} room(s)'
|
||||
|
||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
from fastapi import Depends, HTTPException, status, Request, Cookie
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import os
|
||||
@@ -103,7 +103,8 @@ def get_current_user(
|
||||
except ValueError as e:
|
||||
# JWT secret configuration error - should not happen in production
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Server configuration error')
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
# PERFORMANCE: Eager load role relationship to avoid N+1 queries in authorize_roles
|
||||
user = db.query(User).options(joinedload(User.role)).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
@@ -127,10 +128,17 @@ def get_current_user(
|
||||
def authorize_roles(*allowed_roles: str):
|
||||
|
||||
def role_checker(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)) -> User:
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='User role not found')
|
||||
user_role_name = role.name
|
||||
# PERFORMANCE: Use eager-loaded relationship if available, otherwise query
|
||||
# This reduces database queries since get_current_user now eager loads the role
|
||||
if hasattr(current_user, 'role') and current_user.role is not None:
|
||||
user_role_name = current_user.role.name
|
||||
else:
|
||||
# Fallback: query role if relationship wasn't loaded (shouldn't happen, but safe)
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='User role not found')
|
||||
user_role_name = role.name
|
||||
|
||||
if user_role_name not in allowed_roles:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='You do not have permission to access this resource')
|
||||
return current_user
|
||||
@@ -168,7 +176,8 @@ def get_current_user_optional(
|
||||
except (JWTError, ValueError):
|
||||
return None
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
# PERFORMANCE: Eager load role relationship for consistency
|
||||
user = db.query(User).options(joinedload(User.role)).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
|
||||
Binary file not shown.
@@ -10,6 +10,10 @@ def get_user_role_name(user: User, db: Session) -> str:
|
||||
if not user or not user.role_id:
|
||||
return 'customer'
|
||||
try:
|
||||
# PERFORMANCE: Use eager-loaded relationship if available to avoid query
|
||||
if hasattr(user, 'role') and user.role is not None:
|
||||
return user.role.name
|
||||
# Fallback: query if relationship not loaded
|
||||
role = db.query(Role).filter(Role.id == user.role_id).first()
|
||||
return role.name if role else 'customer'
|
||||
except Exception:
|
||||
@@ -31,6 +35,10 @@ def is_customer(user: User, db: Session) -> bool:
|
||||
"""Check if user is customer"""
|
||||
return get_user_role_name(user, db) == 'customer'
|
||||
|
||||
def is_housekeeping(user: User, db: Session) -> bool:
|
||||
"""Check if user is housekeeping"""
|
||||
return get_user_role_name(user, db) == 'housekeeping'
|
||||
|
||||
def can_access_all_payments(user: User, db: Session) -> bool:
|
||||
"""Check if user can see all payments (admin or accountant)"""
|
||||
role_name = get_user_role_name(user, db)
|
||||
|
||||
Binary file not shown.
@@ -15,6 +15,7 @@ from ...auth.models.user import User
|
||||
from ..models.system_settings import SystemSettings
|
||||
from ...shared.utils.mailer import send_email
|
||||
from ...rooms.services.room_service import get_base_url
|
||||
from ...analytics.services.audit_service import audit_service
|
||||
|
||||
def normalize_image_url(image_url: str, base_url: str) -> str:
|
||||
if not image_url:
|
||||
@@ -63,9 +64,14 @@ async def get_platform_currency(
|
||||
@router.put("/currency")
|
||||
async def update_platform_currency(
|
||||
currency_data: dict,
|
||||
request: Request,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
currency = currency_data.get("currency", "").upper()
|
||||
|
||||
@@ -81,6 +87,8 @@ async def update_platform_currency(
|
||||
SystemSettings.key == "platform_currency"
|
||||
).first()
|
||||
|
||||
old_value = setting.value if setting else None
|
||||
|
||||
if setting:
|
||||
setting.value = currency
|
||||
setting.updated_by_id = current_user.id
|
||||
@@ -96,6 +104,27 @@ async def update_platform_currency(
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
|
||||
# SECURITY: Log system setting change for audit trail
|
||||
try:
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='system_setting_changed',
|
||||
resource_type='system_settings',
|
||||
user_id=current_user.id,
|
||||
resource_id=setting.id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details={
|
||||
'setting_key': 'platform_currency',
|
||||
'old_value': old_value,
|
||||
'new_value': currency
|
||||
},
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log system setting change audit: {e}')
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Platform currency updated successfully",
|
||||
@@ -199,9 +228,13 @@ async def get_stripe_settings(
|
||||
@router.put("/stripe")
|
||||
async def update_stripe_settings(
|
||||
stripe_data: dict,
|
||||
request: Request,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
try:
|
||||
secret_key = stripe_data.get("stripe_secret_key", "").strip()
|
||||
publishable_key = stripe_data.get("stripe_publishable_key", "").strip()
|
||||
@@ -229,11 +262,16 @@ async def update_stripe_settings(
|
||||
)
|
||||
|
||||
|
||||
settings_changed = []
|
||||
old_values = {}
|
||||
|
||||
if secret_key:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "stripe_secret_key"
|
||||
).first()
|
||||
|
||||
old_values['stripe_secret_key'] = setting.value if setting else None
|
||||
|
||||
if setting:
|
||||
setting.value = secret_key
|
||||
setting.updated_by_id = current_user.id
|
||||
@@ -245,6 +283,7 @@ async def update_stripe_settings(
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
settings_changed.append('stripe_secret_key')
|
||||
|
||||
|
||||
if publishable_key:
|
||||
@@ -252,6 +291,8 @@ async def update_stripe_settings(
|
||||
SystemSettings.key == "stripe_publishable_key"
|
||||
).first()
|
||||
|
||||
old_values['stripe_publishable_key'] = setting.value if setting else None
|
||||
|
||||
if setting:
|
||||
setting.value = publishable_key
|
||||
setting.updated_by_id = current_user.id
|
||||
@@ -263,6 +304,7 @@ async def update_stripe_settings(
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
settings_changed.append('stripe_publishable_key')
|
||||
|
||||
|
||||
if webhook_secret:
|
||||
@@ -270,6 +312,8 @@ async def update_stripe_settings(
|
||||
SystemSettings.key == "stripe_webhook_secret"
|
||||
).first()
|
||||
|
||||
old_values['stripe_webhook_secret'] = setting.value if setting else None
|
||||
|
||||
if setting:
|
||||
setting.value = webhook_secret
|
||||
setting.updated_by_id = current_user.id
|
||||
@@ -281,9 +325,44 @@ async def update_stripe_settings(
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
settings_changed.append('stripe_webhook_secret')
|
||||
|
||||
db.commit()
|
||||
|
||||
# SECURITY: Log payment gateway configuration change for audit trail
|
||||
if settings_changed:
|
||||
try:
|
||||
def mask_key(key_value: str) -> str:
|
||||
if not key_value or len(key_value) < 4:
|
||||
return None
|
||||
return "*" * (len(key_value) - 4) + key_value[-4:]
|
||||
|
||||
masked_old = {k: mask_key(v) if v else None for k, v in old_values.items() if k in settings_changed}
|
||||
masked_new = {k: mask_key(v) if v else None for k, v in {
|
||||
'stripe_secret_key': secret_key if secret_key else None,
|
||||
'stripe_publishable_key': publishable_key if publishable_key else None,
|
||||
'stripe_webhook_secret': webhook_secret if webhook_secret else None
|
||||
}.items() if k in settings_changed}
|
||||
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='payment_gateway_config_changed',
|
||||
resource_type='system_settings',
|
||||
user_id=current_user.id,
|
||||
resource_id=None,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details={
|
||||
'gateway': 'stripe',
|
||||
'settings_changed': settings_changed,
|
||||
'old_values': masked_old,
|
||||
'new_values': masked_new
|
||||
},
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log payment gateway config change audit: {e}')
|
||||
|
||||
def mask_key(key_value: str) -> str:
|
||||
if not key_value or len(key_value) < 4:
|
||||
@@ -367,9 +446,14 @@ async def get_paypal_settings(
|
||||
@router.put("/paypal")
|
||||
async def update_paypal_settings(
|
||||
paypal_data: dict,
|
||||
request: Request,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
client_id = paypal_data.get("paypal_client_id", "").strip()
|
||||
client_secret = paypal_data.get("paypal_client_secret", "").strip()
|
||||
@@ -382,6 +466,21 @@ async def update_paypal_settings(
|
||||
detail="Invalid PayPal mode. Must be 'sandbox' or 'live'"
|
||||
)
|
||||
|
||||
settings_changed = []
|
||||
old_values = {}
|
||||
|
||||
# Get old values before updating
|
||||
if client_id:
|
||||
old_setting = db.query(SystemSettings).filter(SystemSettings.key == "paypal_client_id").first()
|
||||
old_values['paypal_client_id'] = old_setting.value if old_setting else None
|
||||
|
||||
if client_secret:
|
||||
old_setting = db.query(SystemSettings).filter(SystemSettings.key == "paypal_client_secret").first()
|
||||
old_values['paypal_client_secret'] = old_setting.value if old_setting else None
|
||||
|
||||
if mode:
|
||||
old_setting = db.query(SystemSettings).filter(SystemSettings.key == "paypal_mode").first()
|
||||
old_values['paypal_mode'] = old_setting.value if old_setting else None
|
||||
|
||||
if client_id:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
@@ -399,6 +498,7 @@ async def update_paypal_settings(
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
settings_changed.append('paypal_client_id')
|
||||
|
||||
|
||||
if client_secret:
|
||||
@@ -417,6 +517,7 @@ async def update_paypal_settings(
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
settings_changed.append('paypal_client_secret')
|
||||
|
||||
|
||||
if mode:
|
||||
@@ -435,9 +536,44 @@ async def update_paypal_settings(
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
settings_changed.append('paypal_mode')
|
||||
|
||||
db.commit()
|
||||
|
||||
# SECURITY: Log payment gateway configuration change for audit trail
|
||||
if settings_changed:
|
||||
try:
|
||||
def mask_key(key_value: str) -> str:
|
||||
if not key_value or len(key_value) < 4:
|
||||
return None
|
||||
return "*" * (len(key_value) - 4) + key_value[-4:]
|
||||
|
||||
masked_old = {k: mask_key(v) if v else None for k, v in old_values.items() if k in settings_changed}
|
||||
masked_new = {k: mask_key(v) if v else None for k, v in {
|
||||
'paypal_client_id': client_id if client_id else None,
|
||||
'paypal_client_secret': client_secret if client_secret else None,
|
||||
'paypal_mode': mode if mode else None
|
||||
}.items() if k in settings_changed}
|
||||
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='payment_gateway_config_changed',
|
||||
resource_type='system_settings',
|
||||
user_id=current_user.id,
|
||||
resource_id=None,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details={
|
||||
'gateway': 'paypal',
|
||||
'settings_changed': settings_changed,
|
||||
'old_values': masked_old,
|
||||
'new_values': masked_new
|
||||
},
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log payment gateway config change audit: {e}')
|
||||
|
||||
def mask_key(key_value: str) -> str:
|
||||
if not key_value or len(key_value) < 4:
|
||||
@@ -548,9 +684,14 @@ async def get_borica_settings(
|
||||
@router.put("/borica")
|
||||
async def update_borica_settings(
|
||||
borica_data: dict,
|
||||
request: Request,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
terminal_id = borica_data.get("borica_terminal_id", "").strip()
|
||||
merchant_id = borica_data.get("borica_merchant_id", "").strip()
|
||||
@@ -565,6 +706,20 @@ async def update_borica_settings(
|
||||
detail="Invalid Borica mode. Must be 'test' or 'production'"
|
||||
)
|
||||
|
||||
settings_changed = []
|
||||
old_values = {}
|
||||
|
||||
# Get old values before updating
|
||||
if terminal_id:
|
||||
old_setting = db.query(SystemSettings).filter(SystemSettings.key == "borica_terminal_id").first()
|
||||
old_values['borica_terminal_id'] = old_setting.value if old_setting else None
|
||||
if merchant_id:
|
||||
old_setting = db.query(SystemSettings).filter(SystemSettings.key == "borica_merchant_id").first()
|
||||
old_values['borica_merchant_id'] = old_setting.value if old_setting else None
|
||||
if mode:
|
||||
old_setting = db.query(SystemSettings).filter(SystemSettings.key == "borica_mode").first()
|
||||
old_values['borica_mode'] = old_setting.value if old_setting else None
|
||||
|
||||
if terminal_id:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "borica_terminal_id"
|
||||
@@ -897,9 +1052,14 @@ async def get_smtp_settings(
|
||||
@router.put("/smtp")
|
||||
async def update_smtp_settings(
|
||||
smtp_data: dict,
|
||||
request: Request,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
request_id = getattr(request.state, 'request_id', None)
|
||||
|
||||
try:
|
||||
smtp_host = smtp_data.get("smtp_host", "").strip()
|
||||
smtp_port = smtp_data.get("smtp_port", "").strip()
|
||||
@@ -938,6 +1098,14 @@ async def update_smtp_settings(
|
||||
detail="Invalid email address format for 'From Email'"
|
||||
)
|
||||
|
||||
settings_changed = []
|
||||
old_values = {}
|
||||
|
||||
# Get old values before updating
|
||||
smtp_keys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'smtp_from_email', 'smtp_from_name', 'smtp_use_tls']
|
||||
for key in smtp_keys:
|
||||
old_setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
|
||||
old_values[key] = old_setting.value if old_setting else None
|
||||
|
||||
def update_setting(key: str, value: str, description: str):
|
||||
setting = db.query(SystemSettings).filter(
|
||||
@@ -955,6 +1123,9 @@ async def update_smtp_settings(
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
if key not in settings_changed:
|
||||
settings_changed.append(key)
|
||||
|
||||
|
||||
if smtp_host:
|
||||
@@ -1009,6 +1180,45 @@ async def update_smtp_settings(
|
||||
|
||||
db.commit()
|
||||
|
||||
# SECURITY: Log SMTP configuration change for audit trail
|
||||
if settings_changed:
|
||||
try:
|
||||
def mask_password(password_value: str) -> str:
|
||||
if not password_value or len(password_value) < 4:
|
||||
return None
|
||||
return "*" * (len(password_value) - 4) + password_value[-4:]
|
||||
|
||||
masked_old = {k: (mask_password(v) if 'password' in k.lower() else v) if v else None
|
||||
for k, v in old_values.items() if k in settings_changed}
|
||||
masked_new = {k: (mask_password(v) if 'password' in k.lower() else v) if v else None
|
||||
for k, v in {
|
||||
'smtp_host': smtp_host if smtp_host else None,
|
||||
'smtp_port': smtp_port if smtp_port else None,
|
||||
'smtp_user': smtp_user if smtp_user else None,
|
||||
'smtp_password': smtp_password if smtp_password else None,
|
||||
'smtp_from_email': smtp_from_email if smtp_from_email else None,
|
||||
'smtp_from_name': smtp_from_name if smtp_from_name else None,
|
||||
'smtp_use_tls': 'true' if smtp_use_tls else 'false' if smtp_use_tls is not None else None
|
||||
}.items() if k in settings_changed}
|
||||
|
||||
await audit_service.log_action(
|
||||
db=db,
|
||||
action='smtp_config_changed',
|
||||
resource_type='system_settings',
|
||||
user_id=current_user.id,
|
||||
resource_id=None,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
details={
|
||||
'settings_changed': settings_changed,
|
||||
'old_values': masked_old,
|
||||
'new_values': masked_new
|
||||
},
|
||||
status='success'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to log SMTP config change audit: {e}')
|
||||
|
||||
def mask_password(password_value: str) -> str:
|
||||
if not password_value or len(password_value) < 4:
|
||||
|
||||
@@ -63,6 +63,7 @@ const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
|
||||
const LoyaltyPage = lazy(() => import('./pages/customer/LoyaltyPage'));
|
||||
const GroupBookingPage = lazy(() => import('./pages/customer/GroupBookingPage'));
|
||||
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 AboutPage = lazy(() => import('./features/content/pages/AboutPage'));
|
||||
@@ -118,6 +119,10 @@ const StaffLoyaltyManagementPage = lazy(() => import('./pages/staff/LoyaltyManag
|
||||
const StaffGuestProfilePage = lazy(() => import('./pages/staff/GuestProfilePage'));
|
||||
const StaffAdvancedRoomManagementPage = lazy(() => import('./pages/staff/AdvancedRoomManagementPage'));
|
||||
const ChatManagementPage = lazy(() => import('./pages/staff/ChatManagementPage'));
|
||||
const GuestRequestManagementPage = lazy(() => import('./pages/staff/GuestRequestManagementPage'));
|
||||
const GuestCommunicationPage = lazy(() => import('./pages/staff/GuestCommunicationPage'));
|
||||
const IncidentComplaintManagementPage = lazy(() => import('./pages/staff/IncidentComplaintManagementPage'));
|
||||
const UpsellManagementPage = lazy(() => import('./pages/staff/UpsellManagementPage'));
|
||||
const StaffLayout = lazy(() => import('./pages/StaffLayout'));
|
||||
|
||||
const AccountantDashboardPage = lazy(() => import('./pages/accountant/DashboardPage'));
|
||||
@@ -452,6 +457,16 @@ function App() {
|
||||
</ErrorBoundaryRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="requests"
|
||||
element={
|
||||
<ErrorBoundaryRoute>
|
||||
<CustomerRoute>
|
||||
<GuestRequestsPage />
|
||||
</CustomerRoute>
|
||||
</ErrorBoundaryRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gdpr"
|
||||
element={
|
||||
@@ -714,6 +729,22 @@ function App() {
|
||||
path="advanced-rooms"
|
||||
element={<StaffAdvancedRoomManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="guest-requests"
|
||||
element={<GuestRequestManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="guest-communication"
|
||||
element={<GuestCommunicationPage />}
|
||||
/>
|
||||
<Route
|
||||
path="incidents-complaints"
|
||||
element={<IncidentComplaintManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="upsells"
|
||||
element={<UpsellManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="profile"
|
||||
element={<StaffProfilePage />}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import apiClient from '../../../shared/services/apiClient';
|
||||
|
||||
export interface Complaint {
|
||||
id: number;
|
||||
guest_id: number;
|
||||
guest_name?: string;
|
||||
booking_id?: number;
|
||||
room_id?: number;
|
||||
room_number?: string;
|
||||
category: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
title: string;
|
||||
description: string;
|
||||
attachments?: string[];
|
||||
assigned_to?: number;
|
||||
assigned_staff_name?: string;
|
||||
resolved_at?: string;
|
||||
resolution_notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ComplaintUpdate {
|
||||
id: number;
|
||||
complaint_id: number;
|
||||
update_type: string;
|
||||
description: string;
|
||||
updated_by: number;
|
||||
updated_by_name?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const complaintService = {
|
||||
async getComplaints(params?: {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
category?: string;
|
||||
assigned_to?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const response = await apiClient.get('/complaints', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getComplaint(complaintId: number) {
|
||||
const response = await apiClient.get(`/complaints/${complaintId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateComplaint(complaintId: number, data: {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
assigned_to?: number;
|
||||
resolution_notes?: string;
|
||||
}) {
|
||||
const response = await apiClient.put(`/complaints/${complaintId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async resolveComplaint(complaintId: number, data: {
|
||||
resolution_notes: string;
|
||||
compensation_amount?: number;
|
||||
}) {
|
||||
const response = await apiClient.post(`/complaints/${complaintId}/resolve`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async addComplaintUpdate(complaintId: number, data: {
|
||||
description: string;
|
||||
update_type?: string;
|
||||
}) {
|
||||
const response = await apiClient.post(`/complaints/${complaintId}/updates`, data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default complaintService;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import apiClient from '../../../shared/services/apiClient';
|
||||
|
||||
export interface GuestRequest {
|
||||
id: number;
|
||||
booking_id: number;
|
||||
room_id: number;
|
||||
room_number?: string;
|
||||
user_id: number;
|
||||
guest_name?: string;
|
||||
guest_email?: string;
|
||||
request_type: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
guest_notes?: string;
|
||||
staff_notes?: string;
|
||||
assigned_to?: number;
|
||||
assigned_staff_name?: string;
|
||||
fulfilled_by?: number;
|
||||
fulfilled_staff_name?: string;
|
||||
requested_at: string;
|
||||
started_at?: string;
|
||||
fulfilled_at?: string;
|
||||
response_time_minutes?: number;
|
||||
fulfillment_time_minutes?: number;
|
||||
}
|
||||
|
||||
const guestRequestService = {
|
||||
async getGuestRequests(params?: {
|
||||
status?: string;
|
||||
request_type?: string;
|
||||
room_id?: number;
|
||||
assigned_to?: number;
|
||||
priority?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const response = await apiClient.get('/guest-requests', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getGuestRequest(requestId: number) {
|
||||
const response = await apiClient.get(`/guest-requests/${requestId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createGuestRequest(data: {
|
||||
booking_id: number;
|
||||
room_id: number;
|
||||
request_type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: string;
|
||||
guest_notes?: string;
|
||||
}) {
|
||||
const response = await apiClient.post('/guest-requests', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateGuestRequest(requestId: number, data: {
|
||||
status?: string;
|
||||
assigned_to?: number;
|
||||
staff_notes?: string;
|
||||
}) {
|
||||
const response = await apiClient.put(`/guest-requests/${requestId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async assignRequest(requestId: number) {
|
||||
const response = await apiClient.post(`/guest-requests/${requestId}/assign`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async fulfillRequest(requestId: number, staff_notes?: string) {
|
||||
const response = await apiClient.post(`/guest-requests/${requestId}/fulfill`, { staff_notes });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default guestRequestService;
|
||||
|
||||
@@ -240,7 +240,7 @@ const HousekeepingManagement: React.FC = () => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingTask) {
|
||||
if (editingTask && editingTask.id) {
|
||||
// For staff, only allow updating status and checklist items
|
||||
if (!isAdmin) {
|
||||
const data = {
|
||||
|
||||
@@ -290,6 +290,7 @@ const MaintenanceManagement: React.FC = () => {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Priority</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Reported By</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Scheduled</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Assigned</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
@@ -317,6 +318,9 @@ const MaintenanceManagement: React.FC = () => {
|
||||
{record.priority}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{record.reported_by_name || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(record.scheduled_start).toLocaleDateString()}
|
||||
</td>
|
||||
@@ -620,12 +624,12 @@ const MaintenanceManagement: React.FC = () => {
|
||||
<p className="mt-1 text-sm text-gray-900">{viewingRecord.title}</p>
|
||||
</div>
|
||||
|
||||
{viewingRecord.description && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Description</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{viewingRecord.description}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Description</label>
|
||||
<p className="mt-1 text-sm text-gray-900 whitespace-pre-wrap">
|
||||
{viewingRecord.description || 'No description provided'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -642,6 +646,21 @@ const MaintenanceManagement: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{viewingRecord.reported_by_name && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Reported By</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{viewingRecord.reported_by_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{viewingRecord.assigned_staff_name && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Assigned To</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{viewingRecord.assigned_staff_name}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Scheduled Start</label>
|
||||
@@ -655,10 +674,17 @@ const MaintenanceManagement: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{viewingRecord.assigned_staff_name && (
|
||||
{viewingRecord.notes && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Assigned To</label>
|
||||
<p className="mt-1 text-sm text-gray-900">{viewingRecord.assigned_staff_name}</p>
|
||||
<label className="block text-sm font-medium text-gray-500">Notes</label>
|
||||
<p className="mt-1 text-sm text-gray-900 whitespace-pre-wrap">{viewingRecord.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewingRecord.completion_notes && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Completion Notes</label>
|
||||
<p className="mt-1 text-sm text-gray-900 whitespace-pre-wrap">{viewingRecord.completion_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
140
Frontend/src/features/inventory/services/inventoryService.ts
Normal file
140
Frontend/src/features/inventory/services/inventoryService.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import apiClient from '../../../shared/services/apiClient';
|
||||
|
||||
export interface InventoryItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
unit: string;
|
||||
current_quantity: number;
|
||||
minimum_quantity: number;
|
||||
maximum_quantity?: number;
|
||||
reorder_quantity?: number;
|
||||
unit_cost?: number;
|
||||
supplier?: string;
|
||||
storage_location?: string;
|
||||
is_active: boolean;
|
||||
is_tracked: boolean;
|
||||
barcode?: string;
|
||||
sku?: string;
|
||||
is_low_stock: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface InventoryTransaction {
|
||||
id: number;
|
||||
transaction_type: string;
|
||||
quantity: number;
|
||||
quantity_before: number;
|
||||
quantity_after: number;
|
||||
notes?: string;
|
||||
cost?: number;
|
||||
transaction_date: string;
|
||||
}
|
||||
|
||||
export interface ReorderRequest {
|
||||
id: number;
|
||||
item_id: number;
|
||||
item_name: string;
|
||||
requested_quantity: number;
|
||||
current_quantity: number;
|
||||
minimum_quantity: number;
|
||||
status: string;
|
||||
priority: string;
|
||||
notes?: string;
|
||||
requested_at: string;
|
||||
}
|
||||
|
||||
const inventoryService = {
|
||||
async getInventoryItems(params?: {
|
||||
category?: string;
|
||||
low_stock?: boolean;
|
||||
is_active?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const response = await apiClient.get('/inventory/items', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getInventoryItem(itemId: number) {
|
||||
const response = await apiClient.get(`/inventory/items/${itemId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createInventoryItem(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
unit: string;
|
||||
minimum_quantity: number;
|
||||
maximum_quantity?: number;
|
||||
reorder_quantity?: number;
|
||||
unit_cost?: number;
|
||||
supplier?: string;
|
||||
supplier_contact?: string;
|
||||
storage_location?: string;
|
||||
barcode?: string;
|
||||
sku?: string;
|
||||
notes?: string;
|
||||
}) {
|
||||
const response = await apiClient.post('/inventory/items', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateInventoryItem(itemId: number, data: Partial<InventoryItem>) {
|
||||
const response = await apiClient.put(`/inventory/items/${itemId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createTransaction(data: {
|
||||
item_id: number;
|
||||
transaction_type: string;
|
||||
quantity: number;
|
||||
notes?: string;
|
||||
cost?: number;
|
||||
reference_type?: string;
|
||||
reference_id?: number;
|
||||
}) {
|
||||
const response = await apiClient.post('/inventory/transactions', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getItemTransactions(itemId: number, params?: { page?: number; limit?: number }) {
|
||||
const response = await apiClient.get(`/inventory/items/${itemId}/transactions`, { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createReorderRequest(data: {
|
||||
item_id: number;
|
||||
requested_quantity: number;
|
||||
priority?: string;
|
||||
notes?: string;
|
||||
}) {
|
||||
const response = await apiClient.post('/inventory/reorder-requests', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getReorderRequests(params?: { status?: string; page?: number; limit?: number }) {
|
||||
const response = await apiClient.get('/inventory/reorder-requests', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async recordTaskConsumption(data: {
|
||||
task_id: number;
|
||||
item_id: number;
|
||||
quantity: number;
|
||||
notes?: string;
|
||||
}) {
|
||||
const response = await apiClient.post('/inventory/task-consumption', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getLowStockItems() {
|
||||
const response = await apiClient.get('/inventory/low-stock');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default inventoryService;
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface MaintenanceRecord {
|
||||
actual_end?: string;
|
||||
assigned_to?: number;
|
||||
assigned_staff_name?: string;
|
||||
reported_by?: number;
|
||||
reported_by_name?: string;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
blocks_room: boolean;
|
||||
block_start?: string;
|
||||
@@ -22,7 +24,9 @@ export interface MaintenanceRecord {
|
||||
estimated_cost?: number;
|
||||
actual_cost?: number;
|
||||
notes?: string;
|
||||
completion_notes?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface HousekeepingTask {
|
||||
@@ -44,6 +48,7 @@ export interface HousekeepingTask {
|
||||
actual_duration_minutes?: number;
|
||||
room_status?: 'available' | 'occupied' | 'maintenance' | 'cleaning';
|
||||
is_room_status_only?: boolean; // Flag to indicate this is from room status, not a task
|
||||
photos?: string[]; // Array of photo URLs
|
||||
}
|
||||
|
||||
export interface ChecklistItem {
|
||||
@@ -185,6 +190,7 @@ const advancedRoomService = {
|
||||
date?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
include_cleaning_rooms?: boolean;
|
||||
}) {
|
||||
const response = await apiClient.get('/advanced-rooms/housekeeping', { params });
|
||||
return response.data;
|
||||
@@ -212,11 +218,36 @@ const advancedRoomService = {
|
||||
quality_score?: number;
|
||||
inspected_by?: number;
|
||||
inspection_notes?: string;
|
||||
photos?: string[];
|
||||
assigned_to?: number;
|
||||
}) {
|
||||
const response = await apiClient.put(`/advanced-rooms/housekeeping/${taskId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async uploadHousekeepingTaskPhoto(taskId: number, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const response = await apiClient.post(`/advanced-rooms/housekeeping/${taskId}/upload-photo`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async reportMaintenanceIssue(taskId: number, data: {
|
||||
title: string;
|
||||
description: string;
|
||||
maintenance_type?: 'corrective' | 'emergency';
|
||||
priority?: 'low' | 'medium' | 'high' | 'urgent';
|
||||
blocks_room?: boolean;
|
||||
notes?: string;
|
||||
}) {
|
||||
const response = await apiClient.post(`/advanced-rooms/housekeeping/${taskId}/report-maintenance-issue`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Room Inspections
|
||||
async getRoomInspections(params?: {
|
||||
room_id?: number;
|
||||
|
||||
150
Frontend/src/features/staffShifts/services/staffShiftService.ts
Normal file
150
Frontend/src/features/staffShifts/services/staffShiftService.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import apiClient from '../../../shared/services/apiClient';
|
||||
|
||||
export interface StaffShift {
|
||||
id: number;
|
||||
staff_id: number;
|
||||
staff_name?: string;
|
||||
shift_date: string;
|
||||
shift_type: 'morning' | 'afternoon' | 'night' | 'full_day' | 'custom';
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'no_show';
|
||||
actual_start_time?: string;
|
||||
actual_end_time?: string;
|
||||
break_duration_minutes?: number;
|
||||
department?: string;
|
||||
notes?: string;
|
||||
handover_notes?: string;
|
||||
tasks_completed?: number;
|
||||
tasks_assigned?: number;
|
||||
assigned_by_name?: string;
|
||||
}
|
||||
|
||||
export interface StaffTask {
|
||||
id: number;
|
||||
shift_id?: number;
|
||||
staff_id: number;
|
||||
staff_name?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
task_type: string;
|
||||
priority: 'low' | 'normal' | 'high' | 'urgent';
|
||||
status: 'pending' | 'assigned' | 'in_progress' | 'completed' | 'cancelled' | 'on_hold';
|
||||
scheduled_start?: string;
|
||||
scheduled_end?: string;
|
||||
actual_start?: string;
|
||||
actual_end?: string;
|
||||
estimated_duration_minutes?: number;
|
||||
actual_duration_minutes?: number;
|
||||
due_date?: string;
|
||||
related_booking_id?: number;
|
||||
related_room_id?: number;
|
||||
related_guest_request_id?: number;
|
||||
related_maintenance_id?: number;
|
||||
notes?: string;
|
||||
completion_notes?: string;
|
||||
assigned_by_name?: string;
|
||||
}
|
||||
|
||||
const staffShiftService = {
|
||||
async getShifts(params?: {
|
||||
staff_id?: number;
|
||||
shift_date?: string;
|
||||
status?: string;
|
||||
department?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const response = await apiClient.get('/staff-shifts', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getShift(shiftId: number) {
|
||||
const response = await apiClient.get(`/staff-shifts/${shiftId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createShift(data: {
|
||||
staff_id?: number;
|
||||
shift_date: string;
|
||||
shift_type: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status?: string;
|
||||
break_duration_minutes?: number;
|
||||
department?: string;
|
||||
notes?: string;
|
||||
}) {
|
||||
const response = await apiClient.post('/staff-shifts', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateShift(shiftId: number, data: {
|
||||
shift_date?: string;
|
||||
shift_type?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
status?: string;
|
||||
break_duration_minutes?: number;
|
||||
department?: string;
|
||||
notes?: string;
|
||||
handover_notes?: string;
|
||||
}) {
|
||||
const response = await apiClient.put(`/staff-shifts/${shiftId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getTasks(params?: {
|
||||
staff_id?: number;
|
||||
shift_id?: number;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const response = await apiClient.get('/staff-shifts/tasks', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createTask(data: {
|
||||
shift_id?: number;
|
||||
staff_id?: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
task_type?: string;
|
||||
priority?: string;
|
||||
status?: string;
|
||||
scheduled_start?: string;
|
||||
scheduled_end?: string;
|
||||
estimated_duration_minutes?: number;
|
||||
due_date?: string;
|
||||
related_booking_id?: number;
|
||||
related_room_id?: number;
|
||||
related_guest_request_id?: number;
|
||||
related_maintenance_id?: number;
|
||||
notes?: string;
|
||||
}) {
|
||||
const response = await apiClient.post('/staff-shifts/tasks', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateTask(taskId: number, data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
priority?: string;
|
||||
status?: string;
|
||||
completion_notes?: string;
|
||||
notes?: string;
|
||||
}) {
|
||||
const response = await apiClient.put(`/staff-shifts/tasks/${taskId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getWorkloadSummary(date?: string) {
|
||||
const response = await apiClient.get('/staff-shifts/workload', { params: { date } });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default staffShiftService;
|
||||
|
||||
@@ -83,7 +83,7 @@ const AccountantDashboardPage: React.FC = () => {
|
||||
if (response.success && response.data?.payments) {
|
||||
setRecentPayments(response.data.payments);
|
||||
// Calculate financial summary
|
||||
const completedPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'completed' || p.payment_status === 'paid');
|
||||
const completedPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'completed');
|
||||
const pendingPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'pending');
|
||||
const totalRevenue = completedPayments.reduce((sum: number, p: Payment) => sum + (p.amount || 0), 0);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Search, Plus, Edit, Trash2, Eye, FileText, Filter } from 'lucide-react';
|
||||
import invoiceService, { Invoice } from '../../features/payments/services/invoiceService';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
@@ -680,8 +680,7 @@ const BannerManagementPage: React.FC = () => {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -513,8 +513,7 @@ const PromotionManagementPage: React.FC = () => {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
457
Frontend/src/pages/customer/GuestRequestsPage.tsx
Normal file
457
Frontend/src/pages/customer/GuestRequestsPage.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Bell,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Wrench,
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import guestRequestService, { GuestRequest } from '../../features/guestRequests/services/guestRequestService';
|
||||
import { getMyBookings, Booking } from '../../features/bookings/services/bookingService';
|
||||
import { formatDate, formatRelativeTime } from '../../shared/utils/format';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import EmptyState from '../../shared/components/EmptyState';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
|
||||
const GuestRequestsPage: React.FC = () => {
|
||||
const { userInfo } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [requests, setRequests] = useState<GuestRequest[]>([]);
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<string>('');
|
||||
const [requestForm, setRequestForm] = useState({
|
||||
booking_id: '',
|
||||
room_id: '',
|
||||
request_type: 'other',
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'normal',
|
||||
guest_notes: '',
|
||||
});
|
||||
|
||||
const requestTypes = [
|
||||
{ value: 'extra_towels', label: 'Extra Towels', icon: Package },
|
||||
{ value: 'extra_pillows', label: 'Extra Pillows', icon: Package },
|
||||
{ value: 'room_cleaning', label: 'Room Cleaning', icon: Sparkles },
|
||||
{ value: 'turndown_service', label: 'Turndown Service', icon: Sparkles },
|
||||
{ value: 'amenities', label: 'Amenities', icon: Package },
|
||||
{ value: 'maintenance', label: 'Maintenance', icon: Wrench },
|
||||
{ value: 'room_service', label: 'Room Service', icon: Bell },
|
||||
{ value: 'other', label: 'Other', icon: MessageSquare },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [filterStatus]);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch active bookings - only checked-in bookings can create requests
|
||||
const bookingsResponse = await getMyBookings();
|
||||
|
||||
if (bookingsResponse.success && bookingsResponse.data?.bookings) {
|
||||
// Only allow requests for checked-in bookings (guests must be in the room)
|
||||
const checkedInBookings = bookingsResponse.data.bookings.filter(
|
||||
(b: Booking) => b.status === 'checked_in'
|
||||
);
|
||||
setBookings(checkedInBookings);
|
||||
}
|
||||
|
||||
// Fetch guest requests
|
||||
const params: any = {};
|
||||
if (filterStatus) {
|
||||
params.status = filterStatus;
|
||||
}
|
||||
|
||||
const requestsResponse = await guestRequestService.getGuestRequests(params);
|
||||
|
||||
if (requestsResponse.status === 'success' && requestsResponse.data?.requests) {
|
||||
setRequests(requestsResponse.data.requests);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Error fetching data', error);
|
||||
toast.error('Failed to load requests');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRequest = async () => {
|
||||
if (!requestForm.booking_id || !requestForm.room_id || !requestForm.title.trim()) {
|
||||
toast.error('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await guestRequestService.createGuestRequest({
|
||||
booking_id: parseInt(requestForm.booking_id),
|
||||
room_id: parseInt(requestForm.room_id),
|
||||
request_type: requestForm.request_type,
|
||||
title: requestForm.title,
|
||||
description: requestForm.description,
|
||||
priority: requestForm.priority,
|
||||
guest_notes: requestForm.guest_notes,
|
||||
});
|
||||
|
||||
toast.success('Request submitted successfully! Our staff will attend to it shortly.');
|
||||
setShowCreateModal(false);
|
||||
setRequestForm({
|
||||
booking_id: '',
|
||||
room_id: '',
|
||||
request_type: 'other',
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'normal',
|
||||
guest_notes: '',
|
||||
});
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
logger.error('Error creating request', error);
|
||||
toast.error(error.response?.data?.detail || 'Failed to submit request');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'fulfilled':
|
||||
return <CheckCircle className="w-5 h-5 text-green-500" />;
|
||||
case 'in_progress':
|
||||
return <Clock className="w-5 h-5 text-blue-500" />;
|
||||
case 'cancelled':
|
||||
return <XCircle className="w-5 h-5 text-red-500" />;
|
||||
default:
|
||||
return <AlertCircle className="w-5 h-5 text-amber-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles = {
|
||||
pending: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
in_progress: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
fulfilled: 'bg-green-100 text-green-800 border-green-200',
|
||||
cancelled: 'bg-red-100 text-red-800 border-red-200',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${styles[status as keyof typeof styles] || styles.pending}`}>
|
||||
{status.replace('_', ' ').toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
const styles = {
|
||||
low: 'bg-gray-100 text-gray-800',
|
||||
normal: 'bg-blue-100 text-blue-800',
|
||||
high: 'bg-orange-100 text-orange-800',
|
||||
urgent: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${styles[priority as keyof typeof styles] || styles.normal}`}>
|
||||
{priority.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getRequestTypeLabel = (type: string) => {
|
||||
return requestTypes.find(t => t.value === type)?.label || type.replace('_', ' ');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Guest Requests</h1>
|
||||
<p className="text-gray-600">Submit and track your service requests</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (bookings.length === 0) {
|
||||
toast.info('You need to be checked in to submit a request. Please check in first or contact reception.');
|
||||
return;
|
||||
}
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="flex items-center space-x-2 px-6 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 shadow-lg hover:shadow-xl font-medium"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span>New Request</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="w-5 h-5 text-gray-500" />
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="fulfilled">Fulfilled</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="flex items-center space-x-2 px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
{bookings.length === 0 && (
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-1">Check-in Required</h3>
|
||||
<p className="text-sm text-blue-800">
|
||||
You need to be checked in to submit service requests. Once you're in your room, you can request extra towels, room cleaning, maintenance, and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requests List */}
|
||||
{requests.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={MessageSquare}
|
||||
title={bookings.length === 0 ? "Check in to submit requests" : "No requests yet"}
|
||||
description={bookings.length === 0
|
||||
? "You need to be checked in to submit service requests. Please check in first or contact reception."
|
||||
: "Submit your first request to get started"
|
||||
}
|
||||
actionLabel={bookings.length > 0 ? "Create Request" : undefined}
|
||||
onAction={bookings.length > 0 ? () => setShowCreateModal(true) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-6">
|
||||
{requests.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="bg-white rounded-xl shadow-md hover:shadow-lg transition-shadow p-6 border border-gray-200"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
{getStatusIcon(request.status)}
|
||||
<h3 className="text-lg font-semibold text-gray-900">{request.title}</h3>
|
||||
{getPriorityBadge(request.priority)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
<span className="font-medium">Type:</span> {getRequestTypeLabel(request.request_type)}
|
||||
{request.room_number && (
|
||||
<>
|
||||
{' • '}
|
||||
<span className="font-medium">Room:</span> {request.room_number}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{request.description && (
|
||||
<p className="text-gray-700 mb-2">{request.description}</p>
|
||||
)}
|
||||
{request.staff_notes && (
|
||||
<div className="mt-3 p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p className="text-sm font-medium text-blue-900 mb-1">Staff Response:</p>
|
||||
<p className="text-sm text-blue-800">{request.staff_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4 text-right">
|
||||
{getStatusBadge(request.status)}
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
{formatRelativeTime(request.requested_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<div className="text-sm text-gray-600">
|
||||
{request.assigned_staff_name && (
|
||||
<span>Assigned to: <span className="font-medium">{request.assigned_staff_name}</span></span>
|
||||
)}
|
||||
</div>
|
||||
{request.fulfilled_at && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Fulfilled: {formatDate(request.fulfilled_at)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Request Modal */}
|
||||
{showCreateModal && (
|
||||
<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-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Create New Request</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Booking *
|
||||
</label>
|
||||
<select
|
||||
value={requestForm.booking_id}
|
||||
onChange={(e) => {
|
||||
const booking = bookings.find(b => b.id === parseInt(e.target.value));
|
||||
setRequestForm({
|
||||
...requestForm,
|
||||
booking_id: e.target.value,
|
||||
room_id: booking?.room_id?.toString() || '',
|
||||
});
|
||||
setSelectedBooking(booking);
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]"
|
||||
required
|
||||
>
|
||||
<option value="">Select Booking</option>
|
||||
{bookings.map((booking) => (
|
||||
<option key={booking.id} value={booking.id}>
|
||||
Booking #{booking.booking_number} - Room {booking.room?.room_number || booking.room_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Request Type *
|
||||
</label>
|
||||
<select
|
||||
value={requestForm.request_type}
|
||||
onChange={(e) => setRequestForm({ ...requestForm, request_type: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]"
|
||||
required
|
||||
>
|
||||
{requestTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={requestForm.title}
|
||||
onChange={(e) => setRequestForm({ ...requestForm, title: e.target.value })}
|
||||
placeholder="Brief description of your request"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={requestForm.description}
|
||||
onChange={(e) => setRequestForm({ ...requestForm, description: e.target.value })}
|
||||
placeholder="Additional details about your request"
|
||||
rows={4}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
value={requestForm.priority}
|
||||
onChange={(e) => setRequestForm({ ...requestForm, priority: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]"
|
||||
>
|
||||
<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-medium text-gray-700 mb-1">
|
||||
Additional Notes
|
||||
</label>
|
||||
<textarea
|
||||
value={requestForm.guest_notes}
|
||||
onChange={(e) => setRequestForm({ ...requestForm, guest_notes: e.target.value })}
|
||||
placeholder="Any special instructions or preferences"
|
||||
rows={3}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateRequest}
|
||||
className="px-4 py-2 text-sm text-white bg-gradient-to-r from-[#d4af37] to-[#c9a227] rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-colors font-medium"
|
||||
>
|
||||
Submit Request
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GuestRequestsPage;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,21 +13,29 @@ import {
|
||||
ChevronUp,
|
||||
Calendar,
|
||||
MapPin,
|
||||
ArrowRight,
|
||||
Search,
|
||||
X,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import advancedRoomService, {
|
||||
RoomStatusBoardItem,
|
||||
OptimalRoomAssignmentRequest,
|
||||
} from '../../features/rooms/services/advancedRoomService';
|
||||
import roomService from '../../features/rooms/services/roomService';
|
||||
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
|
||||
import MaintenanceManagement from '../../features/hotel_services/components/MaintenanceManagement';
|
||||
import HousekeepingManagement from '../../features/hotel_services/components/HousekeepingManagement';
|
||||
import InspectionManagement from '../../features/hotel_services/components/InspectionManagement';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
|
||||
type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections';
|
||||
type Tab = 'status-board' | 'room-assignment' | 'maintenance' | 'housekeeping' | 'inspections';
|
||||
|
||||
const AdvancedRoomManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('status-board');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rooms, setRooms] = useState<RoomStatusBoardItem[]>([]);
|
||||
@@ -35,6 +43,17 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
const [floors, setFloors] = useState<number[]>([]);
|
||||
const [expandedRooms, setExpandedRooms] = useState<Set<number>>(new Set());
|
||||
|
||||
// Room assignment state
|
||||
const [assigningRoom, setAssigningRoom] = useState(false);
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||
const [bookingSearch, setBookingSearch] = useState('');
|
||||
const [bookingSearchResults, setBookingSearchResults] = useState<Booking[]>([]);
|
||||
const [searchingBookings, setSearchingBookings] = useState(false);
|
||||
const [availableRoomsForAssignment, setAvailableRoomsForAssignment] = useState<RoomStatusBoardItem[]>([]);
|
||||
const [loadingAvailableRooms, setLoadingAvailableRooms] = useState(false);
|
||||
const [showUpgradeSuggestions, setShowUpgradeSuggestions] = useState(false);
|
||||
const [upgradeSuggestions, setUpgradeSuggestions] = useState<RoomStatusBoardItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoomStatusBoard();
|
||||
fetchFloors();
|
||||
@@ -166,6 +185,135 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Room assignment handlers
|
||||
const handleSearchBookings = async () => {
|
||||
if (!bookingSearch.trim()) {
|
||||
setBookingSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSearchingBookings(true);
|
||||
const response = await bookingService.getAllBookings({
|
||||
search: bookingSearch,
|
||||
status: 'confirmed',
|
||||
limit: 10,
|
||||
});
|
||||
setBookingSearchResults(response.data.bookings || []);
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to search bookings');
|
||||
logger.error('Error searching bookings', error);
|
||||
} finally {
|
||||
setSearchingBookings(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (bookingSearch.trim()) {
|
||||
handleSearchBookings();
|
||||
} else {
|
||||
setBookingSearchResults([]);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [bookingSearch]);
|
||||
|
||||
const handleSelectBooking = async (booking: Booking) => {
|
||||
setSelectedBooking(booking);
|
||||
setBookingSearch('');
|
||||
setBookingSearchResults([]);
|
||||
|
||||
// Fetch available rooms for the booking dates
|
||||
try {
|
||||
setLoadingAvailableRooms(true);
|
||||
const response = await roomService.searchAvailableRooms({
|
||||
from: booking.check_in_date,
|
||||
to: booking.check_out_date,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
// Get room status board to match with available rooms
|
||||
const statusBoardResponse = await advancedRoomService.getRoomStatusBoard();
|
||||
const allRooms = statusBoardResponse.data.rooms || [];
|
||||
|
||||
// Filter to show only available rooms that match the booking's room type or better
|
||||
const availableRooms = allRooms.filter(room => {
|
||||
const isAvailable = room.status === 'available';
|
||||
const matchesType = !booking.room?.room_type ||
|
||||
room.room_type === booking.room.room_type.name ||
|
||||
(room.room_type && booking.room.room_type);
|
||||
return isAvailable && matchesType;
|
||||
});
|
||||
|
||||
setAvailableRoomsForAssignment(availableRooms);
|
||||
|
||||
// Get upgrade suggestions (better room types)
|
||||
const upgradeRooms = allRooms.filter(room => {
|
||||
return room.status === 'available' &&
|
||||
room.room_type &&
|
||||
room.room_type !== booking.room?.room_type?.name;
|
||||
});
|
||||
setUpgradeSuggestions(upgradeRooms.slice(0, 5));
|
||||
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to load available rooms');
|
||||
logger.error('Error loading available rooms', error);
|
||||
} finally {
|
||||
setLoadingAvailableRooms(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignRoom = async (roomId: number, roomNumber: string) => {
|
||||
if (!selectedBooking) return;
|
||||
|
||||
try {
|
||||
setAssigningRoom(true);
|
||||
await bookingService.updateBooking(selectedBooking.id, {
|
||||
room_id: roomId,
|
||||
} as any);
|
||||
|
||||
toast.success(`Room ${roomNumber} assigned successfully!`);
|
||||
setSelectedBooking(null);
|
||||
setAvailableRoomsForAssignment([]);
|
||||
setUpgradeSuggestions([]);
|
||||
fetchRoomStatusBoard();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to assign room');
|
||||
logger.error('Error assigning room', error);
|
||||
} finally {
|
||||
setAssigningRoom(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetOptimalRoom = async () => {
|
||||
if (!selectedBooking) return;
|
||||
|
||||
try {
|
||||
setLoadingAvailableRooms(true);
|
||||
const request: OptimalRoomAssignmentRequest = {
|
||||
room_type_id: selectedBooking.room?.room_type?.id || 0,
|
||||
check_in: selectedBooking.check_in_date,
|
||||
check_out: selectedBooking.check_out_date,
|
||||
num_guests: selectedBooking.guest_count || 1,
|
||||
};
|
||||
|
||||
const response = await advancedRoomService.assignOptimalRoom(request);
|
||||
if (response.status === 'success' && response.data?.recommended_room) {
|
||||
const recommendedRoom = response.data.recommended_room;
|
||||
toast.success(`Optimal room suggestion: Room ${recommendedRoom.room_number}`);
|
||||
// Auto-select the recommended room
|
||||
handleAssignRoom(recommendedRoom.id, recommendedRoom.room_number);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to get optimal room suggestion');
|
||||
logger.error('Error getting optimal room', error);
|
||||
} finally {
|
||||
setLoadingAvailableRooms(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && rooms.length === 0) {
|
||||
return <Loading />;
|
||||
}
|
||||
@@ -182,6 +330,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{[
|
||||
{ id: 'status-board' as Tab, label: 'Room Status Board', icon: Hotel },
|
||||
{ id: 'room-assignment' as Tab, label: 'Room Assignment', icon: ArrowRight },
|
||||
{ id: 'maintenance' as Tab, label: 'Maintenance', icon: Wrench },
|
||||
{ id: 'housekeeping' as Tab, label: 'Housekeeping', icon: Sparkles },
|
||||
{ id: 'inspections' as Tab, label: 'Inspections', icon: ClipboardCheck },
|
||||
@@ -377,6 +526,228 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Room Assignment Tab */}
|
||||
{activeTab === 'room-assignment' && (
|
||||
<div className="space-y-8">
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-gradient-to-br from-indigo-500/10 to-purple-500/10 border border-indigo-200/40">
|
||||
<ArrowRight className="w-6 h-6 text-indigo-600" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-extrabold text-gray-900">Room Assignment</h2>
|
||||
</div>
|
||||
<p className="text-gray-600 text-base max-w-2xl leading-relaxed">
|
||||
Assign or change rooms for bookings with visual room selection
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Selection */}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-100 flex items-center justify-center">
|
||||
<span className="text-indigo-600 font-bold">1</span>
|
||||
</div>
|
||||
Select Booking
|
||||
</h3>
|
||||
|
||||
{!selectedBooking ? (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
value={bookingSearch}
|
||||
onChange={(e) => setBookingSearch(e.target.value)}
|
||||
placeholder="Search by booking number, guest name, or email..."
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-gray-200 rounded-xl focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100 transition-all duration-200 text-gray-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{bookingSearchResults.length > 0 && (
|
||||
<div className="border-2 border-gray-200 rounded-xl overflow-hidden max-h-96 overflow-y-auto">
|
||||
{bookingSearchResults.map((booking) => (
|
||||
<div
|
||||
key={booking.id}
|
||||
onClick={() => handleSelectBooking(booking)}
|
||||
className="p-4 hover:bg-indigo-50 cursor-pointer border-b border-gray-100 last:border-b-0 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-bold text-gray-900">{booking.booking_number}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{booking.guest_info?.full_name || booking.user?.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{new Date(booking.check_in_date).toLocaleDateString()} - {new Date(booking.check_out_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
Room {booking.room?.room_number || 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{booking.room?.room_type?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchingBookings && (
|
||||
<div className="text-center py-8">
|
||||
<RefreshCw className="w-8 h-8 text-indigo-600 animate-spin mx-auto mb-2" />
|
||||
<p className="text-gray-600">Searching bookings...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-indigo-50 rounded-xl p-6 border-2 border-indigo-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="text-lg font-bold text-gray-900">{selectedBooking.booking_number}</h4>
|
||||
<span className="px-2 py-1 bg-indigo-600 text-white text-xs font-semibold rounded">
|
||||
{selectedBooking.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 space-y-1">
|
||||
<div><strong>Guest:</strong> {selectedBooking.guest_info?.full_name || selectedBooking.user?.name}</div>
|
||||
<div><strong>Current Room:</strong> Room {selectedBooking.room?.room_number || 'N/A'} - {selectedBooking.room?.room_type?.name}</div>
|
||||
<div><strong>Dates:</strong> {new Date(selectedBooking.check_in_date).toLocaleDateString()} to {new Date(selectedBooking.check_out_date).toLocaleDateString()}</div>
|
||||
<div><strong>Guests:</strong> {selectedBooking.guest_count || 1}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedBooking(null);
|
||||
setAvailableRoomsForAssignment([]);
|
||||
setUpgradeSuggestions([]);
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-white rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Room Selection */}
|
||||
{selectedBooking && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-100 flex items-center justify-center">
|
||||
<span className="text-indigo-600 font-bold">2</span>
|
||||
</div>
|
||||
Select New Room
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleGetOptimalRoom}
|
||||
disabled={loadingAvailableRooms}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-indigo-500 to-purple-600 text-white rounded-lg hover:from-indigo-600 hover:to-purple-700 font-medium disabled:opacity-50 transition-all"
|
||||
>
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
Get Optimal Room
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingAvailableRooms ? (
|
||||
<div className="text-center py-12 bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50">
|
||||
<RefreshCw className="w-8 h-8 text-indigo-600 animate-spin mx-auto mb-2" />
|
||||
<p className="text-gray-600">Loading available rooms...</p>
|
||||
</div>
|
||||
) : availableRoomsForAssignment.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50">
|
||||
<Hotel className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-600">No available rooms found for selected dates</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showUpgradeSuggestions && upgradeSuggestions.length > 0 && (
|
||||
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 rounded-xl p-6 border-2 border-amber-200">
|
||||
<h4 className="font-bold text-amber-900 mb-4 flex items-center gap-2">
|
||||
<ArrowUpRight className="w-5 h-5" />
|
||||
Upgrade Suggestions
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{upgradeSuggestions.map((room) => {
|
||||
const statusColors = getStatusColor(room.status);
|
||||
return (
|
||||
<div
|
||||
key={room.id}
|
||||
className={`p-4 rounded-xl border-2 ${statusColors.bg} ${statusColors.border} cursor-pointer hover:shadow-lg transition-all`}
|
||||
onClick={() => handleAssignRoom(room.id, room.room_number)}
|
||||
>
|
||||
<div className="font-bold text-lg">{room.room_number}</div>
|
||||
<div className="text-sm text-gray-700">{room.room_type}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<h4 className="font-bold text-gray-900">Available Rooms ({availableRoomsForAssignment.length})</h4>
|
||||
{upgradeSuggestions.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowUpgradeSuggestions(!showUpgradeSuggestions)}
|
||||
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium"
|
||||
>
|
||||
{showUpgradeSuggestions ? 'Hide' : 'Show'} Upgrade Options
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{availableRoomsForAssignment.map((room) => {
|
||||
const statusColors = getStatusColor(room.status);
|
||||
const isCurrentRoom = room.id === selectedBooking.room_id;
|
||||
return (
|
||||
<div
|
||||
key={room.id}
|
||||
className={`
|
||||
p-5 rounded-xl border-2 transition-all cursor-pointer
|
||||
${isCurrentRoom
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: `${statusColors.bg} ${statusColors.border} hover:shadow-lg`
|
||||
}
|
||||
${assigningRoom ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
onClick={() => !isCurrentRoom && !assigningRoom && handleAssignRoom(room.id, room.room_number)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-bold text-xl text-gray-900">{room.room_number}</div>
|
||||
{isCurrentRoom && (
|
||||
<span className="px-2 py-1 bg-blue-600 text-white text-xs font-semibold rounded">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 mb-3">{room.room_type}</div>
|
||||
{!isCurrentRoom && (
|
||||
<button
|
||||
disabled={assigningRoom}
|
||||
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{assigningRoom ? 'Assigning...' : 'Assign Room'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Maintenance Tab */}
|
||||
{activeTab === 'maintenance' && <MaintenanceManagement />}
|
||||
|
||||
|
||||
708
Frontend/src/pages/staff/GuestCommunicationPage.tsx
Normal file
708
Frontend/src/pages/staff/GuestCommunicationPage.tsx
Normal file
@@ -0,0 +1,708 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Phone,
|
||||
Send,
|
||||
Search,
|
||||
Filter,
|
||||
Plus,
|
||||
Edit,
|
||||
Eye,
|
||||
X,
|
||||
Clock,
|
||||
User,
|
||||
Calendar,
|
||||
RefreshCw,
|
||||
FileText,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import EmptyState from '../../shared/components/EmptyState';
|
||||
import { formatDate, formatRelativeTime } from '../../shared/utils/format';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
|
||||
import userService from '../../features/auth/services/userService';
|
||||
import apiClient from '../../shared/services/apiClient';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
|
||||
interface CommunicationTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
notification_type: string;
|
||||
channel: string;
|
||||
subject?: string;
|
||||
content: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface GuestCommunication {
|
||||
id: number;
|
||||
user_id: number;
|
||||
guest_name?: string;
|
||||
communication_type: string;
|
||||
direction: string;
|
||||
subject?: string;
|
||||
content: string;
|
||||
booking_id?: number;
|
||||
is_automated: boolean;
|
||||
created_at: string;
|
||||
staff_name?: string;
|
||||
}
|
||||
|
||||
type Tab = 'send' | 'history' | 'templates';
|
||||
|
||||
const GuestCommunicationPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('send');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Send communication state
|
||||
const [guestSearch, setGuestSearch] = useState('');
|
||||
const [guestSearchResults, setGuestSearchResults] = useState<any[]>([]);
|
||||
const [selectedGuest, setSelectedGuest] = useState<any>(null);
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||
const [communicationForm, setCommunicationForm] = useState({
|
||||
communication_type: 'email',
|
||||
subject: '',
|
||||
content: '',
|
||||
booking_id: null as number | null,
|
||||
});
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
// History state
|
||||
const [communications, setCommunications] = useState<GuestCommunication[]>([]);
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
const [historyTotalPages, setHistoryTotalPages] = useState(1);
|
||||
const [historyFilters, setHistoryFilters] = useState({
|
||||
search: '',
|
||||
communication_type: '',
|
||||
direction: '',
|
||||
});
|
||||
|
||||
// Templates state
|
||||
const [templates, setTemplates] = useState<CommunicationTemplate[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<CommunicationTemplate | null>(null);
|
||||
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'history') {
|
||||
fetchCommunications();
|
||||
} else if (activeTab === 'templates') {
|
||||
fetchTemplates();
|
||||
}
|
||||
}, [activeTab, historyPage, historyFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (guestSearch.length >= 2) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
searchGuests();
|
||||
}, 300);
|
||||
return () => clearTimeout(timeoutId);
|
||||
} else {
|
||||
setGuestSearchResults([]);
|
||||
}
|
||||
}, [guestSearch]);
|
||||
|
||||
const searchGuests = async () => {
|
||||
try {
|
||||
const response = await userService.getUsers({
|
||||
search: guestSearch,
|
||||
role: 'customer',
|
||||
limit: 10,
|
||||
});
|
||||
setGuestSearchResults(response.data.users || []);
|
||||
} catch (error) {
|
||||
logger.error('Error searching guests', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectGuest = async (guest: any) => {
|
||||
setSelectedGuest(guest);
|
||||
setGuestSearch('');
|
||||
setGuestSearchResults([]);
|
||||
|
||||
// Fetch guest's active bookings
|
||||
try {
|
||||
const response = await bookingService.getAllBookings({
|
||||
search: guest.email || guest.phone,
|
||||
limit: 10,
|
||||
});
|
||||
if (response.data?.bookings && response.data.bookings.length > 0) {
|
||||
setSelectedBooking(response.data.bookings[0]);
|
||||
setCommunicationForm({ ...communicationForm, booking_id: response.data.bookings[0].id });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching bookings', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendCommunication = async () => {
|
||||
if (!selectedGuest) {
|
||||
toast.error('Please select a guest');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!communicationForm.content.trim()) {
|
||||
toast.error('Please enter message content');
|
||||
return;
|
||||
}
|
||||
|
||||
if (communicationForm.communication_type === 'email' && !communicationForm.subject.trim()) {
|
||||
toast.error('Please enter email subject');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSending(true);
|
||||
|
||||
// Create guest communication record
|
||||
await apiClient.post('/guest-profiles/communications', {
|
||||
user_id: selectedGuest.id,
|
||||
communication_type: communicationForm.communication_type,
|
||||
direction: 'outbound',
|
||||
subject: communicationForm.subject || undefined,
|
||||
content: communicationForm.content,
|
||||
booking_id: communicationForm.booking_id || undefined,
|
||||
is_automated: false,
|
||||
});
|
||||
|
||||
// Send notification
|
||||
await apiClient.post('/notifications/send', {
|
||||
user_id: selectedGuest.id,
|
||||
notification_type: 'custom',
|
||||
channel: communicationForm.communication_type === 'email' ? 'email' : 'sms',
|
||||
subject: communicationForm.subject || undefined,
|
||||
content: communicationForm.content,
|
||||
booking_id: communicationForm.booking_id || undefined,
|
||||
});
|
||||
|
||||
toast.success('Communication sent successfully!');
|
||||
|
||||
// Reset form
|
||||
setSelectedGuest(null);
|
||||
setSelectedBooking(null);
|
||||
setCommunicationForm({
|
||||
communication_type: 'email',
|
||||
subject: '',
|
||||
content: '',
|
||||
booking_id: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to send communication');
|
||||
logger.error('Error sending communication', error);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCommunications = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params: any = {
|
||||
page: historyPage,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
if (historyFilters.communication_type) {
|
||||
params.communication_type = historyFilters.communication_type;
|
||||
}
|
||||
if (historyFilters.direction) {
|
||||
params.direction = historyFilters.direction;
|
||||
}
|
||||
|
||||
const response = await apiClient.get('/guest-profiles/communications', { params });
|
||||
if (response.data?.communications) {
|
||||
let filtered = response.data.communications;
|
||||
|
||||
if (historyFilters.search) {
|
||||
const searchLower = historyFilters.search.toLowerCase();
|
||||
filtered = filtered.filter((comm: GuestCommunication) =>
|
||||
comm.guest_name?.toLowerCase().includes(searchLower) ||
|
||||
comm.subject?.toLowerCase().includes(searchLower) ||
|
||||
comm.content?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
setCommunications(filtered);
|
||||
setHistoryTotalPages(response.data.pagination?.total_pages || 1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to load communications');
|
||||
logger.error('Error fetching communications', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.get('/notifications/templates');
|
||||
if (response.data?.templates) {
|
||||
setTemplates(response.data.templates);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to load templates');
|
||||
logger.error('Error fetching templates', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseTemplate = (template: CommunicationTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
setCommunicationForm({
|
||||
...communicationForm,
|
||||
subject: template.subject || '',
|
||||
content: template.content,
|
||||
communication_type: template.channel === 'email' ? 'email' : 'sms',
|
||||
});
|
||||
setShowTemplateModal(false);
|
||||
setActiveTab('send');
|
||||
};
|
||||
|
||||
const getCommunicationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'email':
|
||||
return Mail;
|
||||
case 'sms':
|
||||
return MessageSquare;
|
||||
case 'phone':
|
||||
return Phone;
|
||||
default:
|
||||
return Bell;
|
||||
}
|
||||
};
|
||||
|
||||
const getCommunicationColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'email':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'sms':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'phone':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
|
||||
{/* Header */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
|
||||
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-blue-400 via-indigo-500 to-purple-600 rounded-full"></div>
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
Guest Communication
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">
|
||||
Send messages, emails, and manage communication templates
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 border-b border-slate-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{[
|
||||
{ id: 'send' as Tab, label: 'Send Message', icon: Send },
|
||||
{ id: 'history' as Tab, label: 'History', icon: Clock },
|
||||
{ id: 'templates' as Tab, label: 'Templates', icon: FileText },
|
||||
].map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
flex items-center space-x-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors
|
||||
${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Send Tab */}
|
||||
{activeTab === 'send' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-6">
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-6">Send Communication</h2>
|
||||
|
||||
{/* Guest Selection */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Select Guest *</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
value={guestSearch}
|
||||
onChange={(e) => setGuestSearch(e.target.value)}
|
||||
placeholder="Search by name, email, or phone..."
|
||||
className="w-full pl-10 pr-4 py-3 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{guestSearchResults.length > 0 && (
|
||||
<div className="mt-2 border-2 border-slate-200 rounded-xl overflow-hidden max-h-60 overflow-y-auto">
|
||||
{guestSearchResults.map((guest) => (
|
||||
<div
|
||||
key={guest.id}
|
||||
onClick={() => handleSelectGuest(guest)}
|
||||
className="p-3 hover:bg-blue-50 cursor-pointer border-b border-slate-100 last:border-b-0 transition-colors"
|
||||
>
|
||||
<div className="font-medium text-slate-900">{guest.full_name || guest.name}</div>
|
||||
<div className="text-sm text-slate-600">{guest.email}</div>
|
||||
{guest.phone && <div className="text-sm text-slate-600">{guest.phone}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedGuest && (
|
||||
<div className="mt-3 p-4 bg-blue-50 rounded-xl border border-blue-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-slate-900">{selectedGuest.full_name || selectedGuest.name}</div>
|
||||
<div className="text-sm text-slate-600">{selectedGuest.email}</div>
|
||||
{selectedGuest.phone && <div className="text-sm text-slate-600">{selectedGuest.phone}</div>}
|
||||
{selectedBooking && (
|
||||
<div className="text-sm text-blue-600 mt-2">
|
||||
Active Booking: {selectedBooking.booking_number} - Room {selectedBooking.room?.room_number}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedGuest(null);
|
||||
setSelectedBooking(null);
|
||||
setCommunicationForm({ ...communicationForm, booking_id: null });
|
||||
}}
|
||||
className="p-2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Communication Type */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Communication Type *</label>
|
||||
<select
|
||||
value={communicationForm.communication_type}
|
||||
onChange={(e) => setCommunicationForm({ ...communicationForm, communication_type: e.target.value })}
|
||||
className="w-full px-4 py-3 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"
|
||||
>
|
||||
<option value="email">Email</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="phone">Phone Call</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Subject (for email) */}
|
||||
{communicationForm.communication_type === 'email' && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Subject *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={communicationForm.subject}
|
||||
onChange={(e) => setCommunicationForm({ ...communicationForm, subject: e.target.value })}
|
||||
placeholder="Email subject..."
|
||||
className="w-full px-4 py-3 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Message *</label>
|
||||
<textarea
|
||||
value={communicationForm.content}
|
||||
onChange={(e) => setCommunicationForm({ ...communicationForm, content: e.target.value })}
|
||||
rows={8}
|
||||
placeholder="Enter your message..."
|
||||
className="w-full px-4 py-3 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"
|
||||
/>
|
||||
{communicationForm.communication_type === 'sms' && (
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{communicationForm.content.length} / 160 characters
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setShowTemplateModal(true)}
|
||||
className="px-4 py-2 border-2 border-slate-300 text-slate-700 rounded-xl hover:bg-slate-50 font-medium transition-colors"
|
||||
>
|
||||
Use Template
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSendCommunication}
|
||||
disabled={sending || !selectedGuest}
|
||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-xl hover:from-blue-700 hover:to-indigo-700 font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5" />
|
||||
Send {communicationForm.communication_type === 'email' ? 'Email' : communicationForm.communication_type === 'sms' ? 'SMS' : 'Message'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History Tab */}
|
||||
{activeTab === 'history' && (
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
value={historyFilters.search}
|
||||
onChange={(e) => setHistoryFilters({ ...historyFilters, search: e.target.value })}
|
||||
placeholder="Search communications..."
|
||||
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-medium text-slate-700 mb-2">Type</label>
|
||||
<select
|
||||
value={historyFilters.communication_type}
|
||||
onChange={(e) => setHistoryFilters({ ...historyFilters, communication_type: 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"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="phone">Phone</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Direction</label>
|
||||
<select
|
||||
value={historyFilters.direction}
|
||||
onChange={(e) => setHistoryFilters({ ...historyFilters, direction: 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"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="outbound">Outbound</option>
|
||||
<option value="inbound">Inbound</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Communications List */}
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12">
|
||||
<Loading text="Loading communications..." />
|
||||
</div>
|
||||
) : communications.length === 0 ? (
|
||||
<div className="p-12">
|
||||
<EmptyState title="No communications found" description="No communications match your filters." />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Guest</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Type</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Subject</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Direction</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Date</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-200">
|
||||
{communications.map((comm) => {
|
||||
const Icon = getCommunicationIcon(comm.communication_type);
|
||||
return (
|
||||
<tr key={comm.id} className="hover:bg-slate-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-slate-900">{comm.guest_name || 'N/A'}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 inline-flex text-xs font-semibold rounded-full ${getCommunicationColor(comm.communication_type)}`}>
|
||||
<Icon className="w-3 h-3 mr-1" />
|
||||
{comm.communication_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-slate-900">{comm.subject || 'No subject'}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 inline-flex text-xs font-semibold rounded-full ${
|
||||
comm.direction === 'outbound' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{comm.direction}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500">
|
||||
{formatRelativeTime(comm.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Show communication details
|
||||
alert(`Content: ${comm.content}`);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{historyTotalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t border-slate-200">
|
||||
<Pagination
|
||||
currentPage={historyPage}
|
||||
totalPages={historyTotalPages}
|
||||
onPageChange={setHistoryPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Templates Tab */}
|
||||
{activeTab === 'templates' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-slate-900">Communication Templates</h2>
|
||||
<button
|
||||
onClick={() => setShowTemplateModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 font-medium flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Loading text="Loading templates..." />
|
||||
) : templates.length === 0 ? (
|
||||
<EmptyState title="No templates found" description="Create your first communication template." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{templates.map((template) => {
|
||||
const Icon = getCommunicationIcon(template.channel);
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
className="p-4 border-2 border-slate-200 rounded-xl hover:border-blue-300 hover:shadow-lg transition-all cursor-pointer"
|
||||
onClick={() => handleUseTemplate(template)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className={`px-2 py-1 inline-flex text-xs font-semibold rounded-full ${getCommunicationColor(template.channel)}`}>
|
||||
<Icon className="w-3 h-3 mr-1" />
|
||||
{template.channel}
|
||||
</span>
|
||||
{template.is_active ? (
|
||||
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">Active</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">Inactive</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-bold text-slate-900 mb-1">{template.name}</h3>
|
||||
{template.subject && <p className="text-sm text-slate-600 mb-2">{template.subject}</p>}
|
||||
<p className="text-sm text-slate-500 line-clamp-2">{template.content}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template Selection Modal */}
|
||||
{showTemplateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-slate-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-slate-900">Select Template</h2>
|
||||
<button
|
||||
onClick={() => setShowTemplateModal(false)}
|
||||
className="text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{templates.length === 0 ? (
|
||||
<EmptyState title="No templates available" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{templates.map((template) => {
|
||||
const Icon = getCommunicationIcon(template.channel);
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
onClick={() => {
|
||||
handleUseTemplate(template);
|
||||
setShowTemplateModal(false);
|
||||
}}
|
||||
className="p-4 border-2 border-slate-200 rounded-xl hover:border-blue-300 hover:bg-blue-50 cursor-pointer transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-bold text-slate-900">{template.name}</h3>
|
||||
<span className={`px-2 py-1 inline-flex text-xs font-semibold rounded-full ${getCommunicationColor(template.channel)}`}>
|
||||
<Icon className="w-3 h-3 mr-1" />
|
||||
{template.channel}
|
||||
</span>
|
||||
</div>
|
||||
{template.subject && <p className="text-sm text-slate-600 mb-2">{template.subject}</p>}
|
||||
<p className="text-sm text-slate-500">{template.content.substring(0, 100)}...</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GuestCommunicationPage;
|
||||
|
||||
759
Frontend/src/pages/staff/GuestRequestManagementPage.tsx
Normal file
759
Frontend/src/pages/staff/GuestRequestManagementPage.tsx
Normal file
@@ -0,0 +1,759 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Bell,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Wrench,
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
MapPin,
|
||||
Eye,
|
||||
UserCheck,
|
||||
X,
|
||||
Search,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import guestRequestService, { GuestRequest } from '../../features/guestRequests/services/guestRequestService';
|
||||
import userService, { User as StaffUser } from '../../features/auth/services/userService';
|
||||
import { formatDate, formatRelativeTime } from '../../shared/utils/format';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import EmptyState from '../../shared/components/EmptyState';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
|
||||
const GuestRequestManagementPage: React.FC = () => {
|
||||
const { userInfo } = useAuthStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [requests, setRequests] = useState<GuestRequest[]>([]);
|
||||
const [staffMembers, setStaffMembers] = useState<StaffUser[]>([]);
|
||||
const [selectedRequest, setSelectedRequest] = useState<GuestRequest | null>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [filters, setFilters] = useState({
|
||||
status: '',
|
||||
request_type: '',
|
||||
priority: '',
|
||||
assigned_to: '',
|
||||
room_id: '',
|
||||
search: '',
|
||||
});
|
||||
const [expandedFilters, setExpandedFilters] = useState(false);
|
||||
const [updatingRequestId, setUpdatingRequestId] = useState<number | null>(null);
|
||||
const [staffNotes, setStaffNotes] = useState('');
|
||||
|
||||
const requestTypes = [
|
||||
{ value: 'extra_towels', label: 'Extra Towels', icon: Package, color: 'bg-blue-100 text-blue-800' },
|
||||
{ value: 'extra_pillows', label: 'Extra Pillows', icon: Package, color: 'bg-purple-100 text-purple-800' },
|
||||
{ value: 'room_cleaning', label: 'Room Cleaning', icon: Sparkles, color: 'bg-green-100 text-green-800' },
|
||||
{ value: 'turndown_service', label: 'Turndown Service', icon: Sparkles, color: 'bg-indigo-100 text-indigo-800' },
|
||||
{ value: 'amenities', label: 'Amenities', icon: Package, color: 'bg-pink-100 text-pink-800' },
|
||||
{ value: 'maintenance', label: 'Maintenance', icon: Wrench, color: 'bg-orange-100 text-orange-800' },
|
||||
{ value: 'room_service', label: 'Room Service', icon: Bell, color: 'bg-yellow-100 text-yellow-800' },
|
||||
{ value: 'other', label: 'Other', icon: MessageSquare, color: 'bg-gray-100 text-gray-800' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests();
|
||||
fetchStaffMembers();
|
||||
}, [currentPage, filters]);
|
||||
|
||||
const fetchRequests = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params: any = {
|
||||
page: currentPage,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
if (filters.status) params.status = filters.status;
|
||||
if (filters.request_type) params.request_type = filters.request_type;
|
||||
if (filters.priority) params.priority = filters.priority;
|
||||
if (filters.assigned_to) params.assigned_to = parseInt(filters.assigned_to);
|
||||
if (filters.room_id) params.room_id = parseInt(filters.room_id);
|
||||
|
||||
const response = await guestRequestService.getGuestRequests(params);
|
||||
|
||||
if (response.status === 'success' && response.data?.requests) {
|
||||
let filteredRequests = response.data.requests;
|
||||
|
||||
// Client-side search filter
|
||||
if (filters.search) {
|
||||
const searchLower = filters.search.toLowerCase();
|
||||
filteredRequests = filteredRequests.filter(
|
||||
(req: GuestRequest) =>
|
||||
req.title?.toLowerCase().includes(searchLower) ||
|
||||
req.guest_name?.toLowerCase().includes(searchLower) ||
|
||||
req.room_number?.toString().includes(searchLower) ||
|
||||
req.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
setRequests(filteredRequests);
|
||||
setTotalPages(response.data.pagination?.total_pages || 1);
|
||||
setTotalItems(response.data.pagination?.total || 0);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Error fetching requests', error);
|
||||
toast.error('Failed to load guest requests');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStaffMembers = async () => {
|
||||
try {
|
||||
const response = await userService.getUsers({ role: 'staff', limit: 100 });
|
||||
if (response.data?.users) {
|
||||
setStaffMembers(response.data.users);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch staff members', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = async (requestId: number, staffId?: number) => {
|
||||
try {
|
||||
setUpdatingRequestId(requestId);
|
||||
await guestRequestService.updateGuestRequest(requestId, {
|
||||
assigned_to: staffId || userInfo?.id,
|
||||
status: 'in_progress',
|
||||
});
|
||||
toast.success('Request assigned successfully');
|
||||
fetchRequests();
|
||||
if (selectedRequest?.id === requestId) {
|
||||
setSelectedRequest(null);
|
||||
setShowDetailModal(false);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to assign request');
|
||||
} finally {
|
||||
setUpdatingRequestId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (requestId: number, newStatus: string) => {
|
||||
try {
|
||||
setUpdatingRequestId(requestId);
|
||||
await guestRequestService.updateGuestRequest(requestId, {
|
||||
status: newStatus,
|
||||
});
|
||||
toast.success(`Request marked as ${newStatus.replace('_', ' ')}`);
|
||||
fetchRequests();
|
||||
if (selectedRequest?.id === requestId) {
|
||||
const updated = await guestRequestService.getGuestRequest(requestId);
|
||||
if (updated.status === 'success') {
|
||||
setSelectedRequest(updated.data);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update request');
|
||||
} finally {
|
||||
setUpdatingRequestId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFulfill = async (requestId: number) => {
|
||||
try {
|
||||
setUpdatingRequestId(requestId);
|
||||
await guestRequestService.fulfillRequest(requestId, staffNotes || undefined);
|
||||
toast.success('Request fulfilled successfully');
|
||||
setStaffNotes('');
|
||||
fetchRequests();
|
||||
setShowDetailModal(false);
|
||||
setSelectedRequest(null);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to fulfill request');
|
||||
} finally {
|
||||
setUpdatingRequestId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = async (request: GuestRequest) => {
|
||||
try {
|
||||
const response = await guestRequestService.getGuestRequest(request.id);
|
||||
if (response.status === 'success' && response.data) {
|
||||
setSelectedRequest(response.data);
|
||||
setShowDetailModal(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to load request details');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-300';
|
||||
case 'in_progress':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-300';
|
||||
case 'fulfilled':
|
||||
return 'bg-green-100 text-green-800 border-green-300';
|
||||
case 'cancelled':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'urgent':
|
||||
return 'bg-red-100 text-red-800 border-red-300';
|
||||
case 'high':
|
||||
return 'bg-orange-100 text-orange-800 border-orange-300';
|
||||
case 'normal':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-300';
|
||||
case 'low':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getRequestTypeInfo = (type: string) => {
|
||||
return requestTypes.find(t => t.value === type) || requestTypes[requestTypes.length - 1];
|
||||
};
|
||||
|
||||
const pendingCount = requests.filter(r => r.status === 'pending').length;
|
||||
const inProgressCount = requests.filter(r => r.status === 'in_progress').length;
|
||||
const urgentCount = requests.filter(r => r.priority === 'urgent' && r.status !== 'fulfilled').length;
|
||||
|
||||
if (loading && requests.length === 0) {
|
||||
return <Loading fullScreen text="Loading guest requests..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
|
||||
{/* Header */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
|
||||
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-blue-400 via-indigo-500 to-purple-600 rounded-full"></div>
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
Guest Request Management
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">
|
||||
Manage and fulfill guest service requests
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-5 md:gap-6 mb-6 sm:mb-8">
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-yellow-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Pending</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-yellow-600">{pendingCount}</p>
|
||||
</div>
|
||||
<Clock className="w-8 h-8 sm:w-10 sm:h-10 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-blue-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">In Progress</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-blue-600">{inProgressCount}</p>
|
||||
</div>
|
||||
<RefreshCw className="w-8 h-8 sm:w-10 sm:h-10 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-red-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Urgent</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-red-600">{urgentCount}</p>
|
||||
</div>
|
||||
<AlertCircle className="w-8 h-8 sm:w-10 sm:h-10 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-emerald-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Total</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-emerald-600">{totalItems}</p>
|
||||
</div>
|
||||
<Bell className="w-8 h-8 sm:w-10 sm:h-10 text-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 mb-6 sm:mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg sm:text-xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Filter className="w-5 h-5 text-blue-600" />
|
||||
Filters
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setExpandedFilters(!expandedFilters)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
{expandedFilters ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
{expandedFilters ? 'Hide' : 'Show'} Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by guest name, room, title, or description..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2.5 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedFilters && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Status</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => {
|
||||
setFilters({ ...filters, status: e.target.value });
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2.5 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"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="fulfilled">Fulfilled</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Request Type</label>
|
||||
<select
|
||||
value={filters.request_type}
|
||||
onChange={(e) => {
|
||||
setFilters({ ...filters, request_type: e.target.value });
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2.5 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"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{requestTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Priority</label>
|
||||
<select
|
||||
value={filters.priority}
|
||||
onChange={(e) => {
|
||||
setFilters({ ...filters, priority: e.target.value });
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2.5 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"
|
||||
>
|
||||
<option value="">All Priorities</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
<option value="high">High</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Assigned To</label>
|
||||
<select
|
||||
value={filters.assigned_to}
|
||||
onChange={(e) => {
|
||||
setFilters({ ...filters, assigned_to: e.target.value });
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2.5 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"
|
||||
>
|
||||
<option value="">All Staff</option>
|
||||
<option value="unassigned">Unassigned</option>
|
||||
{staffMembers.map((staff) => (
|
||||
<option key={staff.id} value={staff.id}>
|
||||
{staff.full_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilters({
|
||||
status: '',
|
||||
request_type: '',
|
||||
priority: '',
|
||||
assigned_to: '',
|
||||
room_id: '',
|
||||
search: '',
|
||||
});
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 font-medium"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchRequests}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requests List */}
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 overflow-hidden">
|
||||
{requests.length === 0 ? (
|
||||
<div className="p-8 sm:p-12">
|
||||
<EmptyState
|
||||
title="No guest requests found"
|
||||
description="There are no guest requests matching your filters."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Request
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Guest / Room
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Priority
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Assigned To
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Time
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-200">
|
||||
{requests.map((request) => {
|
||||
const typeInfo = getRequestTypeInfo(request.request_type);
|
||||
const TypeIcon = typeInfo.icon;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={request.id}
|
||||
className={`hover:bg-slate-50 transition-colors ${
|
||||
request.priority === 'urgent' && request.status !== 'fulfilled'
|
||||
? 'bg-red-50/50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${typeInfo.color}`}>
|
||||
<TypeIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-900">{request.title}</div>
|
||||
<div className="text-xs text-slate-500 capitalize">{typeInfo.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-slate-900">{request.guest_name || 'N/A'}</div>
|
||||
<div className="text-xs text-slate-500 flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
Room {request.room_number || request.room_id}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border ${getStatusColor(
|
||||
request.status
|
||||
)}`}
|
||||
>
|
||||
{request.status.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border ${getPriorityColor(
|
||||
request.priority
|
||||
)}`}
|
||||
>
|
||||
{request.priority}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm text-slate-500">
|
||||
{request.assigned_staff_name || (
|
||||
<span className="text-yellow-600 font-medium">Unassigned</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm text-slate-500">
|
||||
<div>{formatRelativeTime(request.requested_at)}</div>
|
||||
{request.response_time_minutes && (
|
||||
<div className="text-xs text-slate-400">
|
||||
Response: {request.response_time_minutes}m
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleViewDetails(request)}
|
||||
className="text-blue-600 hover:text-blue-900 p-1.5 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="View details"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
{request.status === 'pending' && !request.assigned_to && (
|
||||
<button
|
||||
onClick={() => handleAssign(request.id)}
|
||||
disabled={updatingRequestId === request.id}
|
||||
className="text-green-600 hover:text-green-900 p-1.5 hover:bg-green-50 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Assign to me"
|
||||
>
|
||||
<UserCheck className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{request.status === 'in_progress' && request.assigned_to === userInfo?.id && (
|
||||
<button
|
||||
onClick={() => handleFulfill(request.id)}
|
||||
disabled={updatingRequestId === request.id}
|
||||
className="text-emerald-600 hover:text-emerald-900 p-1.5 hover:bg-emerald-50 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Mark as fulfilled"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="px-4 sm:px-6 py-4 border-t border-slate-200">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{showDetailModal && selectedRequest && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-slate-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-slate-900">Request Details</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDetailModal(false);
|
||||
setSelectedRequest(null);
|
||||
setStaffNotes('');
|
||||
}}
|
||||
className="text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Request Info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Status</label>
|
||||
<span
|
||||
className={`px-3 py-1 inline-flex text-sm font-semibold rounded-full border ${getStatusColor(
|
||||
selectedRequest.status
|
||||
)}`}
|
||||
>
|
||||
{selectedRequest.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Priority</label>
|
||||
<span
|
||||
className={`px-3 py-1 inline-flex text-sm font-semibold rounded-full border ${getPriorityColor(
|
||||
selectedRequest.priority
|
||||
)}`}
|
||||
>
|
||||
{selectedRequest.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Title</label>
|
||||
<p className="text-slate-900 font-medium">{selectedRequest.title}</p>
|
||||
</div>
|
||||
|
||||
{selectedRequest.description && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Description</label>
|
||||
<p className="text-slate-900 whitespace-pre-wrap">{selectedRequest.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Guest</label>
|
||||
<p className="text-slate-900">{selectedRequest.guest_name || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Room</label>
|
||||
<p className="text-slate-900">Room {selectedRequest.room_number || selectedRequest.room_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Request Type</label>
|
||||
<p className="text-slate-900 capitalize">{selectedRequest.request_type.replace('_', ' ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Requested At</label>
|
||||
<p className="text-slate-900">{formatDate(selectedRequest.requested_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRequest.assigned_staff_name && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Assigned To</label>
|
||||
<p className="text-slate-900">{selectedRequest.assigned_staff_name}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRequest.guest_notes && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Guest Notes</label>
|
||||
<p className="text-slate-900 whitespace-pre-wrap">{selectedRequest.guest_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRequest.staff_notes && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Staff Notes</label>
|
||||
<p className="text-slate-900 whitespace-pre-wrap">{selectedRequest.staff_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRequest.response_time_minutes && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Response Time</label>
|
||||
<p className="text-slate-900">{selectedRequest.response_time_minutes} minutes</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRequest.fulfillment_time_minutes && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Fulfillment Time</label>
|
||||
<p className="text-slate-900">{selectedRequest.fulfillment_time_minutes} minutes</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{selectedRequest.status !== 'fulfilled' && selectedRequest.status !== 'cancelled' && (
|
||||
<div className="pt-4 border-t border-slate-200 space-y-4">
|
||||
{!selectedRequest.assigned_to && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Assign To</label>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
handleAssign(selectedRequest.id, parseInt(e.target.value));
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
|
||||
>
|
||||
<option value="">Select staff member...</option>
|
||||
<option value={userInfo?.id}>Assign to me</option>
|
||||
{staffMembers.map((staff) => (
|
||||
<option key={staff.id} value={staff.id}>
|
||||
{staff.full_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRequest.status === 'in_progress' && selectedRequest.assigned_to === userInfo?.id && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Staff Notes (Optional)</label>
|
||||
<textarea
|
||||
value={staffNotes}
|
||||
onChange={(e) => setStaffNotes(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Add notes about fulfillment..."
|
||||
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleFulfill(selectedRequest.id)}
|
||||
disabled={updatingRequestId === selectedRequest.id}
|
||||
className="mt-2 w-full px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium disabled:opacity-50"
|
||||
>
|
||||
{updatingRequestId === selectedRequest.id ? 'Fulfilling...' : 'Mark as Fulfilled'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRequest.status === 'pending' && selectedRequest.assigned_to && (
|
||||
<button
|
||||
onClick={() => handleStatusChange(selectedRequest.id, 'in_progress')}
|
||||
disabled={updatingRequestId === selectedRequest.id}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium disabled:opacity-50"
|
||||
>
|
||||
Start Working
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GuestRequestManagementPage;
|
||||
|
||||
800
Frontend/src/pages/staff/IncidentComplaintManagementPage.tsx
Normal file
800
Frontend/src/pages/staff/IncidentComplaintManagementPage.tsx
Normal file
@@ -0,0 +1,800 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
Filter,
|
||||
Search,
|
||||
Eye,
|
||||
Edit,
|
||||
MessageSquare,
|
||||
User,
|
||||
Calendar,
|
||||
MapPin,
|
||||
RefreshCw,
|
||||
X,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import EmptyState from '../../shared/components/EmptyState';
|
||||
import { formatDate, formatRelativeTime } from '../../shared/utils/format';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import complaintService, { Complaint, ComplaintUpdate } from '../../features/complaints/services/complaintService';
|
||||
import userService from '../../features/auth/services/userService';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
|
||||
const IncidentComplaintManagementPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [complaints, setComplaints] = useState<Complaint[]>([]);
|
||||
const [selectedComplaint, setSelectedComplaint] = useState<Complaint | null>(null);
|
||||
const [complaintUpdates, setComplaintUpdates] = useState<ComplaintUpdate[]>([]);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [staffMembers, setStaffMembers] = useState<any[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [filters, setFilters] = useState({
|
||||
status: '',
|
||||
priority: '',
|
||||
category: '',
|
||||
assigned_to: '',
|
||||
search: '',
|
||||
});
|
||||
const [expandedFilters, setExpandedFilters] = useState(false);
|
||||
const [updatingComplaintId, setUpdatingComplaintId] = useState<number | null>(null);
|
||||
const [updateForm, setUpdateForm] = useState({
|
||||
description: '',
|
||||
update_type: 'note',
|
||||
});
|
||||
const [resolveForm, setResolveForm] = useState({
|
||||
resolution_notes: '',
|
||||
compensation_amount: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchComplaints();
|
||||
fetchStaffMembers();
|
||||
}, [currentPage, filters.status, filters.priority, filters.category, filters.assigned_to]);
|
||||
|
||||
const fetchComplaints = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params: any = {
|
||||
page: currentPage,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
if (filters.status) params.status = filters.status;
|
||||
if (filters.priority) params.priority = filters.priority;
|
||||
if (filters.category) params.category = filters.category;
|
||||
if (filters.assigned_to) params.assigned_to = parseInt(filters.assigned_to);
|
||||
|
||||
const response = await complaintService.getComplaints(params);
|
||||
|
||||
if (response.status === 'success' && response.data?.complaints) {
|
||||
let filtered = response.data.complaints;
|
||||
|
||||
// Client-side search filter
|
||||
if (filters.search) {
|
||||
const searchLower = filters.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(complaint: Complaint) =>
|
||||
complaint.title?.toLowerCase().includes(searchLower) ||
|
||||
complaint.guest_name?.toLowerCase().includes(searchLower) ||
|
||||
complaint.description?.toLowerCase().includes(searchLower) ||
|
||||
complaint.room_number?.toString().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
setComplaints(filtered);
|
||||
setTotalPages(response.data.pagination?.total_pages || 1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Error fetching complaints', error);
|
||||
toast.error('Failed to load complaints');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStaffMembers = async () => {
|
||||
try {
|
||||
const response = await userService.getUsers({ role: 'staff', limit: 100 });
|
||||
if (response.data?.users) {
|
||||
setStaffMembers(response.data.users);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch staff members', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = async (complaint: Complaint) => {
|
||||
try {
|
||||
const response = await complaintService.getComplaint(complaint.id);
|
||||
if (response.status === 'success' && response.data) {
|
||||
setSelectedComplaint(response.data.complaint);
|
||||
setComplaintUpdates(response.data.updates || []);
|
||||
setShowDetailModal(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to load complaint details');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (complaintId: number, newStatus: string) => {
|
||||
try {
|
||||
setUpdatingComplaintId(complaintId);
|
||||
await complaintService.updateComplaint(complaintId, { status: newStatus });
|
||||
toast.success(`Complaint marked as ${newStatus}`);
|
||||
fetchComplaints();
|
||||
if (selectedComplaint?.id === complaintId) {
|
||||
const updated = await complaintService.getComplaint(complaintId);
|
||||
if (updated.status === 'success') {
|
||||
setSelectedComplaint(updated.data.complaint);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update complaint');
|
||||
} finally {
|
||||
setUpdatingComplaintId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = async (complaintId: number, staffId?: number) => {
|
||||
try {
|
||||
setUpdatingComplaintId(complaintId);
|
||||
await complaintService.updateComplaint(complaintId, { assigned_to: staffId });
|
||||
toast.success('Complaint assigned successfully');
|
||||
fetchComplaints();
|
||||
if (selectedComplaint?.id === complaintId) {
|
||||
const updated = await complaintService.getComplaint(complaintId);
|
||||
if (updated.status === 'success') {
|
||||
setSelectedComplaint(updated.data.complaint);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to assign complaint');
|
||||
} finally {
|
||||
setUpdatingComplaintId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUpdate = async () => {
|
||||
if (!selectedComplaint || !updateForm.description.trim()) {
|
||||
toast.error('Please enter update description');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await complaintService.addComplaintUpdate(selectedComplaint.id, {
|
||||
description: updateForm.description,
|
||||
update_type: updateForm.update_type,
|
||||
});
|
||||
toast.success('Update added successfully');
|
||||
setUpdateForm({ description: '', update_type: 'note' });
|
||||
|
||||
// Refresh complaint details
|
||||
const updated = await complaintService.getComplaint(selectedComplaint.id);
|
||||
if (updated.status === 'success') {
|
||||
setSelectedComplaint(updated.data.complaint);
|
||||
setComplaintUpdates(updated.data.updates || []);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to add update');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResolve = async () => {
|
||||
if (!selectedComplaint || !resolveForm.resolution_notes.trim()) {
|
||||
toast.error('Please enter resolution notes');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUpdatingComplaintId(selectedComplaint.id);
|
||||
await complaintService.resolveComplaint(selectedComplaint.id, {
|
||||
resolution_notes: resolveForm.resolution_notes,
|
||||
compensation_amount: resolveForm.compensation_amount ? parseFloat(resolveForm.compensation_amount) : undefined,
|
||||
});
|
||||
toast.success('Complaint resolved successfully');
|
||||
setResolveForm({ resolution_notes: '', compensation_amount: '' });
|
||||
fetchComplaints();
|
||||
setShowDetailModal(false);
|
||||
setSelectedComplaint(null);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to resolve complaint');
|
||||
} finally {
|
||||
setUpdatingComplaintId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'bg-red-100 text-red-800 border-red-300';
|
||||
case 'in_progress':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-300';
|
||||
case 'resolved':
|
||||
return 'bg-green-100 text-green-800 border-green-300';
|
||||
case 'closed':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'urgent':
|
||||
return 'bg-red-100 text-red-800 border-red-300';
|
||||
case 'high':
|
||||
return 'bg-orange-100 text-orange-800 border-orange-300';
|
||||
case 'medium':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-300';
|
||||
case 'low':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'room_quality':
|
||||
return '🏨';
|
||||
case 'service':
|
||||
return '👥';
|
||||
case 'billing':
|
||||
return '💰';
|
||||
case 'noise':
|
||||
return '🔊';
|
||||
case 'cleanliness':
|
||||
return '🧹';
|
||||
case 'maintenance':
|
||||
return '🔧';
|
||||
default:
|
||||
return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
const openCount = complaints.filter(c => c.status === 'open').length;
|
||||
const inProgressCount = complaints.filter(c => c.status === 'in_progress').length;
|
||||
const urgentCount = complaints.filter(c => c.priority === 'urgent' && c.status !== 'resolved' && c.status !== 'closed').length;
|
||||
|
||||
if (loading && complaints.length === 0) {
|
||||
return <Loading fullScreen text="Loading complaints..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
|
||||
{/* Header */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
|
||||
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-red-400 via-orange-500 to-yellow-600 rounded-full"></div>
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
Incident & Complaint Management
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">
|
||||
Track, assign, and resolve guest complaints and incidents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-5 md:gap-6 mb-6 sm:mb-8">
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-red-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Open</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-red-600">{openCount}</p>
|
||||
</div>
|
||||
<AlertTriangle className="w-8 h-8 sm:w-10 sm:h-10 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-yellow-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">In Progress</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-yellow-600">{inProgressCount}</p>
|
||||
</div>
|
||||
<Clock className="w-8 h-8 sm:w-10 sm:h-10 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-orange-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Urgent</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-orange-600">{urgentCount}</p>
|
||||
</div>
|
||||
<ArrowUp className="w-8 h-8 sm:w-10 sm:h-10 text-orange-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-blue-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Total</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-blue-600">{complaints.length}</p>
|
||||
</div>
|
||||
<AlertTriangle className="w-8 h-8 sm:w-10 sm:h-10 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 mb-6 sm:mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg sm:text-xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Filter className="w-5 h-5 text-blue-600" />
|
||||
Filters
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setExpandedFilters(!expandedFilters)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
{expandedFilters ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
|
||||
{expandedFilters ? 'Hide' : 'Show'} Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by title, guest name, room, or description..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2.5 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedFilters && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Status</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => {
|
||||
setFilters({ ...filters, status: e.target.value });
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2.5 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"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Priority</label>
|
||||
<select
|
||||
value={filters.priority}
|
||||
onChange={(e) => {
|
||||
setFilters({ ...filters, priority: e.target.value });
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2.5 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"
|
||||
>
|
||||
<option value="">All Priorities</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Category</label>
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={(e) => {
|
||||
setFilters({ ...filters, category: e.target.value });
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2.5 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"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="room_quality">Room Quality</option>
|
||||
<option value="service">Service</option>
|
||||
<option value="billing">Billing</option>
|
||||
<option value="noise">Noise</option>
|
||||
<option value="cleanliness">Cleanliness</option>
|
||||
<option value="maintenance">Maintenance</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Assigned To</label>
|
||||
<select
|
||||
value={filters.assigned_to}
|
||||
onChange={(e) => {
|
||||
setFilters({ ...filters, assigned_to: e.target.value });
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2.5 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"
|
||||
>
|
||||
<option value="">All Staff</option>
|
||||
<option value="unassigned">Unassigned</option>
|
||||
{staffMembers.map((staff) => (
|
||||
<option key={staff.id} value={staff.id}>
|
||||
{staff.full_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilters({
|
||||
status: '',
|
||||
priority: '',
|
||||
category: '',
|
||||
assigned_to: '',
|
||||
search: '',
|
||||
});
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 font-medium"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchComplaints}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Complaints List */}
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 overflow-hidden">
|
||||
{complaints.length === 0 ? (
|
||||
<div className="p-8 sm:p-12">
|
||||
<EmptyState
|
||||
title="No complaints found"
|
||||
description="There are no complaints matching your filters."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Complaint
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Guest / Room
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Priority
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Assigned To
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-200">
|
||||
{complaints.map((complaint) => (
|
||||
<tr
|
||||
key={complaint.id}
|
||||
className={`hover:bg-slate-50 transition-colors cursor-pointer ${
|
||||
complaint.priority === 'urgent' && complaint.status !== 'resolved' && complaint.status !== 'closed'
|
||||
? 'bg-red-50/50'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => handleViewDetails(complaint)}
|
||||
>
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{getCategoryIcon(complaint.category)}</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-900">{complaint.title}</div>
|
||||
<div className="text-xs text-slate-500 capitalize">{complaint.category.replace('_', ' ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-slate-900">{complaint.guest_name || 'N/A'}</div>
|
||||
{complaint.room_number && (
|
||||
<div className="text-xs text-slate-500 flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
Room {complaint.room_number}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border ${getStatusColor(
|
||||
complaint.status
|
||||
)}`}
|
||||
>
|
||||
{complaint.status.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border ${getPriorityColor(
|
||||
complaint.priority
|
||||
)}`}
|
||||
>
|
||||
{complaint.priority}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm text-slate-500">
|
||||
{complaint.assigned_staff_name || (
|
||||
<span className="text-yellow-600 font-medium">Unassigned</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm text-slate-500">
|
||||
{formatRelativeTime(complaint.created_at)}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewDetails(complaint);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900 p-1.5 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="View details"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="px-4 sm:px-6 py-4 border-t border-slate-200">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{showDetailModal && selectedComplaint && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-slate-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-slate-900">Complaint Details</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDetailModal(false);
|
||||
setSelectedComplaint(null);
|
||||
setComplaintUpdates([]);
|
||||
setUpdateForm({ description: '', update_type: 'note' });
|
||||
setResolveForm({ resolution_notes: '', compensation_amount: '' });
|
||||
}}
|
||||
className="text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Complaint Info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Status</label>
|
||||
<span
|
||||
className={`px-3 py-1 inline-flex text-sm font-semibold rounded-full border ${getStatusColor(
|
||||
selectedComplaint.status
|
||||
)}`}
|
||||
>
|
||||
{selectedComplaint.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Priority</label>
|
||||
<span
|
||||
className={`px-3 py-1 inline-flex text-sm font-semibold rounded-full border ${getPriorityColor(
|
||||
selectedComplaint.priority
|
||||
)}`}
|
||||
>
|
||||
{selectedComplaint.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Title</label>
|
||||
<p className="text-slate-900 font-medium">{selectedComplaint.title}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Description</label>
|
||||
<p className="text-slate-900 whitespace-pre-wrap">{selectedComplaint.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Guest</label>
|
||||
<p className="text-slate-900">{selectedComplaint.guest_name || 'N/A'}</p>
|
||||
</div>
|
||||
{selectedComplaint.room_number && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Room</label>
|
||||
<p className="text-slate-900">Room {selectedComplaint.room_number}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Category</label>
|
||||
<p className="text-slate-900 capitalize">{selectedComplaint.category.replace('_', ' ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Created At</label>
|
||||
<p className="text-slate-900">{formatDate(selectedComplaint.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedComplaint.assigned_staff_name && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Assigned To</label>
|
||||
<p className="text-slate-900">{selectedComplaint.assigned_staff_name}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Updates Timeline */}
|
||||
{complaintUpdates.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 mb-3">Updates Timeline</label>
|
||||
<div className="space-y-3">
|
||||
{complaintUpdates.map((update) => (
|
||||
<div key={update.id} className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-slate-900">{update.updated_by_name || 'System'}</span>
|
||||
<span className="text-xs text-slate-500">{formatRelativeTime(update.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700">{update.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{selectedComplaint.status !== 'resolved' && selectedComplaint.status !== 'closed' && (
|
||||
<div className="pt-4 border-t border-slate-200 space-y-4">
|
||||
{!selectedComplaint.assigned_to && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Assign To</label>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
handleAssign(selectedComplaint.id, parseInt(e.target.value));
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
|
||||
>
|
||||
<option value="">Select staff member...</option>
|
||||
{staffMembers.map((staff) => (
|
||||
<option key={staff.id} value={staff.id}>
|
||||
{staff.full_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Add Update</label>
|
||||
<textarea
|
||||
value={updateForm.description}
|
||||
onChange={(e) => setUpdateForm({ ...updateForm, description: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="Add an update or note..."
|
||||
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddUpdate}
|
||||
className="mt-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
|
||||
>
|
||||
Add Update
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedComplaint.status !== 'resolved' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Resolution Notes *</label>
|
||||
<textarea
|
||||
value={resolveForm.resolution_notes}
|
||||
onChange={(e) => setResolveForm({ ...resolveForm, resolution_notes: e.target.value })}
|
||||
rows={4}
|
||||
placeholder="Enter resolution details..."
|
||||
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Compensation Amount (Optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={resolveForm.compensation_amount}
|
||||
onChange={(e) => setResolveForm({ ...resolveForm, compensation_amount: e.target.value })}
|
||||
placeholder="0.00"
|
||||
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleResolve}
|
||||
disabled={updatingComplaintId === selectedComplaint.id}
|
||||
className="mt-2 w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium disabled:opacity-50"
|
||||
>
|
||||
{updatingComplaintId === selectedComplaint.id ? 'Resolving...' : 'Resolve Complaint'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(selectedComplaint.id, 'in_progress')}
|
||||
disabled={updatingComplaintId === selectedComplaint.id || selectedComplaint.status === 'in_progress'}
|
||||
className="flex-1 px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 font-medium disabled:opacity-50"
|
||||
>
|
||||
Mark In Progress
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(selectedComplaint.id, 'closed')}
|
||||
disabled={updatingComplaintId === selectedComplaint.id}
|
||||
className="flex-1 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 font-medium disabled:opacity-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedComplaint.status === 'resolved' && selectedComplaint.resolution_notes && (
|
||||
<div className="pt-4 border-t border-slate-200">
|
||||
<label className="block text-sm font-medium text-slate-500 mb-1">Resolution Notes</label>
|
||||
<p className="text-slate-900 whitespace-pre-wrap">{selectedComplaint.resolution_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncidentComplaintManagementPage;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
LogIn,
|
||||
LogOut,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
|
||||
import roomService, { Room } from '../../features/rooms/services/roomService';
|
||||
import serviceService, { Service } from '../../features/hotel_services/services/serviceService';
|
||||
import userService from '../../features/auth/services/userService';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import CurrencyIcon from '../../shared/components/CurrencyIcon';
|
||||
@@ -37,7 +38,7 @@ import { parseDateLocal } from '../../shared/utils/format';
|
||||
import CreateBookingModal from '../../features/hotel_services/components/CreateBookingModal';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
type ReceptionTab = 'overview' | 'check-in' | 'check-out' | 'bookings' | 'rooms' | 'services';
|
||||
type ReceptionTab = 'overview' | 'check-in' | 'check-out' | 'walk-in' | 'bookings' | 'rooms' | 'services';
|
||||
|
||||
interface GuestInfo {
|
||||
name: string;
|
||||
@@ -77,6 +78,27 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
const [discount, setDiscount] = useState(0);
|
||||
const [showInvoice, setShowInvoice] = useState(false);
|
||||
|
||||
// Walk-in booking state
|
||||
const [walkInLoading, setWalkInLoading] = useState(false);
|
||||
const [walkInSearchingRooms, setWalkInSearchingRooms] = useState(false);
|
||||
const [walkInAvailableRooms, setWalkInAvailableRooms] = useState<Room[]>([]);
|
||||
const [walkInForm, setWalkInForm] = useState({
|
||||
guestName: '',
|
||||
guestEmail: '',
|
||||
guestPhone: '',
|
||||
guestIdNumber: '',
|
||||
selectedRoomId: '',
|
||||
checkInDate: new Date().toISOString().split('T')[0],
|
||||
checkOutDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
numGuests: 1,
|
||||
numChildren: 0,
|
||||
specialRequests: '',
|
||||
paymentMethod: 'cash' as 'cash' | 'stripe',
|
||||
paymentStatus: 'unpaid' as 'unpaid' | 'deposit' | 'full',
|
||||
});
|
||||
const [walkInTotalPrice, setWalkInTotalPrice] = useState(0);
|
||||
const [walkInSelectedRoom, setWalkInSelectedRoom] = useState<Room | null>(null);
|
||||
|
||||
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [bookingsLoading, setBookingsLoading] = useState(true);
|
||||
@@ -342,6 +364,139 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
setShowInvoice(false);
|
||||
};
|
||||
|
||||
// Walk-in booking handlers
|
||||
const handleWalkInSearchRooms = async () => {
|
||||
if (!walkInForm.checkInDate || !walkInForm.checkOutDate) {
|
||||
toast.error('Please select check-in and check-out dates');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setWalkInSearchingRooms(true);
|
||||
const response = await roomService.searchAvailableRooms({
|
||||
from: walkInForm.checkInDate,
|
||||
to: walkInForm.checkOutDate,
|
||||
limit: 50,
|
||||
});
|
||||
setWalkInAvailableRooms(response.data.rooms || []);
|
||||
if (response.data.rooms && response.data.rooms.length === 0) {
|
||||
toast.warning('No available rooms for selected dates');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to search available rooms');
|
||||
logger.error('Error searching rooms', error);
|
||||
} finally {
|
||||
setWalkInSearchingRooms(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (walkInForm.checkInDate && walkInForm.checkOutDate && walkInForm.selectedRoomId) {
|
||||
const selectedRoom = walkInAvailableRooms.find(r => r.id.toString() === walkInForm.selectedRoomId);
|
||||
if (selectedRoom) {
|
||||
setWalkInSelectedRoom(selectedRoom);
|
||||
const checkIn = new Date(walkInForm.checkInDate);
|
||||
const checkOut = new Date(walkInForm.checkOutDate);
|
||||
const nights = Math.ceil((checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const roomPrice = selectedRoom.room_type?.base_price || selectedRoom.price || 0;
|
||||
setWalkInTotalPrice(roomPrice * nights);
|
||||
}
|
||||
}
|
||||
}, [walkInForm.checkInDate, walkInForm.checkOutDate, walkInForm.selectedRoomId, walkInAvailableRooms]);
|
||||
|
||||
const handleWalkInBooking = async () => {
|
||||
if (!walkInForm.guestName || !walkInForm.guestPhone || !walkInForm.selectedRoomId) {
|
||||
toast.error('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!walkInSelectedRoom) {
|
||||
toast.error('Please select a room');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setWalkInLoading(true);
|
||||
|
||||
// First, create or find user
|
||||
let userId: number;
|
||||
try {
|
||||
const userResponse = await userService.getUsers({
|
||||
search: walkInForm.guestEmail || walkInForm.guestPhone,
|
||||
role: 'customer',
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (userResponse.data?.users && userResponse.data.users.length > 0) {
|
||||
userId = userResponse.data.users[0].id;
|
||||
} else {
|
||||
// Create new user for walk-in
|
||||
const createUserResponse = await userService.createUser({
|
||||
full_name: walkInForm.guestName,
|
||||
email: walkInForm.guestEmail || `${walkInForm.guestPhone}@walkin.local`,
|
||||
phone_number: walkInForm.guestPhone,
|
||||
password: 'temp123', // Temporary password
|
||||
role: 'customer',
|
||||
});
|
||||
userId = createUserResponse.data.user.id;
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to create/find guest profile');
|
||||
logger.error('Error creating user', error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create booking
|
||||
const bookingData = {
|
||||
user_id: userId,
|
||||
room_id: parseInt(walkInForm.selectedRoomId),
|
||||
check_in_date: walkInForm.checkInDate,
|
||||
check_out_date: walkInForm.checkOutDate,
|
||||
guest_count: walkInForm.numGuests,
|
||||
total_price: walkInTotalPrice,
|
||||
status: 'confirmed',
|
||||
payment_method: walkInForm.paymentMethod,
|
||||
guest_info: {
|
||||
full_name: walkInForm.guestName,
|
||||
email: walkInForm.guestEmail || `${walkInForm.guestPhone}@walkin.local`,
|
||||
phone: walkInForm.guestPhone,
|
||||
},
|
||||
notes: walkInForm.specialRequests || undefined,
|
||||
};
|
||||
|
||||
await bookingService.adminCreateBooking(bookingData);
|
||||
|
||||
toast.success('Walk-in booking created successfully!');
|
||||
|
||||
// Reset form
|
||||
setWalkInForm({
|
||||
guestName: '',
|
||||
guestEmail: '',
|
||||
guestPhone: '',
|
||||
guestIdNumber: '',
|
||||
selectedRoomId: '',
|
||||
checkInDate: new Date().toISOString().split('T')[0],
|
||||
checkOutDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
numGuests: 1,
|
||||
numChildren: 0,
|
||||
specialRequests: '',
|
||||
paymentMethod: 'cash',
|
||||
paymentStatus: 'unpaid',
|
||||
});
|
||||
setWalkInAvailableRooms([]);
|
||||
setWalkInSelectedRoom(null);
|
||||
setWalkInTotalPrice(0);
|
||||
|
||||
// Optionally switch to check-in tab
|
||||
setActiveTab('check-in');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to create walk-in booking');
|
||||
logger.error('Error creating walk-in booking', error);
|
||||
} finally {
|
||||
setWalkInLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const fetchBookings = useCallback(async () => {
|
||||
try {
|
||||
@@ -986,6 +1141,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
{ id: 'overview' as ReceptionTab, label: 'Overview', icon: LogIn },
|
||||
{ id: 'check-in' as ReceptionTab, label: 'Check-in', icon: LogIn },
|
||||
{ id: 'check-out' as ReceptionTab, label: 'Check-out', icon: LogOut },
|
||||
{ id: 'walk-in' as ReceptionTab, label: 'Walk-in Booking', icon: Plus },
|
||||
{ id: 'bookings' as ReceptionTab, label: 'Bookings', icon: Calendar },
|
||||
{ id: 'rooms' as ReceptionTab, label: 'Rooms', icon: Hotel },
|
||||
{ id: 'services' as ReceptionTab, label: 'Services', icon: Wrench },
|
||||
@@ -1947,6 +2103,271 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{}
|
||||
{activeTab === 'walk-in' && (
|
||||
<div className="space-y-8">
|
||||
{walkInLoading && (
|
||||
<Loading fullScreen text="Creating walk-in booking..." />
|
||||
)}
|
||||
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-gradient-to-br from-purple-500/10 to-pink-500/10 border border-purple-200/40">
|
||||
<Plus className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-extrabold text-gray-900">Walk-in Booking</h2>
|
||||
</div>
|
||||
<p className="text-gray-600 text-base max-w-2xl leading-relaxed">
|
||||
Quick booking creation for walk-in guests
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
|
||||
<span className="text-purple-600 font-bold">1</span>
|
||||
</div>
|
||||
Guest Information
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Full Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={walkInForm.guestName}
|
||||
onChange={(e) => setWalkInForm({ ...walkInForm, guestName: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
|
||||
placeholder="Guest full name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Phone Number *</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={walkInForm.guestPhone}
|
||||
onChange={(e) => setWalkInForm({ ...walkInForm, guestPhone: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
|
||||
placeholder="+1234567890"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={walkInForm.guestEmail}
|
||||
onChange={(e) => setWalkInForm({ ...walkInForm, guestEmail: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
|
||||
placeholder="guest@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">ID Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={walkInForm.guestIdNumber}
|
||||
onChange={(e) => setWalkInForm({ ...walkInForm, guestIdNumber: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
|
||||
placeholder="ID/Passport number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
|
||||
<span className="text-purple-600 font-bold">2</span>
|
||||
</div>
|
||||
Booking Details
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Check-in Date *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={walkInForm.checkInDate}
|
||||
onChange={(e) => setWalkInForm({ ...walkInForm, checkInDate: e.target.value })}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Check-out Date *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={walkInForm.checkOutDate}
|
||||
onChange={(e) => setWalkInForm({ ...walkInForm, checkOutDate: e.target.value })}
|
||||
min={walkInForm.checkInDate || new Date().toISOString().split('T')[0]}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Number of Guests *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={walkInForm.numGuests}
|
||||
onChange={(e) => setWalkInForm({ ...walkInForm, numGuests: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Children</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={walkInForm.numChildren}
|
||||
onChange={(e) => setWalkInForm({ ...walkInForm, numChildren: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Special Requests</label>
|
||||
<textarea
|
||||
value={walkInForm.specialRequests}
|
||||
onChange={(e) => setWalkInForm({ ...walkInForm, specialRequests: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
|
||||
placeholder="Any special requests or notes..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleWalkInSearchRooms}
|
||||
disabled={walkInSearchingRooms || !walkInForm.checkInDate || !walkInForm.checkOutDate}
|
||||
className="w-full px-6 py-3.5 bg-gradient-to-r from-purple-500 to-pink-600 text-white font-semibold rounded-xl shadow-xl hover:shadow-2xl transition-all duration-300 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
>
|
||||
{walkInSearchingRooms ? 'Searching...' : 'Search Available Rooms'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
|
||||
<span className="text-purple-600 font-bold">3</span>
|
||||
</div>
|
||||
Select Room
|
||||
</h3>
|
||||
{walkInAvailableRooms.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Hotel className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||
<p className="text-gray-500">Search for available rooms to see options</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{walkInAvailableRooms.map((room) => (
|
||||
<div
|
||||
key={room.id}
|
||||
onClick={() => setWalkInForm({ ...walkInForm, selectedRoomId: room.id.toString() })}
|
||||
className={`p-4 rounded-xl border-2 cursor-pointer transition-all duration-200 ${
|
||||
walkInForm.selectedRoomId === room.id.toString()
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-purple-50/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-bold text-gray-900">Room {room.room_number}</div>
|
||||
<div className="text-sm text-gray-600">{room.room_type?.name}</div>
|
||||
<div className="text-sm font-semibold text-purple-600 mt-1">
|
||||
{formatCurrency(room.room_type?.base_price || room.price || 0)}/night
|
||||
</div>
|
||||
</div>
|
||||
{walkInForm.selectedRoomId === room.id.toString() && (
|
||||
<CheckCircle className="w-6 h-6 text-purple-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{walkInSelectedRoom && (
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
|
||||
<span className="text-purple-600 font-bold">4</span>
|
||||
</div>
|
||||
Payment & Summary
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Room:</span>
|
||||
<span className="font-semibold">Room {walkInSelectedRoom.room_number}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Nights:</span>
|
||||
<span className="font-semibold">
|
||||
{Math.ceil(
|
||||
(new Date(walkInForm.checkOutDate).getTime() -
|
||||
new Date(walkInForm.checkInDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-lg font-bold pt-2 border-t border-gray-200">
|
||||
<span>Total:</span>
|
||||
<span className="text-purple-600">{formatCurrency(walkInTotalPrice)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Payment Method</label>
|
||||
<select
|
||||
value={walkInForm.paymentMethod}
|
||||
onChange={(e) =>
|
||||
setWalkInForm({ ...walkInForm, paymentMethod: e.target.value as 'cash' | 'stripe' })
|
||||
}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
|
||||
>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="stripe">Card/Stripe</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Payment Status</label>
|
||||
<select
|
||||
value={walkInForm.paymentStatus}
|
||||
onChange={(e) =>
|
||||
setWalkInForm({ ...walkInForm, paymentStatus: e.target.value as 'unpaid' | 'deposit' | 'full' })
|
||||
}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700"
|
||||
>
|
||||
<option value="unpaid">Unpaid</option>
|
||||
<option value="deposit">Deposit</option>
|
||||
<option value="full">Full Payment</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleWalkInBooking}
|
||||
disabled={walkInLoading}
|
||||
className="w-full px-6 py-4 bg-gradient-to-r from-purple-500 to-pink-600 text-white font-bold rounded-xl shadow-xl hover:shadow-2xl transition-all duration-300 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
>
|
||||
{walkInLoading ? 'Creating Booking...' : 'Create Walk-in Booking'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{}
|
||||
{activeTab === 'bookings' && (
|
||||
<div className="space-y-8">
|
||||
|
||||
721
Frontend/src/pages/staff/UpsellManagementPage.tsx
Normal file
721
Frontend/src/pages/staff/UpsellManagementPage.tsx
Normal file
@@ -0,0 +1,721 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
Hotel,
|
||||
Sparkles,
|
||||
Search,
|
||||
ArrowUp,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
Plus,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
User,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import EmptyState from '../../shared/components/EmptyState';
|
||||
import { formatDate, formatCurrency } from '../../shared/utils/format';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
|
||||
import roomService, { Room, RoomType } from '../../features/rooms/services/roomService';
|
||||
import serviceService, { Service } from '../../features/hotel_services/services/serviceService';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import apiClient from '../../shared/services/apiClient';
|
||||
|
||||
interface UpsellOpportunity {
|
||||
booking: Booking;
|
||||
upgradeRooms: Room[];
|
||||
availableServices: Service[];
|
||||
}
|
||||
|
||||
type Tab = 'opportunities' | 'room-upgrades' | 'service-upsells';
|
||||
|
||||
const UpsellManagementPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('opportunities');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [showServiceModal, setShowServiceModal] = useState(false);
|
||||
const [availableRooms, setAvailableRooms] = useState<Room[]>([]);
|
||||
const [availableServices, setAvailableServices] = useState<Service[]>([]);
|
||||
const [roomTypes, setRoomTypes] = useState<RoomType[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
status: 'checked_in',
|
||||
});
|
||||
const [selectedUpgradeRoom, setSelectedUpgradeRoom] = useState<Room | null>(null);
|
||||
const [selectedServices, setSelectedServices] = useState<Array<{ service: Service; quantity: number }>>([]);
|
||||
const [upgradePrice, setUpgradePrice] = useState(0);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBookings();
|
||||
fetchServices();
|
||||
fetchRoomTypes();
|
||||
}, [currentPage, filters.status]);
|
||||
|
||||
const fetchBookings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await bookingService.getAllBookings({
|
||||
status: filters.status,
|
||||
page: currentPage,
|
||||
limit: 20,
|
||||
search: filters.search || undefined,
|
||||
});
|
||||
|
||||
if (response.success && response.data?.bookings) {
|
||||
// Filter to only show checked-in bookings for upsells
|
||||
const checkedInBookings = response.data.bookings.filter(
|
||||
(b: Booking) => b.status === 'checked_in'
|
||||
);
|
||||
setBookings(checkedInBookings);
|
||||
setTotalPages(response.data.pagination?.total_pages || 1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Error fetching bookings', error);
|
||||
toast.error('Failed to load bookings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const response = await serviceService.getServices({ is_active: true });
|
||||
if (response.data?.services) {
|
||||
setAvailableServices(response.data.services);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching services', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRoomTypes = async () => {
|
||||
try {
|
||||
const response = await roomService.getRoomTypes();
|
||||
if (response.data?.room_types) {
|
||||
setRoomTypes(response.data.room_types);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching room types', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewUpgradeOptions = async (booking: Booking) => {
|
||||
try {
|
||||
setSelectedBooking(booking);
|
||||
setLoading(true);
|
||||
|
||||
// Find available rooms of better room types
|
||||
const currentRoomType = booking.room?.room_type;
|
||||
if (!currentRoomType) {
|
||||
toast.error('Current room type not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get room types with higher base price
|
||||
const betterRoomTypes = roomTypes.filter(
|
||||
(rt) => rt.base_price > (currentRoomType.base_price || 0)
|
||||
);
|
||||
|
||||
if (betterRoomTypes.length === 0) {
|
||||
toast.info('No upgrade options available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Search for available rooms in better room types
|
||||
const checkIn = new Date(booking.check_in_date);
|
||||
const checkOut = new Date(booking.check_out_date);
|
||||
|
||||
const allAvailableRooms: Room[] = [];
|
||||
for (const roomType of betterRoomTypes) {
|
||||
try {
|
||||
const response = await roomService.searchAvailableRooms({
|
||||
from: checkIn.toISOString().split('T')[0],
|
||||
to: checkOut.toISOString().split('T')[0],
|
||||
room_type_id: roomType.id,
|
||||
capacity: booking.guest_count || 1,
|
||||
});
|
||||
|
||||
if (response.data?.rooms) {
|
||||
allAvailableRooms.push(...response.data.rooms);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching rooms for type ${roomType.id}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
setAvailableRooms(allAvailableRooms);
|
||||
setShowUpgradeModal(true);
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to load upgrade options');
|
||||
logger.error('Error loading upgrade options', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewServiceOptions = (booking: Booking) => {
|
||||
setSelectedBooking(booking);
|
||||
setSelectedServices([]);
|
||||
setShowServiceModal(true);
|
||||
};
|
||||
|
||||
const calculateUpgradePrice = (booking: Booking, newRoom: Room) => {
|
||||
if (!booking.room?.room_type || !newRoom.room_type) return 0;
|
||||
|
||||
const currentPrice = booking.room.room_type.base_price || 0;
|
||||
const newPrice = newRoom.room_type.base_price || 0;
|
||||
const priceDiff = newPrice - currentPrice;
|
||||
|
||||
// Calculate nights
|
||||
const checkIn = new Date(booking.check_in_date);
|
||||
const checkOut = new Date(booking.check_out_date);
|
||||
const nights = Math.ceil((checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return priceDiff * nights;
|
||||
};
|
||||
|
||||
const handleSelectUpgradeRoom = (room: Room) => {
|
||||
if (!selectedBooking) return;
|
||||
setSelectedUpgradeRoom(room);
|
||||
setUpgradePrice(calculateUpgradePrice(selectedBooking, room));
|
||||
};
|
||||
|
||||
const handleApplyUpgrade = async () => {
|
||||
if (!selectedBooking || !selectedUpgradeRoom) {
|
||||
toast.error('Please select a room for upgrade');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await bookingService.updateBooking(selectedBooking.id, {
|
||||
room_id: selectedUpgradeRoom.id,
|
||||
});
|
||||
|
||||
toast.success(
|
||||
`Room upgraded successfully! Guest moved to Room ${selectedUpgradeRoom.room_number}. Additional charge: ${formatCurrency(upgradePrice)}`
|
||||
);
|
||||
setShowUpgradeModal(false);
|
||||
setSelectedBooking(null);
|
||||
setSelectedUpgradeRoom(null);
|
||||
setUpgradePrice(0);
|
||||
fetchBookings();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to apply upgrade');
|
||||
logger.error('Error applying upgrade', error);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddService = (service: Service) => {
|
||||
const existing = selectedServices.find((s) => s.service.id === service.id);
|
||||
if (existing) {
|
||||
setSelectedServices(
|
||||
selectedServices.map((s) =>
|
||||
s.service.id === service.id ? { ...s, quantity: s.quantity + 1 } : s
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setSelectedServices([...selectedServices, { service, quantity: 1 }]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveService = (serviceId: number) => {
|
||||
setSelectedServices(selectedServices.filter((s) => s.service.id !== serviceId));
|
||||
};
|
||||
|
||||
const handleUpdateServiceQuantity = (serviceId: number, quantity: number) => {
|
||||
if (quantity <= 0) {
|
||||
handleRemoveService(serviceId);
|
||||
return;
|
||||
}
|
||||
setSelectedServices(
|
||||
selectedServices.map((s) => (s.service.id === serviceId ? { ...s, quantity } : s))
|
||||
);
|
||||
};
|
||||
|
||||
const calculateServiceTotal = () => {
|
||||
return selectedServices.reduce(
|
||||
(total, item) => total + (item.service.price || 0) * item.quantity,
|
||||
0
|
||||
);
|
||||
};
|
||||
|
||||
const handleApplyServiceUpsell = async () => {
|
||||
if (!selectedBooking || selectedServices.length === 0) {
|
||||
toast.error('Please select at least one service');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
|
||||
// Add services to booking
|
||||
for (const item of selectedServices) {
|
||||
await apiClient.post('/bookings/service-usage', {
|
||||
booking_id: selectedBooking.id,
|
||||
service_id: item.service.id,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.service.price,
|
||||
total_price: item.service.price * item.quantity,
|
||||
});
|
||||
}
|
||||
|
||||
// Update booking total price
|
||||
const serviceTotal = calculateServiceTotal();
|
||||
const newTotal = (selectedBooking.total_price || 0) + serviceTotal;
|
||||
|
||||
await bookingService.updateBooking(selectedBooking.id, {
|
||||
total_price: newTotal,
|
||||
});
|
||||
|
||||
toast.success(`Services added successfully! Total: ${formatCurrency(serviceTotal)}`);
|
||||
setShowServiceModal(false);
|
||||
setSelectedBooking(null);
|
||||
setSelectedServices([]);
|
||||
fetchBookings();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to add services');
|
||||
logger.error('Error adding services', error);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && bookings.length === 0) {
|
||||
return <Loading fullScreen text="Loading upsell opportunities..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
|
||||
{/* Header */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
|
||||
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-green-400 via-emerald-500 to-teal-600 rounded-full"></div>
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
||||
Upsell Management
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">
|
||||
Offer room upgrades and service upsells to increase revenue
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 border-b border-slate-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{[
|
||||
{ id: 'opportunities' as Tab, label: 'Upsell Opportunities', icon: TrendingUp },
|
||||
{ id: 'room-upgrades' as Tab, label: 'Room Upgrades', icon: Hotel },
|
||||
{ id: 'service-upsells' as Tab, label: 'Service Upsells', icon: Sparkles },
|
||||
].map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
flex items-center space-x-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors
|
||||
${
|
||||
activeTab === tab.id
|
||||
? 'border-green-500 text-green-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Opportunities Tab */}
|
||||
{activeTab === 'opportunities' && (
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by booking number, guest name..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
onKeyPress={(e) => e.key === 'Enter' && fetchBookings()}
|
||||
className="w-full pl-10 pr-4 py-2.5 border-2 border-slate-200 rounded-xl focus:border-green-400 focus:ring-4 focus:ring-green-100 transition-all duration-200 text-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchBookings}
|
||||
className="px-6 py-2.5 bg-green-600 text-white rounded-xl hover:bg-green-700 font-medium flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bookings List */}
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 overflow-hidden">
|
||||
{bookings.length === 0 ? (
|
||||
<div className="p-8 sm:p-12">
|
||||
<EmptyState
|
||||
title="No upsell opportunities"
|
||||
description="No checked-in bookings available for upsells."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Booking
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Guest
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Room
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Stay Duration
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-200">
|
||||
{bookings.map((booking) => {
|
||||
const checkIn = new Date(booking.check_in_date);
|
||||
const checkOut = new Date(booking.check_out_date);
|
||||
const nights = Math.ceil(
|
||||
(checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
return (
|
||||
<tr key={booking.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-slate-900">
|
||||
{booking.booking_number}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{formatDate(booking.check_in_date)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-slate-900">
|
||||
{booking.guest_info?.full_name || booking.user?.full_name || 'N/A'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-slate-900">
|
||||
Room {booking.room?.room_number || 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{booking.room?.room_type?.name || 'N/A'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm text-slate-500">
|
||||
{nights} night{nights !== 1 ? 's' : ''}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleViewUpgradeOptions(booking)}
|
||||
className="text-green-600 hover:text-green-900 p-1.5 hover:bg-green-50 rounded-lg transition-colors"
|
||||
title="View upgrade options"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewServiceOptions(booking)}
|
||||
className="text-blue-600 hover:text-blue-900 p-1.5 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="Add services"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="px-4 sm:px-6 py-4 border-t border-slate-200">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Room Upgrade Modal */}
|
||||
{showUpgradeModal && selectedBooking && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-slate-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-slate-900">Room Upgrade Options</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUpgradeModal(false);
|
||||
setSelectedUpgradeRoom(null);
|
||||
setUpgradePrice(0);
|
||||
}}
|
||||
className="text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mt-2">
|
||||
Booking: {selectedBooking.booking_number} | Current Room:{' '}
|
||||
{selectedBooking.room?.room_number} ({selectedBooking.room?.room_type?.name})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{availableRooms.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No upgrade options available"
|
||||
description="No better rooms available for the selected dates."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{availableRooms.map((room) => {
|
||||
const priceDiff = calculateUpgradePrice(selectedBooking, room);
|
||||
const isSelected = selectedUpgradeRoom?.id === room.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={room.id}
|
||||
onClick={() => handleSelectUpgradeRoom(room)}
|
||||
className={`p-4 border-2 rounded-xl cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-slate-200 hover:border-green-300 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-slate-900">
|
||||
Room {room.room_number}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600">{room.room_type?.name}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Floor: {room.floor} | Base Price: {formatCurrency(room.room_type?.base_price || 0)}/night
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-green-600">
|
||||
+{formatCurrency(priceDiff)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">Additional charge</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedUpgradeRoom && (
|
||||
<div className="mt-6 pt-6 border-t border-slate-200">
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-slate-900">Upgrade Summary</span>
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
+{formatCurrency(upgradePrice)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">
|
||||
Guest will be moved to Room {selectedUpgradeRoom.room_number} (
|
||||
{selectedUpgradeRoom.room_type?.name})
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleApplyUpgrade}
|
||||
disabled={processing}
|
||||
className="mt-4 w-full px-6 py-3 bg-green-600 text-white rounded-xl hover:bg-green-700 font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Apply Upgrade
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Upsell Modal */}
|
||||
{showServiceModal && selectedBooking && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-slate-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-slate-900">Add Services</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowServiceModal(false);
|
||||
setSelectedServices([]);
|
||||
}}
|
||||
className="text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mt-2">
|
||||
Booking: {selectedBooking.booking_number} | Guest:{' '}
|
||||
{selectedBooking.guest_info?.full_name || selectedBooking.user?.full_name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{availableServices.map((service) => (
|
||||
<div
|
||||
key={service.id}
|
||||
className="p-4 border-2 border-slate-200 rounded-xl hover:border-blue-300 transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-bold text-slate-900">{service.name}</h3>
|
||||
<span className="text-lg font-bold text-blue-600">
|
||||
{formatCurrency(service.price || 0)}
|
||||
</span>
|
||||
</div>
|
||||
{service.description && (
|
||||
<p className="text-sm text-slate-600 mb-3">{service.description}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleAddService(service)}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
|
||||
>
|
||||
Add Service
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedServices.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-slate-200">
|
||||
<h3 className="font-bold text-lg text-slate-900 mb-4">Selected Services</h3>
|
||||
<div className="space-y-3">
|
||||
{selectedServices.map((item) => (
|
||||
<div
|
||||
key={item.service.id}
|
||||
className="flex items-center justify-between p-4 bg-slate-50 rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-slate-900">{item.service.name}</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
{formatCurrency(item.service.price || 0)} each
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
handleUpdateServiceQuantity(item.service.id, item.quantity - 1)
|
||||
}
|
||||
className="px-2 py-1 bg-slate-200 rounded hover:bg-slate-300"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-8 text-center font-semibold">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleUpdateServiceQuantity(item.service.id, item.quantity + 1)
|
||||
}
|
||||
className="px-2 py-1 bg-slate-200 rounded hover:bg-slate-300"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-right w-24">
|
||||
<div className="font-bold text-slate-900">
|
||||
{formatCurrency((item.service.price || 0) * item.quantity)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveService(item.service.id)}
|
||||
className="text-red-600 hover:text-red-800 p-1"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 pt-4 border-t border-slate-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-lg font-semibold text-slate-900">Total</span>
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
{formatCurrency(calculateServiceTotal())}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleApplyServiceUpsell}
|
||||
disabled={processing}
|
||||
className="w-full px-6 py-3 bg-green-600 text-white rounded-xl hover:bg-green-700 font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Add Services to Booking
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpsellManagementPage;
|
||||
|
||||
@@ -16,6 +16,10 @@ const AnalyticsDashboardPage = lazy(() => import('../pages/staff/AnalyticsDashbo
|
||||
const LoyaltyManagementPage = lazy(() => import('../pages/staff/LoyaltyManagementPage'));
|
||||
const GuestProfilePage = lazy(() => import('../pages/staff/GuestProfilePage'));
|
||||
const AdvancedRoomManagementPage = lazy(() => import('../pages/staff/AdvancedRoomManagementPage'));
|
||||
const GuestRequestManagementPage = lazy(() => import('../pages/staff/GuestRequestManagementPage'));
|
||||
const GuestCommunicationPage = lazy(() => import('../pages/staff/GuestCommunicationPage'));
|
||||
const IncidentComplaintManagementPage = lazy(() => import('../pages/staff/IncidentComplaintManagementPage'));
|
||||
const UpsellManagementPage = lazy(() => import('../pages/staff/UpsellManagementPage'));
|
||||
|
||||
const staffRoutes: RouteObject[] = [
|
||||
{
|
||||
@@ -31,6 +35,10 @@ const staffRoutes: RouteObject[] = [
|
||||
{ path: 'loyalty', element: <LoyaltyManagementPage /> },
|
||||
{ path: 'guest-profiles', element: <GuestProfilePage /> },
|
||||
{ path: 'advanced-rooms', element: <AdvancedRoomManagementPage /> },
|
||||
{ path: 'guest-requests', element: <GuestRequestManagementPage /> },
|
||||
{ path: 'guest-communication', element: <GuestCommunicationPage /> },
|
||||
{ path: 'incidents-complaints', element: <IncidentComplaintManagementPage /> },
|
||||
{ path: 'upsells', element: <UpsellManagementPage /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
MessageCircle,
|
||||
Award,
|
||||
Users,
|
||||
Wrench
|
||||
Wrench,
|
||||
Bell,
|
||||
Mail,
|
||||
AlertTriangle,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { useChatNotifications } from '../../features/notifications/contexts/ChatNotificationContext';
|
||||
@@ -112,6 +116,26 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
|
||||
icon: Users,
|
||||
label: 'Guest Profiles'
|
||||
},
|
||||
{
|
||||
path: '/staff/guest-requests',
|
||||
icon: Bell,
|
||||
label: 'Guest Requests'
|
||||
},
|
||||
{
|
||||
path: '/staff/guest-communication',
|
||||
icon: Mail,
|
||||
label: 'Communication'
|
||||
},
|
||||
{
|
||||
path: '/staff/incidents-complaints',
|
||||
icon: AlertTriangle,
|
||||
label: 'Incidents & Complaints'
|
||||
},
|
||||
{
|
||||
path: '/staff/upsells',
|
||||
icon: TrendingUp,
|
||||
label: 'Upsell Management'
|
||||
},
|
||||
{
|
||||
path: '/staff/advanced-rooms',
|
||||
icon: Wrench,
|
||||
|
||||
@@ -250,14 +250,32 @@ apiClient.interceptors.response.use(
|
||||
|
||||
if (status === 403) {
|
||||
// SECURITY: Sanitize error message - don't expose internal details
|
||||
const rawMessage = (error.response?.data as any)?.message || '';
|
||||
// Only show generic message to users, log details in dev mode
|
||||
const errorMessage = rawMessage.includes('CSRF token')
|
||||
? 'Security validation failed. Please refresh the page and try again.'
|
||||
: 'You do not have permission to access this resource.';
|
||||
const rawMessage = (error.response?.data as any)?.message || (error.response?.data as any)?.detail || '';
|
||||
const errorData = error.response?.data as any;
|
||||
|
||||
// Determine error type for better UX
|
||||
let errorMessage = 'You do not have permission to access this resource.';
|
||||
let shouldRetry = false;
|
||||
|
||||
if (rawMessage.includes('CSRF token') || rawMessage.toLowerCase().includes('csrf')) {
|
||||
errorMessage = 'Security validation failed. Please refresh the page and try again.';
|
||||
shouldRetry = true;
|
||||
} else if (rawMessage.toLowerCase().includes('forbidden') || rawMessage.toLowerCase().includes('permission')) {
|
||||
// Keep generic message for permission errors
|
||||
errorMessage = 'You do not have permission to access this resource.';
|
||||
}
|
||||
|
||||
// Log 403 errors in development for debugging (not in production to avoid information disclosure)
|
||||
if (import.meta.env.DEV) {
|
||||
logDebug('403 Forbidden error', {
|
||||
url: originalRequest?.url,
|
||||
method: originalRequest?.method,
|
||||
rawMessage: rawMessage.substring(0, 100), // Limit length
|
||||
});
|
||||
}
|
||||
|
||||
// Handle CSRF token missing/invalid errors - retry after getting token from error response
|
||||
if (errorMessage.includes('CSRF token') && originalRequest && !originalRequest._retry) {
|
||||
if (shouldRetry && originalRequest && !originalRequest._retry) {
|
||||
// The backend sets the CSRF cookie in the error response, so wait a moment for browser to process it
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user