update
This commit is contained in:
@@ -127,6 +127,7 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
|
||||
|
||||
const checkInDate = watch('checkInDate');
|
||||
const checkOutDate = watch('checkOutDate');
|
||||
const guestCount = watch('guestCount');
|
||||
const paymentMethodForm = watch('paymentMethod');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -134,6 +135,26 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
|
||||
fetchRoomDetails(roomId);
|
||||
fetchServices();
|
||||
fetchBookedDates(roomId);
|
||||
|
||||
// Auto-fill promotion code from sessionStorage or URL
|
||||
try {
|
||||
const storedPromotion = sessionStorage.getItem('activePromotion');
|
||||
if (storedPromotion) {
|
||||
const promo = JSON.parse(storedPromotion);
|
||||
if (promo.code && !promotionCode) {
|
||||
setPromotionCode(promo.code.toUpperCase());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to read promotion from sessionStorage:', error);
|
||||
}
|
||||
|
||||
// Check URL params as fallback
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlPromoCode = urlParams.get('promo');
|
||||
if (urlPromoCode && !promotionCode) {
|
||||
setPromotionCode(urlPromoCode.toUpperCase());
|
||||
}
|
||||
}
|
||||
}, [isOpen, roomId]);
|
||||
|
||||
@@ -251,16 +272,29 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
|
||||
setPromotionError('Please enter a promotion code');
|
||||
return;
|
||||
}
|
||||
if (subtotal === 0) {
|
||||
if (subtotal === 0 || !checkInDate || !checkOutDate) {
|
||||
setPromotionError('Please select dates first');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setValidatingPromotion(true);
|
||||
setPromotionError(null);
|
||||
|
||||
// Format dates as YYYY-MM-DD for backend
|
||||
const checkInDateStr = formatDateLocal(checkInDate);
|
||||
const checkOutDateStr = formatDateLocal(checkOutDate);
|
||||
|
||||
// Get room type ID from room object
|
||||
const roomTypeId = room?.room_type_id || room?.room_type?.id || undefined;
|
||||
|
||||
const response = await promotionService.validatePromotion(
|
||||
promotionCode.toUpperCase().trim(),
|
||||
subtotal
|
||||
subtotal,
|
||||
checkInDateStr,
|
||||
checkOutDateStr,
|
||||
roomTypeId,
|
||||
guestCount || undefined,
|
||||
undefined // is_first_time_customer - will be determined by backend based on user bookings
|
||||
);
|
||||
if (response.success && response.data) {
|
||||
setSelectedPromotion(response.data.promotion);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ChevronRight,
|
||||
ZoomIn,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import {
|
||||
BannerCarousel,
|
||||
@@ -29,6 +30,7 @@ import type { PageContent } from '../services/pageContentService';
|
||||
import type { Service } from '../../hotel_services/services/serviceService';
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
const [featuredRooms, setFeaturedRooms] = useState<Room[]>([]);
|
||||
@@ -41,7 +43,7 @@ const HomePage: React.FC = () => {
|
||||
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
|
||||
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
|
||||
const [, setIsLoadingContent] = useState(true);
|
||||
const [isLoadingServices, setIsLoadingServices] = useState(true);
|
||||
const [, setIsLoadingServices] = useState(true);
|
||||
const [isLoadingBlog, setIsLoadingBlog] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [apiError, setApiError] = useState(false);
|
||||
@@ -49,6 +51,7 @@ const HomePage: React.FC = () => {
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const [lightboxImages, setLightboxImages] = useState<string[]>([]);
|
||||
const [clickedPromotion, setClickedPromotion] = useState<number | null>(null);
|
||||
|
||||
// Prevent body scroll when API error modal is shown
|
||||
useEffect(() => {
|
||||
@@ -104,6 +107,100 @@ const HomePage: React.FC = () => {
|
||||
return Array.from(roomMap.values());
|
||||
}, [featuredRooms, newestRooms]);
|
||||
|
||||
// Enterprise-grade promotion click handler
|
||||
const handlePromotionClick = useCallback((promo: any, index: number, e?: React.MouseEvent) => {
|
||||
// Prevent default if event is provided (for button clicks)
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate promotion data
|
||||
if (!promo) {
|
||||
toast.error('Invalid promotion. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if promotion is still valid
|
||||
if (promo.valid_until) {
|
||||
const validUntil = new Date(promo.valid_until);
|
||||
const now = new Date();
|
||||
if (validUntil < now) {
|
||||
toast.warning('This promotion has expired.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Set loading state for this specific promotion
|
||||
setClickedPromotion(index);
|
||||
|
||||
// Store promotion data in sessionStorage for booking flow
|
||||
const promotionData = {
|
||||
id: promo.code || `promo-${index}`,
|
||||
title: promo.title,
|
||||
description: promo.description,
|
||||
discount: promo.discount,
|
||||
code: promo.code || promo.title?.toUpperCase().replace(/\s+/g, '') || '',
|
||||
valid_until: promo.valid_until,
|
||||
link: promo.link,
|
||||
clicked_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
sessionStorage.setItem('activePromotion', JSON.stringify(promotionData));
|
||||
} catch (storageError) {
|
||||
console.warn('Failed to store promotion data:', storageError);
|
||||
// Continue even if storage fails
|
||||
}
|
||||
|
||||
// Analytics tracking (enterprise-grade)
|
||||
try {
|
||||
if (window.gtag) {
|
||||
window.gtag('event', 'promotion_click', {
|
||||
promotion_title: promo.title,
|
||||
promotion_code: promotionData.code,
|
||||
promotion_discount: promo.discount,
|
||||
});
|
||||
}
|
||||
} catch (analyticsError) {
|
||||
console.warn('Analytics tracking failed:', analyticsError);
|
||||
}
|
||||
|
||||
// Determine navigation target
|
||||
let targetPath = promo.link || '/rooms';
|
||||
|
||||
// Add promotion code to URL for visibility
|
||||
if (targetPath.startsWith('/rooms') || (!targetPath.startsWith('http') && targetPath.startsWith('/'))) {
|
||||
const url = new URL(targetPath, window.location.origin);
|
||||
if (promotionData.code) {
|
||||
url.searchParams.set('promo', promotionData.code);
|
||||
}
|
||||
targetPath = url.pathname + url.search;
|
||||
}
|
||||
|
||||
// Show success message
|
||||
toast.success(`🎉 Promotion "${promo.title}" activated! The code will be applied when you book.`, {
|
||||
autoClose: 4000,
|
||||
});
|
||||
|
||||
// Navigate after a brief delay for UX (allows loading state to show)
|
||||
setTimeout(() => {
|
||||
navigate(targetPath);
|
||||
|
||||
// Reset loading state after navigation
|
||||
setTimeout(() => {
|
||||
setClickedPromotion(null);
|
||||
}, 500);
|
||||
}, 150);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Error handling promotion click:', error);
|
||||
toast.error('An error occurred. Please try again.');
|
||||
setClickedPromotion(null);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices();
|
||||
@@ -1337,62 +1434,6 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Trust Badges Section */}
|
||||
{(pageContent?.sections_enabled?.trust_badges !== false) && pageContent?.trust_badges_enabled && pageContent?.trust_badges && Array.isArray(pageContent.trust_badges) && pageContent.trust_badges.length > 0 && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
|
||||
<div className="text-center mb-8 md:mb-10 animate-fade-in">
|
||||
<div className="inline-block mb-3">
|
||||
<div className="h-0.5 w-16 md:w-20 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
|
||||
</div>
|
||||
{pageContent.trust_badges_section_title && (
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-3 md:mb-4 px-4">
|
||||
{pageContent.trust_badges_section_title}
|
||||
</h2>
|
||||
)}
|
||||
{pageContent.trust_badges_section_subtitle && (
|
||||
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto mt-3 md:mt-4 px-4 leading-relaxed">
|
||||
{pageContent.trust_badges_section_subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6 md:gap-8 px-4">
|
||||
{pageContent.trust_badges.map((badge, index) => (
|
||||
<div key={index} className="flex flex-col items-center text-center group">
|
||||
{badge.link ? (
|
||||
<a href={badge.link} target="_blank" rel="noopener noreferrer" className="w-full">
|
||||
<div className="w-24 h-24 md:w-32 md:h-32 mx-auto mb-4 rounded-lg overflow-hidden bg-white p-4 shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-200 hover:border-[#d4af37]/50 group-hover:scale-105">
|
||||
{badge.logo && (
|
||||
<img src={badge.logo} alt={badge.name} className="w-full h-full object-contain" />
|
||||
)}
|
||||
</div>
|
||||
{badge.name && (
|
||||
<h4 className="text-sm md:text-base font-semibold text-gray-900 mt-2">{badge.name}</h4>
|
||||
)}
|
||||
{badge.description && (
|
||||
<p className="text-xs md:text-sm text-gray-600 mt-1">{badge.description}</p>
|
||||
)}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-24 h-24 md:w-32 md:h-32 mx-auto mb-4 rounded-lg overflow-hidden bg-white p-4 shadow-lg border border-gray-200">
|
||||
{badge.logo && (
|
||||
<img src={badge.logo} alt={badge.name} className="w-full h-full object-contain" />
|
||||
)}
|
||||
</div>
|
||||
{badge.name && (
|
||||
<h4 className="text-sm md:text-base font-semibold text-gray-900 mt-2">{badge.name}</h4>
|
||||
)}
|
||||
{badge.description && (
|
||||
<p className="text-xs md:text-sm text-gray-600 mt-1">{badge.description}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Promotions Section */}
|
||||
{(pageContent?.sections_enabled?.promotions !== false) && pageContent?.promotions_enabled && pageContent?.promotions && Array.isArray(pageContent.promotions) && pageContent.promotions.length > 0 && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
|
||||
@@ -1412,38 +1453,131 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 px-4">
|
||||
{pageContent.promotions.map((promo, index) => (
|
||||
<div key={index} className="relative bg-white rounded-xl shadow-xl overflow-hidden border border-gray-200 hover:shadow-2xl transition-all duration-300 group">
|
||||
{promo.image && (
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<img src={promo.image} alt={promo.title} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
|
||||
{promo.discount && (
|
||||
<div className="absolute top-4 right-4 bg-red-600 text-white px-4 py-2 rounded-full font-bold text-lg shadow-lg">
|
||||
{promo.discount}
|
||||
</div>
|
||||
{pageContent.promotions.map((promo, index) => {
|
||||
const isClicked = clickedPromotion === index;
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
const validUntilDate = promo.valid_until ? new Date(promo.valid_until) : null;
|
||||
if (validUntilDate) {
|
||||
validUntilDate.setHours(23, 59, 59, 999);
|
||||
}
|
||||
const isValid = validUntilDate ? validUntilDate >= now : true;
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (!isValid) {
|
||||
toast.warning('This promotion has expired.');
|
||||
return;
|
||||
}
|
||||
if (!isClicked) {
|
||||
handlePromotionClick(promo, index);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={handleCardClick}
|
||||
className={`relative bg-white rounded-xl shadow-xl overflow-hidden border transition-all duration-300 group ${
|
||||
isValid
|
||||
? 'border-gray-200 hover:shadow-2xl hover:-translate-y-1 cursor-pointer'
|
||||
: 'border-gray-300 opacity-75 cursor-not-allowed'
|
||||
} ${
|
||||
isClicked ? 'opacity-75 pointer-events-none' : ''
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex={isValid ? 0 : -1}
|
||||
onKeyDown={(e) => {
|
||||
if (isValid && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
handleCardClick();
|
||||
}
|
||||
}}
|
||||
aria-label={`${isValid ? 'View' : 'Expired'} promotion: ${promo.title}`}
|
||||
aria-disabled={!isValid}
|
||||
>
|
||||
{promo.image && (
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<img
|
||||
src={promo.image}
|
||||
alt={promo.title || 'Promotion'}
|
||||
className={`w-full h-full object-cover transition-transform duration-500 ${
|
||||
isValid ? 'group-hover:scale-110' : 'grayscale'
|
||||
}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
{promo.discount && (
|
||||
<div className={`absolute top-4 right-4 px-4 py-2 rounded-full font-bold text-lg shadow-lg z-10 ${
|
||||
isValid ? 'bg-red-600 text-white' : 'bg-gray-500 text-white'
|
||||
}`}>
|
||||
{promo.discount}
|
||||
</div>
|
||||
)}
|
||||
{!isValid && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center z-20">
|
||||
<span className="bg-red-600 text-white px-6 py-3 rounded-lg font-bold text-base shadow-xl">
|
||||
EXPIRED
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className={`text-xl font-bold mb-2 transition-colors ${
|
||||
isValid ? 'text-gray-900 group-hover:text-[#d4af37]' : 'text-gray-500'
|
||||
}`}>
|
||||
{promo.title}
|
||||
</h3>
|
||||
{!isValid && (
|
||||
<span className="ml-2 px-2 py-1 bg-gray-200 text-gray-600 text-xs font-semibold rounded">
|
||||
Expired
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{promo.description && (
|
||||
<p className={`mb-4 line-clamp-3 ${isValid ? 'text-gray-600' : 'text-gray-400'}`}>
|
||||
{promo.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">{promo.title}</h3>
|
||||
{promo.description && (
|
||||
<p className="text-gray-600 mb-4">{promo.description}</p>
|
||||
)}
|
||||
{promo.valid_until && (
|
||||
<p className="text-sm text-gray-500 mb-4">Valid until: {new Date(promo.valid_until).toLocaleDateString()}</p>
|
||||
)}
|
||||
{promo.link && (
|
||||
<Link
|
||||
to={promo.link}
|
||||
className="inline-flex items-center gap-2 px-6 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-semibold hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300"
|
||||
{promo.valid_until && (
|
||||
<p className={`text-sm mb-4 ${
|
||||
isValid ? 'text-gray-500' : 'text-red-500 font-medium'
|
||||
}`}>
|
||||
{isValid ? 'Valid until:' : 'Expired on:'} {new Date(promo.valid_until).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isValid) {
|
||||
toast.warning('This promotion has expired.');
|
||||
return;
|
||||
}
|
||||
handlePromotionClick(promo, index, e);
|
||||
}}
|
||||
className={`inline-flex items-center gap-2 px-6 py-2 rounded-lg font-semibold transition-all duration-300 ${
|
||||
isValid
|
||||
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37] cursor-pointer group-hover:shadow-lg'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-disabled={!isValid}
|
||||
>
|
||||
{promo.button_text || 'Learn More'}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
)}
|
||||
<span>{isClicked ? 'Loading...' : (promo.button_text || (isValid ? 'View Offer' : 'Expired'))}</span>
|
||||
{!isClicked && isValid && <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />}
|
||||
{isClicked && (
|
||||
<div className="w-4 h-4 border-2 border-[#0f0f0f] border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,19 @@ export interface Promotion {
|
||||
discount_value: number;
|
||||
min_booking_amount?: number;
|
||||
max_discount_amount?: number;
|
||||
min_stay_days?: number;
|
||||
max_stay_days?: number;
|
||||
advance_booking_days?: number;
|
||||
max_advance_booking_days?: number;
|
||||
allowed_check_in_days?: number[];
|
||||
allowed_check_out_days?: number[];
|
||||
allowed_room_type_ids?: number[];
|
||||
excluded_room_type_ids?: number[];
|
||||
min_guests?: number;
|
||||
max_guests?: number;
|
||||
first_time_customer_only?: boolean;
|
||||
repeat_customer_only?: boolean;
|
||||
blackout_dates?: string[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
usage_limit?: number;
|
||||
@@ -41,6 +54,19 @@ export interface CreatePromotionData {
|
||||
discount_value: number;
|
||||
min_booking_amount?: number;
|
||||
max_discount_amount?: number;
|
||||
min_stay_days?: number;
|
||||
max_stay_days?: number;
|
||||
advance_booking_days?: number;
|
||||
max_advance_booking_days?: number;
|
||||
allowed_check_in_days?: number[];
|
||||
allowed_check_out_days?: number[];
|
||||
allowed_room_type_ids?: number[];
|
||||
excluded_room_type_ids?: number[];
|
||||
min_guests?: number;
|
||||
max_guests?: number;
|
||||
first_time_customer_only?: boolean;
|
||||
repeat_customer_only?: boolean;
|
||||
blackout_dates?: string[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
usage_limit?: number;
|
||||
@@ -55,6 +81,19 @@ export interface UpdatePromotionData {
|
||||
discount_value?: number;
|
||||
min_booking_amount?: number;
|
||||
max_discount_amount?: number;
|
||||
min_stay_days?: number;
|
||||
max_stay_days?: number;
|
||||
advance_booking_days?: number;
|
||||
max_advance_booking_days?: number;
|
||||
allowed_check_in_days?: number[];
|
||||
allowed_check_out_days?: number[];
|
||||
allowed_room_type_ids?: number[];
|
||||
excluded_room_type_ids?: number[];
|
||||
min_guests?: number;
|
||||
max_guests?: number;
|
||||
first_time_customer_only?: boolean;
|
||||
repeat_customer_only?: boolean;
|
||||
blackout_dates?: string[];
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
usage_limit?: number;
|
||||
@@ -106,11 +145,21 @@ export const deletePromotion = async (
|
||||
|
||||
export const validatePromotion = async (
|
||||
code: string,
|
||||
bookingValue: number
|
||||
bookingValue: number,
|
||||
checkInDate?: string,
|
||||
checkOutDate?: string,
|
||||
roomTypeId?: number,
|
||||
guestCount?: number,
|
||||
isFirstTimeCustomer?: boolean
|
||||
): Promise<{ success: boolean; data: { promotion: Promotion; discount: number }; message: string }> => {
|
||||
const response = await apiClient.post('/promotions/validate', {
|
||||
code,
|
||||
booking_value: bookingValue,
|
||||
check_in_date: checkInDate,
|
||||
check_out_date: checkOutDate,
|
||||
room_type_id: roomTypeId,
|
||||
guest_count: guestCount,
|
||||
is_first_time_customer: isFirstTimeCustomer,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -22,10 +22,13 @@ import { useAsync } from '../../shared/hooks/useAsync';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import { getPaymentStatusColor } from '../../shared/utils/paymentUtils';
|
||||
import MfaRequiredBanner from '../../shared/components/MfaRequiredBanner';
|
||||
import { useMfaStatus } from '../../shared/hooks/useMfaStatus';
|
||||
|
||||
const AccountantDashboardPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const navigate = useNavigate();
|
||||
const { requiresMfaButNotEnabled } = useMfaStatus();
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
to: new Date().toISOString().split('T')[0],
|
||||
@@ -235,6 +238,11 @@ const AccountantDashboardPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
|
||||
{/* MFA Required Banner */}
|
||||
{requiresMfaButNotEnabled && (
|
||||
<MfaRequiredBanner securityPagePath="/accountant/security" />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 sm:gap-6 mb-6 sm:mb-8 md:mb-10 animate-fade-in">
|
||||
<div className="w-full lg:w-auto">
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Shield, Activity, LogOut, AlertTriangle, CheckCircle2, Clock } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import accountantSecurityService, { AccountantSession, AccountantActivityLog, MFAStatus } from '../../features/security/services/accountantSecurityService';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import EmptyState from '../../shared/components/EmptyState';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
|
||||
const SecurityManagementPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'sessions' | 'activity' | 'mfa'>('sessions');
|
||||
const [searchParams] = useSearchParams();
|
||||
const setupMfa = searchParams.get('setup_mfa') === 'true';
|
||||
const [activeTab, setActiveTab] = useState<'sessions' | 'activity' | 'mfa'>(setupMfa ? 'mfa' : 'sessions');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sessions, setSessions] = useState<AccountantSession[]>([]);
|
||||
const [activityLogs, setActivityLogs] = useState<AccountantActivityLog[]>([]);
|
||||
@@ -20,6 +23,19 @@ const SecurityManagementPage: React.FC = () => {
|
||||
else if (activeTab === 'mfa') fetchMFAStatus();
|
||||
}, [activeTab]);
|
||||
|
||||
// Auto-switch to MFA tab if setup_mfa query param is present
|
||||
useEffect(() => {
|
||||
if (setupMfa && activeTab !== 'mfa') {
|
||||
setActiveTab('mfa');
|
||||
fetchMFAStatus();
|
||||
// Show toast notification
|
||||
toast.info('Please enable Multi-Factor Authentication to access financial features.', {
|
||||
autoClose: 5000,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setupMfa]);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
@@ -24,8 +24,14 @@ import invoiceService, { Invoice } from '../../features/payments/services/invoic
|
||||
import paymentService from '../../features/payments/services/paymentService';
|
||||
import type { Payment } from '../../features/payments/services/paymentService';
|
||||
import promotionService, { Promotion } from '../../features/loyalty/services/promotionService';
|
||||
import { getRoomTypes } from '../../features/rooms/services/roomService';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
|
||||
interface RoomType {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type BusinessTab = 'overview' | 'invoices' | 'payments' | 'promotions';
|
||||
|
||||
const BusinessDashboardPage: React.FC = () => {
|
||||
@@ -83,11 +89,26 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
discount_value: 0,
|
||||
min_booking_amount: 0,
|
||||
max_discount_amount: 0,
|
||||
min_stay_days: 0,
|
||||
max_stay_days: 0,
|
||||
advance_booking_days: 0,
|
||||
max_advance_booking_days: 0,
|
||||
allowed_check_in_days: [] as number[],
|
||||
allowed_check_out_days: [] as number[],
|
||||
allowed_room_type_ids: [] as number[],
|
||||
excluded_room_type_ids: [] as number[],
|
||||
min_guests: 0,
|
||||
max_guests: 0,
|
||||
first_time_customer_only: false,
|
||||
repeat_customer_only: false,
|
||||
blackout_dates: [] as string[],
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
usage_limit: 0,
|
||||
status: 'active' as 'active' | 'inactive' | 'expired',
|
||||
});
|
||||
|
||||
const [roomTypes, setRoomTypes] = useState<RoomType[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'invoices') {
|
||||
@@ -99,6 +120,21 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
}
|
||||
}, [activeTab, invoiceFilters, invoicesCurrentPage, paymentFilters, paymentsCurrentPage, promotionFilters, promotionsCurrentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoomTypes();
|
||||
}, []);
|
||||
|
||||
const fetchRoomTypes = async () => {
|
||||
try {
|
||||
const response = await getRoomTypes();
|
||||
if (response.success && response.data.room_types) {
|
||||
setRoomTypes(response.data.room_types);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch room types:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'invoices') {
|
||||
setInvoicesCurrentPage(1);
|
||||
@@ -280,11 +316,30 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
const handlePromotionSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
// Prepare data, converting empty arrays to undefined and 0 values to undefined for optional fields
|
||||
const submitData: any = {
|
||||
...promotionFormData,
|
||||
min_stay_days: promotionFormData.min_stay_days || undefined,
|
||||
max_stay_days: promotionFormData.max_stay_days || undefined,
|
||||
advance_booking_days: promotionFormData.advance_booking_days || undefined,
|
||||
max_advance_booking_days: promotionFormData.max_advance_booking_days || undefined,
|
||||
min_guests: promotionFormData.min_guests || undefined,
|
||||
max_guests: promotionFormData.max_guests || undefined,
|
||||
allowed_check_in_days: promotionFormData.allowed_check_in_days?.length ? promotionFormData.allowed_check_in_days : undefined,
|
||||
allowed_check_out_days: promotionFormData.allowed_check_out_days?.length ? promotionFormData.allowed_check_out_days : undefined,
|
||||
allowed_room_type_ids: promotionFormData.allowed_room_type_ids?.length ? promotionFormData.allowed_room_type_ids : undefined,
|
||||
excluded_room_type_ids: promotionFormData.excluded_room_type_ids?.length ? promotionFormData.excluded_room_type_ids : undefined,
|
||||
blackout_dates: promotionFormData.blackout_dates?.length ? promotionFormData.blackout_dates : undefined,
|
||||
min_booking_amount: promotionFormData.min_booking_amount || undefined,
|
||||
max_discount_amount: promotionFormData.max_discount_amount || undefined,
|
||||
usage_limit: promotionFormData.usage_limit || undefined,
|
||||
};
|
||||
|
||||
if (editingPromotion) {
|
||||
await promotionService.updatePromotion(editingPromotion.id, promotionFormData);
|
||||
await promotionService.updatePromotion(editingPromotion.id, submitData);
|
||||
toast.success('Promotion updated successfully');
|
||||
} else {
|
||||
await promotionService.createPromotion(promotionFormData);
|
||||
await promotionService.createPromotion(submitData);
|
||||
toast.success('Promotion added successfully');
|
||||
}
|
||||
setShowPromotionModal(false);
|
||||
@@ -305,6 +360,19 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
discount_value: promotion.discount_value,
|
||||
min_booking_amount: promotion.min_booking_amount || 0,
|
||||
max_discount_amount: promotion.max_discount_amount || 0,
|
||||
min_stay_days: promotion.min_stay_days || 0,
|
||||
max_stay_days: promotion.max_stay_days || 0,
|
||||
advance_booking_days: promotion.advance_booking_days || 0,
|
||||
max_advance_booking_days: promotion.max_advance_booking_days || 0,
|
||||
allowed_check_in_days: promotion.allowed_check_in_days || [],
|
||||
allowed_check_out_days: promotion.allowed_check_out_days || [],
|
||||
allowed_room_type_ids: promotion.allowed_room_type_ids || [],
|
||||
excluded_room_type_ids: promotion.excluded_room_type_ids || [],
|
||||
min_guests: promotion.min_guests || 0,
|
||||
max_guests: promotion.max_guests || 0,
|
||||
first_time_customer_only: promotion.first_time_customer_only || false,
|
||||
repeat_customer_only: promotion.repeat_customer_only || false,
|
||||
blackout_dates: promotion.blackout_dates || [],
|
||||
start_date: promotion.start_date?.split('T')[0] || '',
|
||||
end_date: promotion.end_date?.split('T')[0] || '',
|
||||
usage_limit: promotion.usage_limit || 0,
|
||||
@@ -335,6 +403,19 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
discount_value: 0,
|
||||
min_booking_amount: 0,
|
||||
max_discount_amount: 0,
|
||||
min_stay_days: 0,
|
||||
max_stay_days: 0,
|
||||
advance_booking_days: 0,
|
||||
max_advance_booking_days: 0,
|
||||
allowed_check_in_days: [],
|
||||
allowed_check_out_days: [],
|
||||
allowed_room_type_ids: [],
|
||||
excluded_room_type_ids: [],
|
||||
min_guests: 0,
|
||||
max_guests: 0,
|
||||
first_time_customer_only: false,
|
||||
repeat_customer_only: false,
|
||||
blackout_dates: [],
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
usage_limit: 0,
|
||||
@@ -1041,36 +1122,37 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
{showPromotionModal && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-4">
|
||||
<div className="min-h-full flex items-start justify-center py-8">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-gray-200">
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-purple-100 mb-1">
|
||||
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
|
||||
</h2>
|
||||
<p className="text-purple-200/80 text-sm font-light">
|
||||
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPromotionModal(false)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl text-purple-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-purple-400"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
{}
|
||||
{showPromotionModal && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4">
|
||||
<div className="min-h-full flex items-center justify-center py-4">
|
||||
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-5xl w-full my-4 flex flex-col border border-gray-200" style={{ maxHeight: 'calc(100vh - 2rem)' }}>
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 md:px-8 py-4 sm:py-5 md:py-6 border-b border-slate-700 flex-shrink-0">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-purple-100 mb-1">
|
||||
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
|
||||
</h2>
|
||||
<p className="text-purple-200/80 text-xs sm:text-sm font-light">
|
||||
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="p-8 overflow-y-auto max-h-[calc(100vh-12rem)] custom-scrollbar">
|
||||
<form onSubmit={handlePromotionSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<button
|
||||
onClick={() => setShowPromotionModal(false)}
|
||||
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-purple-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-purple-400"
|
||||
>
|
||||
<X className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<form onSubmit={handlePromotionSubmit} className="p-4 sm:p-6 md:p-8 space-y-4 sm:space-y-5 md:space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
||||
Code <span className="text-red-500">*</span>
|
||||
@@ -1169,6 +1251,258 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enterprise Booking Conditions Section */}
|
||||
<div className="border-t-2 border-purple-200 pt-6 mt-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<div className="h-1 w-8 bg-gradient-to-r from-purple-400 to-purple-600 rounded-full"></div>
|
||||
Enterprise Booking Conditions
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">Configure advanced conditions for when this promotion applies</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
||||
Minimum Stay (nights)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={promotionFormData.min_stay_days || ''}
|
||||
onChange={(e) => setPromotionFormData({ ...promotionFormData, min_stay_days: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
|
||||
min="0"
|
||||
placeholder="0 = no minimum"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Minimum number of nights required for booking</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
||||
Advance Booking (days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={promotionFormData.advance_booking_days || ''}
|
||||
onChange={(e) => setPromotionFormData({ ...promotionFormData, advance_booking_days: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
|
||||
min="0"
|
||||
placeholder="0 = no requirement"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Minimum days in advance the booking must be made</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
||||
Maximum Stay (nights)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={promotionFormData.max_stay_days || ''}
|
||||
onChange={(e) => setPromotionFormData({ ...promotionFormData, max_stay_days: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
|
||||
min="0"
|
||||
placeholder="0 = no maximum"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Maximum number of nights allowed for booking</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
||||
Max Advance Booking (days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={promotionFormData.max_advance_booking_days || ''}
|
||||
onChange={(e) => setPromotionFormData({ ...promotionFormData, max_advance_booking_days: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
|
||||
min="0"
|
||||
placeholder="0 = no maximum"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Maximum days in advance the booking can be made</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
||||
Allowed Check-in Days
|
||||
</label>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, index) => (
|
||||
<label key={day} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={promotionFormData.allowed_check_in_days?.includes(index) || false}
|
||||
onChange={(e) => {
|
||||
const current = promotionFormData.allowed_check_in_days || [];
|
||||
if (e.target.checked) {
|
||||
setPromotionFormData({ ...promotionFormData, allowed_check_in_days: [...current, index] });
|
||||
} else {
|
||||
setPromotionFormData({ ...promotionFormData, allowed_check_in_days: current.filter(d => d !== index) });
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span className="text-xs text-gray-700">{day}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Leave empty to allow all days</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
||||
Allowed Check-out Days
|
||||
</label>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, index) => (
|
||||
<label key={day} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={promotionFormData.allowed_check_out_days?.includes(index) || false}
|
||||
onChange={(e) => {
|
||||
const current = promotionFormData.allowed_check_out_days || [];
|
||||
if (e.target.checked) {
|
||||
setPromotionFormData({ ...promotionFormData, allowed_check_out_days: [...current, index] });
|
||||
} else {
|
||||
setPromotionFormData({ ...promotionFormData, allowed_check_out_days: current.filter(d => d !== index) });
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span className="text-xs text-gray-700">{day}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Leave empty to allow all days</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
||||
Allowed Room Types
|
||||
</label>
|
||||
<select
|
||||
multiple
|
||||
value={promotionFormData.allowed_room_type_ids?.map(String) || []}
|
||||
onChange={(e) => {
|
||||
const selected = Array.from(e.target.selectedOptions, option => parseInt(option.value));
|
||||
setPromotionFormData({ ...promotionFormData, allowed_room_type_ids: selected });
|
||||
}}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
|
||||
size={4}
|
||||
>
|
||||
{roomTypes.map(rt => (
|
||||
<option key={rt.id} value={rt.id}>{rt.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">Hold Ctrl/Cmd to select multiple. Leave empty to allow all room types.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
||||
Excluded Room Types
|
||||
</label>
|
||||
<select
|
||||
multiple
|
||||
value={promotionFormData.excluded_room_type_ids?.map(String) || []}
|
||||
onChange={(e) => {
|
||||
const selected = Array.from(e.target.selectedOptions, option => parseInt(option.value));
|
||||
setPromotionFormData({ ...promotionFormData, excluded_room_type_ids: selected });
|
||||
}}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
|
||||
size={4}
|
||||
>
|
||||
{roomTypes.map(rt => (
|
||||
<option key={rt.id} value={rt.id}>{rt.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">Hold Ctrl/Cmd to select multiple. These room types cannot use this promotion.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
||||
Minimum Guests
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={promotionFormData.min_guests || ''}
|
||||
onChange={(e) => setPromotionFormData({ ...promotionFormData, min_guests: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
|
||||
min="1"
|
||||
placeholder="0 = no minimum"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Minimum number of guests required</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
||||
Maximum Guests
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={promotionFormData.max_guests || ''}
|
||||
onChange={(e) => setPromotionFormData({ ...promotionFormData, max_guests: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
|
||||
min="1"
|
||||
placeholder="0 = no maximum"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Maximum number of guests allowed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={promotionFormData.first_time_customer_only || false}
|
||||
onChange={(e) => setPromotionFormData({ ...promotionFormData, first_time_customer_only: e.target.checked, repeat_customer_only: e.target.checked ? false : promotionFormData.repeat_customer_only })}
|
||||
className="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-gray-700">First-Time Customer Only</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1 ml-8">Only available to first-time customers</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={promotionFormData.repeat_customer_only || false}
|
||||
onChange={(e) => setPromotionFormData({ ...promotionFormData, repeat_customer_only: e.target.checked, first_time_customer_only: e.target.checked ? false : promotionFormData.first_time_customer_only })}
|
||||
className="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-gray-700">Repeat Customer Only</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1 ml-8">Only available to returning customers</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
||||
Blackout Dates
|
||||
</label>
|
||||
<textarea
|
||||
value={promotionFormData.blackout_dates?.join('\n') || ''}
|
||||
onChange={(e) => {
|
||||
const dates = e.target.value.split('\n').filter(d => d.trim()).map(d => d.trim());
|
||||
setPromotionFormData({ ...promotionFormData, blackout_dates: dates });
|
||||
}}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
|
||||
rows={3}
|
||||
placeholder="Enter dates (one per line) in YYYY-MM-DD format Example: 2024-12-25 2024-12-31 2025-01-01"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Dates when promotion doesn't apply. One date per line (YYYY-MM-DD format).</p>
|
||||
</div>
|
||||
|
||||
{/* Dates & Status Section */}
|
||||
<div className="border-t-2 border-purple-200 pt-6 mt-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<div className="h-1 w-8 bg-gradient-to-r from-purple-400 to-purple-600 rounded-full"></div>
|
||||
Promotion Period & Status
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
|
||||
@@ -1224,7 +1558,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-6 border-t border-gray-200">
|
||||
<div className="sticky bottom-0 bg-white border-t border-gray-200 mt-8 -mx-4 sm:-mx-6 md:-mx-8 px-4 sm:px-6 md:px-8 py-4 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPromotionModal(false)}
|
||||
@@ -1239,15 +1573,12 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
{editingPromotion ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2895,167 +2895,6 @@ const PageContentDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust Badges Section */}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-extrabold text-gray-900">Trust Badges Section</h2>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={homeData.trust_badges_enabled ?? false}
|
||||
onChange={(e) => setHomeData({ ...homeData, trust_badges_enabled: e.target.checked })}
|
||||
className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-gray-700">Enable Trust Badges</span>
|
||||
</label>
|
||||
</div>
|
||||
{homeData.trust_badges_enabled && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={homeData.trust_badges_section_title || ''}
|
||||
onChange={(e) => setHomeData({ ...homeData, trust_badges_section_title: e.target.value })}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
|
||||
placeholder="Trusted & Certified"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Subtitle</label>
|
||||
<input
|
||||
type="text"
|
||||
value={homeData.trust_badges_section_subtitle || ''}
|
||||
onChange={(e) => setHomeData({ ...homeData, trust_badges_section_subtitle: e.target.value })}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
|
||||
placeholder="Recognized for excellence"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Trust Badges</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = Array.isArray(homeData.trust_badges) ? homeData.trust_badges : [];
|
||||
setHomeData({
|
||||
...homeData,
|
||||
trust_badges: [...current, { name: '', logo: '', description: '', link: '' }]
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-lg font-semibold hover:from-green-600 hover:to-green-700 transition-all"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Badge
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{Array.isArray(homeData.trust_badges) && homeData.trust_badges.map((badge, index) => (
|
||||
<div key={`badge-${index}`} className="p-6 border-2 border-gray-200 rounded-xl space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-semibold text-gray-900">Badge {index + 1}</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = Array.isArray(homeData.trust_badges) ? homeData.trust_badges : [];
|
||||
const updated = current.filter((_, i) => i !== index);
|
||||
setHomeData({ ...homeData, trust_badges: updated });
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 p-1"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={badge?.name || ''}
|
||||
onChange={(e) => {
|
||||
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
|
||||
current[index] = { ...current[index], name: e.target.value };
|
||||
setHomeData({ ...homeData, trust_badges: current });
|
||||
}}
|
||||
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
|
||||
placeholder="Certification Name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Link (Optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
value={badge?.link || ''}
|
||||
onChange={(e) => {
|
||||
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
|
||||
current[index] = { ...current[index], link: e.target.value };
|
||||
setHomeData({ ...homeData, trust_badges: current });
|
||||
}}
|
||||
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Description (Optional)</label>
|
||||
<textarea
|
||||
value={badge?.description || ''}
|
||||
onChange={(e) => {
|
||||
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
|
||||
current[index] = { ...current[index], description: e.target.value };
|
||||
setHomeData({ ...homeData, trust_badges: current });
|
||||
}}
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
|
||||
placeholder="Brief description"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Logo</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={badge?.logo || ''}
|
||||
onChange={(e) => {
|
||||
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
|
||||
current[index] = { ...current[index], logo: e.target.value };
|
||||
setHomeData({ ...homeData, trust_badges: current });
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border-2 border-gray-200 rounded-lg"
|
||||
placeholder="Logo URL or upload"
|
||||
/>
|
||||
<label className="px-5 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-bold hover:from-green-700 hover:to-green-800 transition-all cursor-pointer flex items-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
await handlePageContentImageUpload(file, (imageUrl) => {
|
||||
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
|
||||
current[index] = { ...current[index], logo: imageUrl };
|
||||
setHomeData({ ...homeData, trust_badges: current });
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!homeData.trust_badges || homeData.trust_badges.length === 0) && (
|
||||
<p className="text-gray-500 text-center py-8">No trust badges added yet. Click "Add Badge" to get started.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Promotions Section */}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, X, Tag } from 'lucide-react';
|
||||
import promotionService, { Promotion } from '../../features/loyalty/services/promotionService';
|
||||
import { getRoomTypes } from '../../features/rooms/services/roomService';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { useCurrency } from '../../features/payments/contexts/CurrencyContext';
|
||||
|
||||
interface RoomType {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const PromotionManagementPage: React.FC = () => {
|
||||
const { currency } = useCurrency();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -32,11 +38,27 @@ const PromotionManagementPage: React.FC = () => {
|
||||
discount_value: 0,
|
||||
min_booking_amount: 0,
|
||||
max_discount_amount: 0,
|
||||
min_stay_days: 0,
|
||||
max_stay_days: 0,
|
||||
advance_booking_days: 0,
|
||||
max_advance_booking_days: 0,
|
||||
allowed_check_in_days: [] as number[],
|
||||
allowed_check_out_days: [] as number[],
|
||||
allowed_room_type_ids: [] as number[],
|
||||
excluded_room_type_ids: [] as number[],
|
||||
min_guests: 0,
|
||||
max_guests: 0,
|
||||
first_time_customer_only: false,
|
||||
repeat_customer_only: false,
|
||||
blackout_dates: [] as string[],
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
usage_limit: 0,
|
||||
status: 'active' as 'active' | 'inactive' | 'expired',
|
||||
});
|
||||
|
||||
const [roomTypes, setRoomTypes] = useState<RoomType[]>([]);
|
||||
const [loadingRoomTypes, setLoadingRoomTypes] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
@@ -46,6 +68,24 @@ const PromotionManagementPage: React.FC = () => {
|
||||
fetchPromotions();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoomTypes();
|
||||
}, []);
|
||||
|
||||
const fetchRoomTypes = async () => {
|
||||
try {
|
||||
setLoadingRoomTypes(true);
|
||||
const response = await getRoomTypes();
|
||||
if (response.success && response.data.room_types) {
|
||||
setRoomTypes(response.data.room_types);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch room types:', error);
|
||||
} finally {
|
||||
setLoadingRoomTypes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPromotions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -69,11 +109,30 @@ const PromotionManagementPage: React.FC = () => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
// Prepare data, converting empty arrays to undefined and 0 values to undefined for optional fields
|
||||
const submitData: any = {
|
||||
...formData,
|
||||
min_stay_days: formData.min_stay_days || undefined,
|
||||
max_stay_days: formData.max_stay_days || undefined,
|
||||
advance_booking_days: formData.advance_booking_days || undefined,
|
||||
max_advance_booking_days: formData.max_advance_booking_days || undefined,
|
||||
min_guests: formData.min_guests || undefined,
|
||||
max_guests: formData.max_guests || undefined,
|
||||
allowed_check_in_days: formData.allowed_check_in_days?.length ? formData.allowed_check_in_days : undefined,
|
||||
allowed_check_out_days: formData.allowed_check_out_days?.length ? formData.allowed_check_out_days : undefined,
|
||||
allowed_room_type_ids: formData.allowed_room_type_ids?.length ? formData.allowed_room_type_ids : undefined,
|
||||
excluded_room_type_ids: formData.excluded_room_type_ids?.length ? formData.excluded_room_type_ids : undefined,
|
||||
blackout_dates: formData.blackout_dates?.length ? formData.blackout_dates : undefined,
|
||||
min_booking_amount: formData.min_booking_amount || undefined,
|
||||
max_discount_amount: formData.max_discount_amount || undefined,
|
||||
usage_limit: formData.usage_limit || undefined,
|
||||
};
|
||||
|
||||
if (editingPromotion) {
|
||||
await promotionService.updatePromotion(editingPromotion.id, formData);
|
||||
await promotionService.updatePromotion(editingPromotion.id, submitData);
|
||||
toast.success('Promotion updated successfully');
|
||||
} else {
|
||||
await promotionService.createPromotion(formData);
|
||||
await promotionService.createPromotion(submitData);
|
||||
toast.success('Promotion added successfully');
|
||||
}
|
||||
setShowModal(false);
|
||||
@@ -94,6 +153,19 @@ const PromotionManagementPage: React.FC = () => {
|
||||
discount_value: promotion.discount_value,
|
||||
min_booking_amount: promotion.min_booking_amount || 0,
|
||||
max_discount_amount: promotion.max_discount_amount || 0,
|
||||
min_stay_days: promotion.min_stay_days || 0,
|
||||
max_stay_days: promotion.max_stay_days || 0,
|
||||
advance_booking_days: promotion.advance_booking_days || 0,
|
||||
max_advance_booking_days: promotion.max_advance_booking_days || 0,
|
||||
allowed_check_in_days: promotion.allowed_check_in_days || [],
|
||||
allowed_check_out_days: promotion.allowed_check_out_days || [],
|
||||
allowed_room_type_ids: promotion.allowed_room_type_ids || [],
|
||||
excluded_room_type_ids: promotion.excluded_room_type_ids || [],
|
||||
min_guests: promotion.min_guests || 0,
|
||||
max_guests: promotion.max_guests || 0,
|
||||
first_time_customer_only: promotion.first_time_customer_only || false,
|
||||
repeat_customer_only: promotion.repeat_customer_only || false,
|
||||
blackout_dates: promotion.blackout_dates || [],
|
||||
start_date: promotion.start_date?.split('T')[0] || '',
|
||||
end_date: promotion.end_date?.split('T')[0] || '',
|
||||
usage_limit: promotion.usage_limit || 0,
|
||||
@@ -124,6 +196,19 @@ const PromotionManagementPage: React.FC = () => {
|
||||
discount_value: 0,
|
||||
min_booking_amount: 0,
|
||||
max_discount_amount: 0,
|
||||
min_stay_days: 0,
|
||||
max_stay_days: 0,
|
||||
advance_booking_days: 0,
|
||||
max_advance_booking_days: 0,
|
||||
allowed_check_in_days: [],
|
||||
allowed_check_out_days: [],
|
||||
allowed_room_type_ids: [],
|
||||
excluded_room_type_ids: [],
|
||||
min_guests: 0,
|
||||
max_guests: 0,
|
||||
first_time_customer_only: false,
|
||||
repeat_customer_only: false,
|
||||
blackout_dates: [],
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
usage_limit: 0,
|
||||
@@ -315,32 +400,30 @@ const PromotionManagementPage: React.FC = () => {
|
||||
|
||||
{}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-4 animate-fade-in">
|
||||
<div className="min-h-full flex items-start justify-center py-8">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200 animate-scale-in">
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-amber-100 mb-1">
|
||||
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
|
||||
</h2>
|
||||
<p className="text-amber-200/80 text-sm font-light">
|
||||
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
|
||||
</p>
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4 animate-fade-in" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
|
||||
<div className="min-h-full flex items-center justify-center py-4 sm:py-8">
|
||||
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-5xl w-full my-8 flex flex-col border border-slate-200 animate-scale-in" style={{ maxHeight: 'calc(100vh - 2rem)' }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 md:px-8 py-4 sm:py-5 md:py-6 border-b border-slate-700 flex-shrink-0">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-amber-100 mb-1">
|
||||
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
|
||||
</h2>
|
||||
<p className="text-amber-200/80 text-xs sm:text-sm font-light">
|
||||
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
|
||||
>
|
||||
<X className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="p-8 overflow-y-auto max-h-[calc(100vh-12rem)] custom-scrollbar">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
||||
<div className="flex-1 overflow-y-auto" style={{ minHeight: 0, maxHeight: 'calc(100vh - 180px)' }}>
|
||||
<form onSubmit={handleSubmit} className="p-4 sm:p-6 md:p-8 space-y-4 sm:space-y-5 md:space-y-6 pb-8">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
@@ -440,6 +523,258 @@ const PromotionManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enterprise Booking Conditions Section */}
|
||||
<div className="border-t-2 border-amber-200 pt-6 mt-6">
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<div className="h-1 w-8 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
Enterprise Booking Conditions
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 mb-6">Configure advanced conditions for when this promotion applies</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Minimum Stay (nights)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.min_stay_days || ''}
|
||||
onChange={(e) => setFormData({ ...formData, min_stay_days: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
min="0"
|
||||
placeholder="0 = no minimum"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Minimum number of nights required for booking</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Advance Booking (days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.advance_booking_days || ''}
|
||||
onChange={(e) => setFormData({ ...formData, advance_booking_days: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
min="0"
|
||||
placeholder="0 = no requirement"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Minimum days in advance the booking must be made</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Maximum Stay (nights)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.max_stay_days || ''}
|
||||
onChange={(e) => setFormData({ ...formData, max_stay_days: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
min="0"
|
||||
placeholder="0 = no maximum"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Maximum number of nights allowed for booking</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Max Advance Booking (days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.max_advance_booking_days || ''}
|
||||
onChange={(e) => setFormData({ ...formData, max_advance_booking_days: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
min="0"
|
||||
placeholder="0 = no maximum"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Maximum days in advance the booking can be made</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Allowed Check-in Days
|
||||
</label>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, index) => (
|
||||
<label key={day} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.allowed_check_in_days?.includes(index) || false}
|
||||
onChange={(e) => {
|
||||
const current = formData.allowed_check_in_days || [];
|
||||
if (e.target.checked) {
|
||||
setFormData({ ...formData, allowed_check_in_days: [...current, index] });
|
||||
} else {
|
||||
setFormData({ ...formData, allowed_check_in_days: current.filter(d => d !== index) });
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
|
||||
/>
|
||||
<span className="text-xs text-slate-700">{day}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">Leave empty to allow all days</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Allowed Check-out Days
|
||||
</label>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, index) => (
|
||||
<label key={day} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.allowed_check_out_days?.includes(index) || false}
|
||||
onChange={(e) => {
|
||||
const current = formData.allowed_check_out_days || [];
|
||||
if (e.target.checked) {
|
||||
setFormData({ ...formData, allowed_check_out_days: [...current, index] });
|
||||
} else {
|
||||
setFormData({ ...formData, allowed_check_out_days: current.filter(d => d !== index) });
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
|
||||
/>
|
||||
<span className="text-xs text-slate-700">{day}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">Leave empty to allow all days</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Allowed Room Types
|
||||
</label>
|
||||
<select
|
||||
multiple
|
||||
value={formData.allowed_room_type_ids?.map(String) || []}
|
||||
onChange={(e) => {
|
||||
const selected = Array.from(e.target.selectedOptions, option => parseInt(option.value));
|
||||
setFormData({ ...formData, allowed_room_type_ids: selected });
|
||||
}}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
size={4}
|
||||
>
|
||||
{roomTypes.map(rt => (
|
||||
<option key={rt.id} value={rt.id}>{rt.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-500 mt-1">Hold Ctrl/Cmd to select multiple. Leave empty to allow all room types.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Excluded Room Types
|
||||
</label>
|
||||
<select
|
||||
multiple
|
||||
value={formData.excluded_room_type_ids?.map(String) || []}
|
||||
onChange={(e) => {
|
||||
const selected = Array.from(e.target.selectedOptions, option => parseInt(option.value));
|
||||
setFormData({ ...formData, excluded_room_type_ids: selected });
|
||||
}}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
size={4}
|
||||
>
|
||||
{roomTypes.map(rt => (
|
||||
<option key={rt.id} value={rt.id}>{rt.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-500 mt-1">Hold Ctrl/Cmd to select multiple. These room types cannot use this promotion.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Minimum Guests
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.min_guests || ''}
|
||||
onChange={(e) => setFormData({ ...formData, min_guests: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
min="1"
|
||||
placeholder="0 = no minimum"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Minimum number of guests required</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Maximum Guests
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.max_guests || ''}
|
||||
onChange={(e) => setFormData({ ...formData, max_guests: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
min="1"
|
||||
placeholder="0 = no maximum"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Maximum number of guests allowed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.first_time_customer_only || false}
|
||||
onChange={(e) => setFormData({ ...formData, first_time_customer_only: e.target.checked, repeat_customer_only: e.target.checked ? false : formData.repeat_customer_only })}
|
||||
className="w-5 h-5 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-slate-700">First-Time Customer Only</span>
|
||||
</label>
|
||||
<p className="text-xs text-slate-500 mt-1 ml-8">Only available to first-time customers</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.repeat_customer_only || false}
|
||||
onChange={(e) => setFormData({ ...formData, repeat_customer_only: e.target.checked, first_time_customer_only: e.target.checked ? false : formData.first_time_customer_only })}
|
||||
className="w-5 h-5 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-slate-700">Repeat Customer Only</span>
|
||||
</label>
|
||||
<p className="text-xs text-slate-500 mt-1 ml-8">Only available to returning customers</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Blackout Dates
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.blackout_dates?.join('\n') || ''}
|
||||
onChange={(e) => {
|
||||
const dates = e.target.value.split('\n').filter(d => d.trim()).map(d => d.trim());
|
||||
setFormData({ ...formData, blackout_dates: dates });
|
||||
}}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
rows={3}
|
||||
placeholder="Enter dates (one per line) in YYYY-MM-DD format Example: 2024-12-25 2024-12-31 2025-01-01"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Dates when promotion doesn't apply. One date per line (YYYY-MM-DD format).</p>
|
||||
</div>
|
||||
|
||||
{/* Dates & Status Section */}
|
||||
<div className="border-t-2 border-amber-200 pt-6 mt-6">
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<div className="h-1 w-8 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
Promotion Period & Status
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
@@ -495,7 +830,7 @@ const PromotionManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-6 border-t border-slate-200">
|
||||
<div className="sticky bottom-0 bg-white border-t border-slate-200 mt-8 -mx-4 sm:-mx-6 md:-mx-8 px-4 sm:px-6 md:px-8 py-4 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
@@ -514,7 +849,6 @@ const PromotionManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,9 @@ import RoomFilter from '../../features/rooms/components/RoomFilter';
|
||||
import RoomCard from '../../features/rooms/components/RoomCard';
|
||||
import RoomCardSkeleton from '../../features/rooms/components/RoomCardSkeleton';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import { ArrowLeft, Hotel, Filter, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { ArrowLeft, Hotel, Filter, ChevronDown, ChevronUp, Tag, X, CheckCircle } from 'lucide-react';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const RoomListPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -23,6 +24,45 @@ const RoomListPage: React.FC = () => {
|
||||
totalPages: 1,
|
||||
});
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const [activePromotion, setActivePromotion] = useState<any>(null);
|
||||
const [showPromotionBanner, setShowPromotionBanner] = useState(false);
|
||||
|
||||
// Check for active promotion from URL or sessionStorage
|
||||
useEffect(() => {
|
||||
const promoCode = searchParams.get('promo');
|
||||
|
||||
// Check sessionStorage first (from homepage promotion click)
|
||||
try {
|
||||
const storedPromotion = sessionStorage.getItem('activePromotion');
|
||||
if (storedPromotion) {
|
||||
const promo = JSON.parse(storedPromotion);
|
||||
setActivePromotion(promo);
|
||||
setShowPromotionBanner(true);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to read promotion from sessionStorage:', error);
|
||||
}
|
||||
|
||||
// Check URL params (fallback)
|
||||
if (promoCode) {
|
||||
setActivePromotion({
|
||||
code: promoCode,
|
||||
title: 'Special Offer',
|
||||
discount: searchParams.get('discount') || '',
|
||||
});
|
||||
setShowPromotionBanner(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleDismissPromotion = () => {
|
||||
setShowPromotionBanner(false);
|
||||
try {
|
||||
sessionStorage.removeItem('activePromotion');
|
||||
} catch (error) {
|
||||
console.warn('Failed to remove promotion from sessionStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -108,6 +148,44 @@ const RoomListPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
|
||||
{}
|
||||
{/* Promotion Banner */}
|
||||
{showPromotionBanner && activePromotion && (
|
||||
<div className="w-full bg-gradient-to-r from-[#d4af37]/20 via-[#f5d76e]/15 to-[#d4af37]/20 border-b border-[#d4af37]/30">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-4">
|
||||
<div className="flex items-center justify-between gap-4 bg-gradient-to-r from-[#1a1a1a] to-[#0f0f0f] border border-[#d4af37]/40 rounded-lg p-4 backdrop-blur-xl shadow-lg">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="p-2 bg-[#d4af37]/20 rounded-lg border border-[#d4af37]/40">
|
||||
<Tag className="w-5 h-5 text-[#d4af37]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
<span className="text-sm font-semibold text-[#d4af37]">
|
||||
Active Promotion: {activePromotion.code || activePromotion.title}
|
||||
</span>
|
||||
</div>
|
||||
{activePromotion.discount && (
|
||||
<p className="text-xs text-gray-300">
|
||||
{activePromotion.discount} - {activePromotion.description || 'Valid on bookings'}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
The promotion code will be automatically applied when you book a room
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismissPromotion}
|
||||
className="p-2 hover:bg-[#d4af37]/10 rounded-lg transition-colors"
|
||||
aria-label="Dismiss promotion"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-400 hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] border-b border-[#d4af37]/10 pt-6 sm:pt-7 md:pt-8">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-6 sm:py-7 md:py-8 lg:py-10">
|
||||
{}
|
||||
|
||||
102
Frontend/src/shared/components/MfaRequiredBanner.tsx
Normal file
102
Frontend/src/shared/components/MfaRequiredBanner.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, Shield, ArrowRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
|
||||
interface MfaRequiredBannerProps {
|
||||
/**
|
||||
* Role-specific security page path
|
||||
* e.g., '/accountant/security' or '/admin/security'
|
||||
*/
|
||||
securityPagePath: string;
|
||||
/**
|
||||
* Optional custom message
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* Optional callback when banner is dismissed
|
||||
*/
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* MFA Required Banner Component
|
||||
*
|
||||
* Displays a warning banner when MFA is required but not enabled.
|
||||
* Used on dashboards for roles that require MFA for financial access.
|
||||
*/
|
||||
const MfaRequiredBanner: React.FC<MfaRequiredBannerProps> = ({
|
||||
securityPagePath,
|
||||
message,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { userInfo } = useAuthStore();
|
||||
const [dismissed, setDismissed] = React.useState(false);
|
||||
|
||||
// Check if MFA is required for this role
|
||||
const isFinancialRole = userInfo?.role === 'accountant' || userInfo?.role === 'admin';
|
||||
|
||||
if (!isFinancialRole || dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSetupMfa = () => {
|
||||
navigate(`${securityPagePath}?setup_mfa=true`);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setDismissed(true);
|
||||
if (onDismiss) {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-amber-50 via-yellow-50 to-amber-50 border-l-4 border-amber-500 rounded-lg shadow-md mb-6 p-4 sm:p-5">
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-amber-100 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 sm:w-6 sm:h-6 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-amber-900 mb-1 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
Multi-Factor Authentication Required
|
||||
</h3>
|
||||
<p className="text-sm sm:text-base text-amber-800 mb-3">
|
||||
{message ||
|
||||
'Multi-factor authentication is required for financial access. Please enable MFA to continue using financial features.'}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleSetupMfa}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
Enable MFA Now
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 text-amber-600 hover:text-amber-800 transition-colors p-1"
|
||||
aria-label="Dismiss banner"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MfaRequiredBanner;
|
||||
52
Frontend/src/shared/hooks/useMfaStatus.ts
Normal file
52
Frontend/src/shared/hooks/useMfaStatus.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import accountantSecurityService, { MFAStatus } from '../../features/security/services/accountantSecurityService';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
|
||||
/**
|
||||
* Hook to fetch and check MFA status for accountant/admin users
|
||||
*/
|
||||
export const useMfaStatus = () => {
|
||||
const { userInfo } = useAuthStore();
|
||||
const [mfaStatus, setMfaStatus] = useState<MFAStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isFinancialRole = userInfo?.role === 'accountant' || userInfo?.role === 'admin';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFinancialRole) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchMfaStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await accountantSecurityService.getMFAStatus();
|
||||
setMfaStatus(response.data);
|
||||
} catch (err: any) {
|
||||
// Silently fail - MFA status check shouldn't block dashboard
|
||||
setError(err.response?.data?.detail || 'Failed to load MFA status');
|
||||
setMfaStatus(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMfaStatus();
|
||||
}, [isFinancialRole, userInfo?.role]);
|
||||
|
||||
const requiresMfaButNotEnabled = mfaStatus
|
||||
? (mfaStatus.requires_mfa || mfaStatus.is_enforced) && !mfaStatus.mfa_enabled
|
||||
: false;
|
||||
|
||||
return {
|
||||
mfaStatus,
|
||||
loading,
|
||||
error,
|
||||
requiresMfaButNotEnabled,
|
||||
isFinancialRole,
|
||||
};
|
||||
};
|
||||
|
||||
235
Frontend/src/shared/hooks/usePermissions.ts
Normal file
235
Frontend/src/shared/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useMemo } from 'react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
|
||||
/**
|
||||
* Permission constants matching backend ROLE_PERMISSIONS
|
||||
* These are used for frontend UI/UX (showing/hiding elements)
|
||||
* Backend enforces actual permissions for API access
|
||||
*/
|
||||
const ROLE_PERMISSIONS: Record<string, Set<string>> = {
|
||||
admin: new Set([
|
||||
'financial.view_reports',
|
||||
'financial.manage_invoices',
|
||||
'financial.manage_payments',
|
||||
'financial.manage_settings',
|
||||
'financial.high_risk_approve',
|
||||
'users.manage',
|
||||
'housekeeping.view_tasks',
|
||||
'housekeeping.manage_tasks',
|
||||
'housekeeping.view_rooms',
|
||||
'bookings.manage',
|
||||
'rooms.manage',
|
||||
]),
|
||||
accountant: new Set([
|
||||
'financial.view_reports',
|
||||
'financial.manage_invoices',
|
||||
'financial.manage_payments',
|
||||
'financial.manage_settings',
|
||||
'financial.high_risk_approve',
|
||||
]),
|
||||
accountant_readonly: new Set([
|
||||
'financial.view_reports',
|
||||
'financial.view_invoices',
|
||||
'financial.view_payments',
|
||||
'financial.view_audit_trail',
|
||||
]),
|
||||
accountant_operator: new Set([
|
||||
'financial.view_reports',
|
||||
'financial.view_invoices',
|
||||
'financial.view_payments',
|
||||
'financial.manage_invoices',
|
||||
'financial.manage_payments',
|
||||
]),
|
||||
accountant_approver: new Set([
|
||||
'financial.view_reports',
|
||||
'financial.view_invoices',
|
||||
'financial.view_payments',
|
||||
'financial.high_risk_approve',
|
||||
]),
|
||||
staff: new Set([
|
||||
'financial.view_invoices',
|
||||
'financial.manage_invoices',
|
||||
'financial.view_payments',
|
||||
'housekeeping.view_tasks',
|
||||
'housekeeping.manage_tasks',
|
||||
'housekeeping.view_rooms',
|
||||
'bookings.manage',
|
||||
'rooms.manage',
|
||||
]),
|
||||
housekeeping: new Set([
|
||||
'housekeeping.view_tasks',
|
||||
'housekeeping.manage_tasks',
|
||||
'housekeeping.create_tasks',
|
||||
'housekeeping.view_rooms',
|
||||
'housekeeping.upload_photos',
|
||||
'housekeeping.report_issues',
|
||||
'housekeeping.view_inspections',
|
||||
'housekeeping.manage_inspections',
|
||||
]),
|
||||
customer: new Set([
|
||||
'bookings.view_own',
|
||||
'bookings.create',
|
||||
'rooms.view',
|
||||
'profile.manage',
|
||||
]),
|
||||
};
|
||||
|
||||
/**
|
||||
* Get permissions for a role
|
||||
*/
|
||||
const getRolePermissions = (roleName: string | undefined | null): Set<string> => {
|
||||
if (!roleName) return new Set();
|
||||
return ROLE_PERMISSIONS[roleName] || new Set();
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission
|
||||
*/
|
||||
const hasPermission = (roleName: string | undefined | null, permission: string): boolean => {
|
||||
const permissions = getRolePermissions(roleName);
|
||||
return permissions.has(permission);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has any of the given permissions
|
||||
*/
|
||||
const hasAnyPermission = (
|
||||
roleName: string | undefined | null,
|
||||
permissions: string[]
|
||||
): boolean => {
|
||||
if (!permissions.length) return true;
|
||||
const rolePerms = getRolePermissions(roleName);
|
||||
return permissions.some(perm => rolePerms.has(perm));
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has all of the given permissions
|
||||
*/
|
||||
const hasAllPermissions = (
|
||||
roleName: string | undefined | null,
|
||||
permissions: string[]
|
||||
): boolean => {
|
||||
if (!permissions.length) return true;
|
||||
const rolePerms = getRolePermissions(roleName);
|
||||
return permissions.every(perm => rolePerms.has(perm));
|
||||
};
|
||||
|
||||
/**
|
||||
* Main hook to check permissions
|
||||
*/
|
||||
export const usePermissions = () => {
|
||||
const { userInfo } = useAuthStore();
|
||||
const roleName = userInfo?.role;
|
||||
|
||||
const permissions = useMemo(() => {
|
||||
return getRolePermissions(roleName);
|
||||
}, [roleName]);
|
||||
|
||||
return {
|
||||
roleName,
|
||||
permissions,
|
||||
hasPermission: (permission: string) => hasPermission(roleName, permission),
|
||||
hasAnyPermission: (permissions: string[]) => hasAnyPermission(roleName, permissions),
|
||||
hasAllPermissions: (permissions: string[]) => hasAllPermissions(roleName, permissions),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check if user can create invoices
|
||||
*/
|
||||
export const useCanCreateInvoices = (): boolean => {
|
||||
const { roleName, hasPermission } = usePermissions();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!roleName) return false;
|
||||
// Admin, staff, accountant, accountant_operator can create invoices
|
||||
return ['admin', 'staff', 'accountant', 'accountant_operator'].includes(roleName);
|
||||
}, [roleName]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check if user can manage users
|
||||
*/
|
||||
export const useCanManageUsers = (): boolean => {
|
||||
const { roleName } = usePermissions();
|
||||
return roleName === 'admin';
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check if user can manage housekeeping tasks
|
||||
*/
|
||||
export const useCanManageHousekeepingTasks = (): boolean => {
|
||||
const { hasPermission } = usePermissions();
|
||||
return hasPermission('housekeeping.manage_tasks');
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check if user can view financial reports
|
||||
*/
|
||||
export const useCanViewFinancialReports = (): boolean => {
|
||||
const { hasPermission } = usePermissions();
|
||||
return hasPermission('financial.view_reports');
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check if user can manage invoices
|
||||
*/
|
||||
export const useCanManageInvoices = (): boolean => {
|
||||
const { hasPermission } = usePermissions();
|
||||
return hasPermission('financial.manage_invoices');
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check if user can manage payments
|
||||
*/
|
||||
export const useCanManagePayments = (): boolean => {
|
||||
const { hasPermission } = usePermissions();
|
||||
return hasPermission('financial.manage_payments');
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check if user can access financial features
|
||||
*/
|
||||
export const useCanAccessFinancial = (): boolean => {
|
||||
const { roleName, hasPermission } = usePermissions();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!roleName) return false;
|
||||
// Any permission starting with 'financial.' indicates financial access
|
||||
return ['admin', 'staff', 'accountant', 'accountant_readonly', 'accountant_operator', 'accountant_approver']
|
||||
.includes(roleName) || hasPermission('financial.view_reports');
|
||||
}, [roleName, hasPermission]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check if user can manage bookings
|
||||
*/
|
||||
export const useCanManageBookings = (): boolean => {
|
||||
const { roleName, hasPermission } = usePermissions();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!roleName) return false;
|
||||
return ['admin', 'staff'].includes(roleName) || hasPermission('bookings.manage');
|
||||
}, [roleName, hasPermission]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check if user can manage rooms
|
||||
*/
|
||||
export const useCanManageRooms = (): boolean => {
|
||||
const { roleName, hasPermission } = usePermissions();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!roleName) return false;
|
||||
return ['admin', 'staff'].includes(roleName) || hasPermission('rooms.manage');
|
||||
}, [roleName, hasPermission]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check if user can view all housekeeping tasks (not just assigned)
|
||||
*/
|
||||
export const useCanViewAllHousekeepingTasks = (): boolean => {
|
||||
const { roleName } = usePermissions();
|
||||
return roleName === 'admin' || roleName === 'staff';
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { toast } from 'react-toastify';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { logSecurityWarning, logDebug, logWarn } from '../utils/errorReporter';
|
||||
|
||||
@@ -257,7 +258,61 @@ apiClient.interceptors.response.use(
|
||||
let errorMessage = 'You do not have permission to access this resource.';
|
||||
let shouldRetry = false;
|
||||
|
||||
if (rawMessage.includes('CSRF token') || rawMessage.toLowerCase().includes('csrf')) {
|
||||
// Check for MFA requirement error
|
||||
const isMfaRequired =
|
||||
errorData?.error === 'mfa_required' ||
|
||||
errorData?.requires_mfa_setup === true ||
|
||||
(typeof errorData === 'object' && errorData?.detail &&
|
||||
(typeof errorData.detail === 'string' && errorData.detail.includes('Multi-factor authentication is required')) ||
|
||||
(typeof errorData.detail === 'object' && errorData.detail?.error === 'mfa_required'));
|
||||
|
||||
if (isMfaRequired) {
|
||||
// Get user info to determine redirect path
|
||||
try {
|
||||
const userInfoStr = localStorage.getItem('userInfo');
|
||||
if (userInfoStr) {
|
||||
const userInfo = JSON.parse(userInfoStr);
|
||||
const userRole = userInfo?.role;
|
||||
|
||||
// Redirect to appropriate security page based on role
|
||||
if (userRole === 'accountant') {
|
||||
errorMessage = 'Multi-factor authentication is required for financial access. Redirecting to setup...';
|
||||
toast.error('Multi-factor authentication is required for financial access. Please enable MFA to continue.');
|
||||
|
||||
// Use setTimeout to allow error to be logged before redirect
|
||||
setTimeout(() => {
|
||||
window.location.href = '/accountant/security?setup_mfa=true&redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 1000);
|
||||
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: errorMessage,
|
||||
requiresMfaSetup: true,
|
||||
redirectPath: '/accountant/security?setup_mfa=true',
|
||||
});
|
||||
} else if (userRole === 'admin') {
|
||||
errorMessage = 'Multi-factor authentication is required for financial access. Redirecting to setup...';
|
||||
toast.error('Multi-factor authentication is required for financial access. Please enable MFA to continue.');
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = '/admin/security?setup_mfa=true&redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 1000);
|
||||
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: errorMessage,
|
||||
requiresMfaSetup: true,
|
||||
redirectPath: '/admin/security?setup_mfa=true',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logDebug('Error parsing userInfo for MFA redirect', { error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
|
||||
// Fallback message if we can't determine role
|
||||
errorMessage = 'Multi-factor authentication is required. Please enable MFA in your security settings.';
|
||||
} else if (rawMessage.includes('CSRF token') || rawMessage.toLowerCase().includes('csrf')) {
|
||||
errorMessage = 'Security validation failed. Please refresh the page and try again.';
|
||||
shouldRetry = true;
|
||||
} else if (rawMessage.toLowerCase().includes('forbidden') || rawMessage.toLowerCase().includes('permission')) {
|
||||
@@ -271,6 +326,7 @@ apiClient.interceptors.response.use(
|
||||
url: originalRequest?.url,
|
||||
method: originalRequest?.method,
|
||||
rawMessage: rawMessage.substring(0, 100), // Limit length
|
||||
isMfaRequired,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
176
Frontend/src/shared/utils/errorHandler.ts
Normal file
176
Frontend/src/shared/utils/errorHandler.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Centralized error handling utilities
|
||||
* Provides consistent error handling and user feedback across the application
|
||||
*/
|
||||
import { toast } from 'react-toastify';
|
||||
import { AxiosError } from 'axios';
|
||||
import { logger } from './logger';
|
||||
|
||||
export interface ErrorDetails {
|
||||
message: string;
|
||||
errors?: Array<{ field?: string; message: string }>;
|
||||
statusCode?: number;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from various error types
|
||||
*/
|
||||
export const extractErrorMessage = (error: any): string => {
|
||||
// Handle Axios errors
|
||||
if (error?.response?.data) {
|
||||
const data = error.response.data;
|
||||
|
||||
// Check for standard error response format
|
||||
if (data.message) {
|
||||
return data.message;
|
||||
}
|
||||
|
||||
// Check for detail field
|
||||
if (data.detail) {
|
||||
return typeof data.detail === 'string'
|
||||
? data.detail
|
||||
: data.detail?.message || 'An error occurred';
|
||||
}
|
||||
|
||||
// Check for errors array
|
||||
if (data.errors && Array.isArray(data.errors) && data.errors.length > 0) {
|
||||
return data.errors[0].message || data.errors[0].detail || 'Validation error';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle standard Error objects
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
// Handle string errors
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return 'An unexpected error occurred. Please try again.';
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract error details for logging/debugging
|
||||
*/
|
||||
export const extractErrorDetails = (error: any): ErrorDetails => {
|
||||
const message = extractErrorMessage(error);
|
||||
const statusCode = error?.response?.status;
|
||||
const requestId = error?.response?.headers?.['x-request-id'] || error?.requestId;
|
||||
|
||||
let errors: Array<{ field?: string; message: string }> | undefined;
|
||||
|
||||
if (error?.response?.data?.errors && Array.isArray(error.response.data.errors)) {
|
||||
errors = error.response.data.errors.map((err: any) => ({
|
||||
field: err.field || err.path,
|
||||
message: err.message || 'Invalid value',
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
message,
|
||||
errors,
|
||||
statusCode,
|
||||
requestId,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle error with user feedback
|
||||
* Shows toast notification and optionally logs error
|
||||
*/
|
||||
export const handleError = (
|
||||
error: any,
|
||||
options?: {
|
||||
defaultMessage?: string;
|
||||
showToast?: boolean;
|
||||
logError?: boolean;
|
||||
toastOptions?: any;
|
||||
}
|
||||
): ErrorDetails => {
|
||||
const {
|
||||
defaultMessage = 'An error occurred. Please try again.',
|
||||
showToast = true,
|
||||
logError = true,
|
||||
toastOptions = {},
|
||||
} = options || {};
|
||||
|
||||
const errorDetails = extractErrorDetails(error);
|
||||
const displayMessage = errorDetails.message || defaultMessage;
|
||||
|
||||
// Log error for debugging (only in development or if explicitly enabled)
|
||||
if (logError && (import.meta.env.DEV || import.meta.env.MODE === 'development')) {
|
||||
logger.error('Error occurred', {
|
||||
message: displayMessage,
|
||||
statusCode: errorDetails.statusCode,
|
||||
requestId: errorDetails.requestId,
|
||||
error: error instanceof Error ? error.stack : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// Show user-friendly toast notification
|
||||
if (showToast) {
|
||||
toast.error(displayMessage, {
|
||||
autoClose: 5000,
|
||||
...toastOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return errorDetails;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle error silently (no toast, only logging)
|
||||
*/
|
||||
export const handleErrorSilently = (error: any): ErrorDetails => {
|
||||
return handleError(error, { showToast: false, logError: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle error with custom message
|
||||
*/
|
||||
export const handleErrorWithMessage = (
|
||||
error: any,
|
||||
customMessage: string
|
||||
): ErrorDetails => {
|
||||
const errorDetails = extractErrorDetails(error);
|
||||
|
||||
toast.error(customMessage, {
|
||||
autoClose: 5000,
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
logger.error('Error occurred', {
|
||||
customMessage,
|
||||
originalError: errorDetails.message,
|
||||
statusCode: errorDetails.statusCode,
|
||||
requestId: errorDetails.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
return errorDetails;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle validation errors specifically
|
||||
*/
|
||||
export const handleValidationError = (error: any): ErrorDetails => {
|
||||
const errorDetails = extractErrorDetails(error);
|
||||
|
||||
if (errorDetails.errors && errorDetails.errors.length > 0) {
|
||||
// Show first validation error
|
||||
toast.error(errorDetails.errors[0].message || 'Please check your input and try again.', {
|
||||
autoClose: 5000,
|
||||
});
|
||||
} else {
|
||||
toast.error(errorDetails.message || 'Please check your input and try again.', {
|
||||
autoClose: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
return errorDetails;
|
||||
};
|
||||
|
||||
144
Frontend/src/shared/utils/permissionHelpers.ts
Normal file
144
Frontend/src/shared/utils/permissionHelpers.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Permission helper utilities
|
||||
* Mirror backend permission structure for frontend UI/UX
|
||||
*
|
||||
* NOTE: These are for UI purposes only. Backend enforces actual permissions.
|
||||
*/
|
||||
|
||||
export type Permission =
|
||||
// Financial permissions
|
||||
| 'financial.view_reports'
|
||||
| 'financial.manage_invoices'
|
||||
| 'financial.manage_payments'
|
||||
| 'financial.manage_settings'
|
||||
| 'financial.high_risk_approve'
|
||||
| 'financial.view_invoices'
|
||||
| 'financial.view_payments'
|
||||
| 'financial.view_audit_trail'
|
||||
|
||||
// User management permissions
|
||||
| 'users.manage'
|
||||
|
||||
// Housekeeping permissions
|
||||
| 'housekeeping.view_tasks'
|
||||
| 'housekeeping.manage_tasks'
|
||||
| 'housekeeping.create_tasks'
|
||||
| 'housekeeping.view_rooms'
|
||||
| 'housekeeping.upload_photos'
|
||||
| 'housekeeping.report_issues'
|
||||
| 'housekeeping.view_inspections'
|
||||
| 'housekeeping.manage_inspections'
|
||||
|
||||
// Booking permissions
|
||||
| 'bookings.manage'
|
||||
| 'bookings.view_own'
|
||||
| 'bookings.create'
|
||||
|
||||
// Room permissions
|
||||
| 'rooms.manage'
|
||||
| 'rooms.view'
|
||||
|
||||
// Profile permissions
|
||||
| 'profile.manage';
|
||||
|
||||
/**
|
||||
* Check if a role has a specific permission
|
||||
* This is a pure function that can be used outside of React components
|
||||
*/
|
||||
export const roleHasPermission = (
|
||||
roleName: string | undefined | null,
|
||||
permission: Permission
|
||||
): boolean => {
|
||||
if (!roleName) return false;
|
||||
|
||||
// Import permissions from hook (circular dependency issue if we import directly)
|
||||
// Instead, we'll define a simplified check here
|
||||
const rolePermissions: Record<string, Permission[]> = {
|
||||
admin: [
|
||||
'financial.view_reports',
|
||||
'financial.manage_invoices',
|
||||
'financial.manage_payments',
|
||||
'financial.manage_settings',
|
||||
'financial.high_risk_approve',
|
||||
'users.manage',
|
||||
'housekeeping.view_tasks',
|
||||
'housekeeping.manage_tasks',
|
||||
'housekeeping.view_rooms',
|
||||
'bookings.manage',
|
||||
'rooms.manage',
|
||||
],
|
||||
accountant: [
|
||||
'financial.view_reports',
|
||||
'financial.manage_invoices',
|
||||
'financial.manage_payments',
|
||||
'financial.manage_settings',
|
||||
'financial.high_risk_approve',
|
||||
],
|
||||
accountant_readonly: [
|
||||
'financial.view_reports',
|
||||
'financial.view_invoices',
|
||||
'financial.view_payments',
|
||||
'financial.view_audit_trail',
|
||||
],
|
||||
accountant_operator: [
|
||||
'financial.view_reports',
|
||||
'financial.view_invoices',
|
||||
'financial.view_payments',
|
||||
'financial.manage_invoices',
|
||||
'financial.manage_payments',
|
||||
],
|
||||
accountant_approver: [
|
||||
'financial.view_reports',
|
||||
'financial.view_invoices',
|
||||
'financial.view_payments',
|
||||
'financial.high_risk_approve',
|
||||
],
|
||||
staff: [
|
||||
'financial.view_invoices',
|
||||
'financial.manage_invoices',
|
||||
'financial.view_payments',
|
||||
'housekeeping.view_tasks',
|
||||
'housekeeping.manage_tasks',
|
||||
'housekeeping.view_rooms',
|
||||
'bookings.manage',
|
||||
'rooms.manage',
|
||||
],
|
||||
housekeeping: [
|
||||
'housekeeping.view_tasks',
|
||||
'housekeeping.manage_tasks',
|
||||
'housekeeping.create_tasks',
|
||||
'housekeeping.view_rooms',
|
||||
'housekeeping.upload_photos',
|
||||
'housekeeping.report_issues',
|
||||
'housekeeping.view_inspections',
|
||||
'housekeeping.manage_inspections',
|
||||
],
|
||||
customer: [
|
||||
'bookings.view_own',
|
||||
'bookings.create',
|
||||
'rooms.view',
|
||||
'profile.manage',
|
||||
],
|
||||
};
|
||||
|
||||
const permissions = rolePermissions[roleName] || [];
|
||||
return permissions.includes(permission);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if role is in accountant family
|
||||
*/
|
||||
export const isAccountantRole = (roleName: string | undefined | null): boolean => {
|
||||
if (!roleName) return false;
|
||||
return roleName === 'accountant' || roleName.startsWith('accountant_');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if role can access financial features
|
||||
*/
|
||||
export const canAccessFinancial = (roleName: string | undefined | null): boolean => {
|
||||
if (!roleName) return false;
|
||||
return ['admin', 'staff', 'accountant', 'accountant_readonly', 'accountant_operator', 'accountant_approver']
|
||||
.includes(roleName);
|
||||
};
|
||||
|
||||
131
Frontend/src/shared/utils/sanitize.ts
Normal file
131
Frontend/src/shared/utils/sanitize.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Input sanitization utilities using DOMPurify
|
||||
* Prevents XSS attacks by sanitizing user-generated content
|
||||
*/
|
||||
|
||||
// DOMPurify needs to run in browser environment
|
||||
let DOMPurify: any = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
DOMPurify = require('dompurify');
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for DOMPurify
|
||||
*/
|
||||
const PURIFY_CONFIG = {
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'br', 'strong', 'em', 'u', 'b', 'i', 's', 'strike',
|
||||
'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'blockquote', 'pre', 'code', 'hr', 'div', 'span',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
'href', 'title', 'target', 'rel',
|
||||
'class',
|
||||
'colspan', 'rowspan',
|
||||
],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize HTML content to prevent XSS attacks
|
||||
*
|
||||
* @param content - HTML content to sanitize
|
||||
* @param strip - If true, remove disallowed tags instead of escaping them
|
||||
* @returns Sanitized HTML string
|
||||
*/
|
||||
export const sanitizeHTML = (content: string | null | undefined, strip: boolean = false): string => {
|
||||
if (!content) return '';
|
||||
|
||||
if (typeof content !== 'string') {
|
||||
content = String(content);
|
||||
}
|
||||
|
||||
// Skip sanitization in SSR or if DOMPurify not available
|
||||
if (!DOMPurify || typeof window === 'undefined') {
|
||||
// Fallback: basic HTML entity escaping
|
||||
return content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Sanitize HTML
|
||||
return DOMPurify.sanitize(content, {
|
||||
...PURIFY_CONFIG,
|
||||
KEEP_CONTENT: !strip,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip all HTML tags from content, leaving only plain text
|
||||
*
|
||||
* @param content - Content to strip HTML from
|
||||
* @returns Plain text string
|
||||
*/
|
||||
export const sanitizeText = (content: string | null | undefined): string => {
|
||||
if (!content) return '';
|
||||
|
||||
if (typeof content !== 'string') {
|
||||
content = String(content);
|
||||
}
|
||||
|
||||
// Skip sanitization in SSR or if DOMPurify not available
|
||||
if (!DOMPurify || typeof window === 'undefined') {
|
||||
// Fallback: basic HTML entity escaping
|
||||
return content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Strip all HTML tags
|
||||
return DOMPurify.sanitize(content, {
|
||||
ALLOWED_TAGS: [],
|
||||
KEEP_CONTENT: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape HTML entities for safe display
|
||||
*
|
||||
* @param text - Text to escape
|
||||
* @returns Escaped text safe for HTML display
|
||||
*/
|
||||
export const escapeHTML = (text: string | null | undefined): string => {
|
||||
if (!text) return '';
|
||||
|
||||
if (typeof text !== 'string') {
|
||||
text = String(text);
|
||||
}
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize user input for display in React components
|
||||
* This removes any potentially dangerous content
|
||||
*
|
||||
* @param input - User input to sanitize
|
||||
* @returns Sanitized string safe for display
|
||||
*/
|
||||
export const sanitizeUserInput = (input: string | null | undefined): string => {
|
||||
return sanitizeText(input);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize rich text content (allows safe HTML)
|
||||
*
|
||||
* @param html - HTML content to sanitize
|
||||
* @returns Sanitized HTML safe for rendering
|
||||
*/
|
||||
export const sanitizeRichText = (html: string | null | undefined): string => {
|
||||
return sanitizeHTML(html, false);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user