This commit is contained in:
Iliyan Angelov
2025-11-29 17:23:06 +02:00
parent fb16d7ae34
commit 24b40450dd
23 changed files with 1911 additions and 813 deletions

View File

@@ -166,4 +166,38 @@ async def get_invoices_by_booking(booking_id: int, current_user: User=Depends(ge
except Exception as e:
db.rollback()
logger.error(f'Error fetching invoices by booking: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post('/{id}/send-email')
async def send_invoice_email(request: Request, id: int, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)):
try:
invoice = db.query(Invoice).filter(Invoice.id == id).first()
if not invoice:
raise HTTPException(status_code=404, detail='Invoice not found')
from ..routes.booking_routes import _generate_invoice_email_html
from ..models.user import User as UserModel
from ..utils.mailer import send_email
invoice_dict = InvoiceService.invoice_to_dict(invoice)
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
invoice_type = 'Proforma Invoice' if invoice.is_proforma else 'Invoice'
user = db.query(UserModel).filter(UserModel.id == invoice.user_id).first()
if not user:
raise HTTPException(status_code=404, detail='User not found')
await send_email(
to=user.email,
subject=f'{invoice_type} {invoice.invoice_number}',
html=invoice_html
)
logger.info(f'{invoice_type} {invoice.invoice_number} sent to {user.email}')
return success_response(message=f'{invoice_type} sent successfully to {user.email}')
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f'Error sending invoice email: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -6,6 +6,7 @@ from ..models.invoice import Invoice, InvoiceItem, InvoiceStatus
from ..models.booking import Booking
from ..models.payment import Payment, PaymentStatus
from ..models.user import User
from ..models.system_settings import SystemSettings
from ..config.logging_config import get_logger
logger = get_logger(__name__)
@@ -29,14 +30,33 @@ class InvoiceService:
@staticmethod
def create_invoice_from_booking(booking_id: int, db: Session, created_by_id: Optional[int]=None, tax_rate: float=0.0, discount_amount: float=0.0, due_days: int=30, is_proforma: bool=False, invoice_amount: Optional[float]=None, request_id: Optional[str]=None, **kwargs) -> Dict[str, Any]:
from sqlalchemy.orm import selectinload
from ..models.service_usage import ServiceUsage
from ..models.room import Room
from ..models.room_type import RoomType
from ..models.service import Service
logger.info(f'Creating invoice from booking {booking_id}', extra={'booking_id': booking_id, 'request_id': request_id})
booking = db.query(Booking).options(selectinload(Booking.service_usages).selectinload('service'), selectinload(Booking.room).selectinload('room_type'), selectinload(Booking.payments)).filter(Booking.id == booking_id).first()
booking = db.query(Booking).options(
selectinload(Booking.service_usages).selectinload(ServiceUsage.service),
selectinload(Booking.room).selectinload(Room.room_type),
selectinload(Booking.payments)
).filter(Booking.id == booking_id).first()
if not booking:
logger.error(f'Booking {booking_id} not found', extra={'booking_id': booking_id, 'request_id': request_id})
raise ValueError('Booking not found')
user = db.query(User).filter(User.id == booking.user_id).first()
if not user:
raise ValueError('User not found')
# Get tax_rate from system settings if not provided or is 0
if tax_rate == 0.0:
tax_rate_setting = db.query(SystemSettings).filter(SystemSettings.key == 'tax_rate').first()
if tax_rate_setting and tax_rate_setting.value:
try:
tax_rate = float(tax_rate_setting.value)
except (ValueError, TypeError):
tax_rate = 0.0
invoice_number = generate_invoice_number(db, is_proforma=is_proforma)
booking_total = float(booking.total_price)
if invoice_amount is not None:
@@ -61,7 +81,34 @@ class InvoiceService:
else:
status = InvoiceStatus.draft
paid_date = None
invoice = Invoice(invoice_number=invoice_number, booking_id=booking_id, user_id=booking.user_id, issue_date=datetime.utcnow(), due_date=datetime.utcnow() + timedelta(days=due_days), paid_date=paid_date, subtotal=subtotal, tax_rate=tax_rate, tax_amount=tax_amount, discount_amount=discount_amount, total_amount=total_amount, amount_paid=amount_paid, balance_due=balance_due, status=status, is_proforma=is_proforma, company_name=kwargs.get('company_name'), company_address=kwargs.get('company_address'), company_phone=kwargs.get('company_phone'), company_email=kwargs.get('company_email'), company_tax_id=kwargs.get('company_tax_id'), company_logo_url=kwargs.get('company_logo_url'), customer_name=user.full_name or f'{user.email}', customer_email=user.email, customer_address=user.address, customer_phone=user.phone, customer_tax_id=kwargs.get('customer_tax_id'), notes=kwargs.get('notes'), terms_and_conditions=kwargs.get('terms_and_conditions'), payment_instructions=kwargs.get('payment_instructions'), created_by_id=created_by_id)
# Get company information from system settings if not provided
company_name = kwargs.get('company_name')
company_address = kwargs.get('company_address')
company_phone = kwargs.get('company_phone')
company_email = kwargs.get('company_email')
company_tax_id = kwargs.get('company_tax_id')
company_logo_url = kwargs.get('company_logo_url')
# If company info not provided, fetch from system settings
if not company_name or not company_address or not company_phone or not company_email:
company_settings = db.query(SystemSettings).filter(
SystemSettings.key.in_(['company_name', 'company_address', 'company_phone', 'company_email', 'company_logo_url'])
).all()
settings_dict = {setting.key: setting.value for setting in company_settings if setting.value}
if not company_name and settings_dict.get('company_name'):
company_name = settings_dict['company_name']
if not company_address and settings_dict.get('company_address'):
company_address = settings_dict['company_address']
if not company_phone and settings_dict.get('company_phone'):
company_phone = settings_dict['company_phone']
if not company_email and settings_dict.get('company_email'):
company_email = settings_dict['company_email']
if not company_logo_url and settings_dict.get('company_logo_url'):
company_logo_url = settings_dict['company_logo_url']
invoice = Invoice(invoice_number=invoice_number, booking_id=booking_id, user_id=booking.user_id, issue_date=datetime.utcnow(), due_date=datetime.utcnow() + timedelta(days=due_days), paid_date=paid_date, subtotal=subtotal, tax_rate=tax_rate, tax_amount=tax_amount, discount_amount=discount_amount, total_amount=total_amount, amount_paid=amount_paid, balance_due=balance_due, status=status, is_proforma=is_proforma, company_name=company_name, company_address=company_address, company_phone=company_phone, company_email=company_email, company_tax_id=company_tax_id, company_logo_url=company_logo_url, customer_name=user.full_name or f'{user.email}', customer_email=user.email, customer_address=user.address, customer_phone=user.phone, customer_tax_id=kwargs.get('customer_tax_id'), notes=kwargs.get('notes'), terms_and_conditions=kwargs.get('terms_and_conditions'), payment_instructions=kwargs.get('payment_instructions'), created_by_id=created_by_id)
db.add(invoice)
db.flush()
services_total = sum((float(su.total_price) for su in booking.service_usages))
@@ -110,9 +157,15 @@ class InvoiceService:
if 'tax_rate' in kwargs or 'discount_amount' in kwargs:
tax_rate = kwargs.get('tax_rate', invoice.tax_rate)
discount_amount = kwargs.get('discount_amount', invoice.discount_amount)
invoice.tax_amount = (invoice.subtotal - discount_amount) * (float(tax_rate) / 100)
invoice.total_amount = invoice.subtotal + invoice.tax_amount - discount_amount
invoice.balance_due = invoice.total_amount - invoice.amount_paid
# Convert decimal types to float for arithmetic operations
subtotal = float(invoice.subtotal) if invoice.subtotal else 0.0
discount_amount = float(discount_amount) if discount_amount else 0.0
tax_rate = float(tax_rate) if tax_rate else 0.0
amount_paid = float(invoice.amount_paid) if invoice.amount_paid else 0.0
invoice.tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
invoice.total_amount = subtotal + invoice.tax_amount - discount_amount
invoice.balance_due = invoice.total_amount - amount_paid
if invoice.balance_due <= 0 and invoice.status != InvoiceStatus.paid:
invoice.status = InvoiceStatus.paid
invoice.paid_date = datetime.utcnow()

View File

@@ -55,6 +55,7 @@ const PayPalReturnPage = lazy(() => import('./pages/customer/PayPalReturnPage'))
const PayPalCancelPage = lazy(() => import('./pages/customer/PayPalCancelPage'));
const BoricaReturnPage = lazy(() => import('./pages/customer/BoricaReturnPage'));
const InvoicePage = lazy(() => import('./pages/customer/InvoicePage'));
const InvoiceEditPage = lazy(() => import('./pages/admin/InvoiceEditPage'));
const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
const LoyaltyPage = lazy(() => import('./pages/customer/LoyaltyPage'));
const GroupBookingPage = lazy(() => import('./pages/customer/GroupBookingPage'));
@@ -75,6 +76,7 @@ const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagement
const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage'));
const GuestProfilePage = lazy(() => import('./pages/admin/GuestProfilePage'));
const GroupBookingManagementPage = lazy(() => import('./pages/admin/GroupBookingManagementPage'));
const AdminBookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage'));
const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard'));
const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage'));
const BusinessDashboardPage = lazy(() => import('./pages/admin/BusinessDashboardPage'));
@@ -467,6 +469,10 @@ function App() {
path="invoices"
element={<InvoiceManagementPage />}
/>
<Route
path="invoices/:id/edit"
element={<InvoiceEditPage />}
/>
<Route
path="invoices/:id"
element={<InvoicePage />}
@@ -487,6 +493,10 @@ function App() {
path="group-bookings"
element={<GroupBookingManagementPage />}
/>
<Route
path="bookings"
element={<AdminBookingManagementPage />}
/>
<Route
path="rate-plans"
element={<RatePlanManagementPage />}
@@ -587,6 +597,14 @@ function App() {
path="invoices"
element={<AccountantInvoiceManagementPage />}
/>
<Route
path="invoices/:id/edit"
element={<InvoiceEditPage />}
/>
<Route
path="invoices/:id"
element={<InvoicePage />}
/>
<Route
path="reports"
element={<AccountantAnalyticsDashboardPage />}

View File

@@ -4,8 +4,6 @@ import {
Hotel,
User,
LogOut,
Menu,
X,
LogIn,
UserPlus,
Heart,
@@ -20,6 +18,7 @@ import { useCompanySettings } from '../../contexts/CompanySettingsContext';
import { useAuthModal } from '../../contexts/AuthModalContext';
import { normalizeImageUrl } from '../../utils/imageUtils';
import InAppNotificationBell from '../notifications/InAppNotificationBell';
import Navbar from './Navbar';
interface HeaderProps {
isAuthenticated?: boolean;
@@ -76,6 +75,199 @@ const Header: React.FC<HeaderProps> = ({
setIsMobileMenuOpen(false);
};
// Mobile menu content with user authentication
const mobileMenuContent = (
<>
{!isAuthenticated ? (
<>
<button
onClick={() => {
setIsMobileMenuOpen(false);
openModal('login');
}}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide w-full text-left"
>
<LogIn className="w-4 h-4" />
<span>Login</span>
</button>
<button
onClick={() => {
setIsMobileMenuOpen(false);
openModal('register');
}}
className="flex items-center
space-x-2 px-4 py-3 bg-gradient-to-r
from-[#d4af37] to-[#c9a227] text-[#0f0f0f]
rounded-sm hover:from-[#f5d76e]
hover:to-[#d4af37] transition-all
duration-300 font-medium tracking-wide
mt-2 w-full text-left"
>
<UserPlus className="w-4 h-4" />
<span>Register</span>
</button>
</>
) : (
<>
<div className="px-4 py-2 text-sm
text-[#d4af37]/70 font-light tracking-wide"
>
Hello, {userInfo?.name}
</div>
<Link
to="/profile"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<User className="w-4 h-4" />
<span>Profile</span>
</Link>
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && (
<>
<Link
to="/favorites"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<Heart className="w-4 h-4" />
<span>Favorites</span>
</Link>
<Link
to="/bookings"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<Calendar className="w-4 h-4" />
<span>My Bookings</span>
</Link>
<Link
to="/loyalty"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<Star className="w-4 h-4" />
<span>Loyalty Program</span>
</Link>
<Link
to="/group-bookings"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<Users className="w-4 h-4" />
<span>Group Bookings</span>
</Link>
</>
)}
{userInfo?.role === 'admin' && (
<Link
to="/admin"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<User className="w-4 h-4" />
<span>Admin</span>
</Link>
)}
{userInfo?.role === 'staff' && (
<Link
to="/staff"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<User className="w-4 h-4" />
<span>Staff Dashboard</span>
</Link>
)}
{userInfo?.role === 'accountant' && (
<Link
to="/accountant"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<User className="w-4 h-4" />
<span>Accountant Dashboard</span>
</Link>
)}
<div className="border-t border-[#d4af37]/20 my-2"></div>
<button
onClick={handleLogout}
className="w-full flex items-center
space-x-2 px-4 py-3 text-red-400/90
hover:bg-red-500/10 hover:text-red-400
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-red-500/50 text-left
font-light tracking-wide"
>
<LogOut className="w-4 h-4" />
<span>Logout</span>
</button>
</>
)}
</>
);
return (
<header className="bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f] sticky top-0 z-50 border-b border-[#d4af37]/20 shadow-2xl">
<div className="hidden lg:block bg-[#0a0a0a]/50 border-b border-[#d4af37]/10">
@@ -128,58 +320,13 @@ const Header: React.FC<HeaderProps> = ({
</div>
</Link>
{}
<nav className="hidden md:flex items-center
space-x-1"
>
<Link
to="/"
className="text-white/90 hover:text-[#d4af37]
transition-all duration-300 font-light px-4 py-2
relative group tracking-wide"
>
<span className="relative z-10">Home</span>
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
<Link
to="/rooms"
className="text-white/90 hover:text-[#d4af37]
transition-all duration-300 font-light px-4 py-2
relative group tracking-wide"
>
<span className="relative z-10">Rooms</span>
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
<Link
to="/about"
className="text-white/90 hover:text-[#d4af37]
transition-all duration-300 font-light px-4 py-2
relative group tracking-wide"
>
<span className="relative z-10">About</span>
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
<Link
to="/contact"
className="text-white/90 hover:text-[#d4af37]
transition-all duration-300 font-light px-4 py-2
relative group tracking-wide"
>
<span className="relative z-10">Contact</span>
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
<Link
to="/blog"
className="text-white/90 hover:text-[#d4af37]
transition-all duration-300 font-light px-4 py-2
relative group tracking-wide"
>
<span className="relative z-10">Blog</span>
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
</nav>
<Navbar
isMobileMenuOpen={isMobileMenuOpen}
onMobileMenuToggle={toggleMobileMenu}
onLinkClick={() => setIsMobileMenuOpen(false)}
mobileMenuContent={mobileMenuContent}
/>
{}
<div className="hidden md:flex items-center
space-x-3"
>
@@ -381,226 +528,7 @@ const Header: React.FC<HeaderProps> = ({
</div>
</div>
)}
<button
onClick={toggleMobileMenu}
className="md:hidden p-2 rounded-sm
hover:bg-white/10 border border-transparent
hover:border-[#d4af37]/30 transition-all duration-300"
>
{isMobileMenuOpen ? (
<X className="w-6 h-6 text-[#d4af37]" />
) : (
<Menu className="w-6 h-6 text-white/90" />
)}
</button>
</div>
{isMobileMenuOpen && (
<div className="md:hidden py-4 border-t border-[#d4af37]/20 mt-4 bg-[#0a0a0a]/50 backdrop-blur-xl animate-fade-in rounded-b-sm">
<div className="flex flex-col space-y-1">
<Link
to="/"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
Home
</Link>
<Link
to="/rooms"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
Rooms
</Link>
<Link
to="/about"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
About
</Link>
<Link
to="/contact"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
Contact
</Link>
<Link
to="/blog"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
Blog
</Link>
<div className="border-t border-[#d4af37]/20
pt-3 mt-3"
>
{!isAuthenticated ? (
<>
<button
onClick={() => {
setIsMobileMenuOpen(false);
openModal('login');
}}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide w-full text-left"
>
<LogIn className="w-4 h-4" />
<span>Login</span>
</button>
<button
onClick={() => {
setIsMobileMenuOpen(false);
openModal('register');
}}
className="flex items-center
space-x-2 px-4 py-3 bg-gradient-to-r
from-[#d4af37] to-[#c9a227] text-[#0f0f0f]
rounded-sm hover:from-[#f5d76e]
hover:to-[#d4af37] transition-all
duration-300 font-medium tracking-wide
mt-2 w-full text-left"
>
<UserPlus className="w-4 h-4" />
<span>Register</span>
</button>
</>
) : (
<>
<div className="px-4 py-2 text-sm
text-[#d4af37]/70 font-light tracking-wide"
>
Hello, {userInfo?.name}
</div>
<Link
to="/profile"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<User className="w-4 h-4" />
<span>Profile</span>
</Link>
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && (
<>
<Link
to="/favorites"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<Heart className="w-4 h-4" />
<span>Favorites</span>
</Link>
<Link
to="/bookings"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<Calendar className="w-4 h-4" />
<span>My Bookings</span>
</Link>
</>
)}
{userInfo?.role === 'admin' && (
<Link
to="/admin"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<User className="w-4 h-4" />
<span>Admin</span>
</Link>
)}
{userInfo?.role === 'staff' && (
<Link
to="/staff"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<User className="w-4 h-4" />
<span>Staff Dashboard</span>
</Link>
)}
<button
onClick={handleLogout}
className="w-full flex items-center
space-x-2 px-4 py-3 text-red-400/90
hover:bg-red-500/10 hover:text-red-400
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-red-500/50 text-left
font-light tracking-wide"
>
<LogOut className="w-4 h-4" />
<span>Logout</span>
</button>
</>
)}
</div>
</div>
</div>
)}
</div>
</div>
</header>

View File

@@ -0,0 +1,135 @@
import React, { useRef } from 'react';
import { Link } from 'react-router-dom';
import { Menu, X } from 'lucide-react';
import { useClickOutside } from '../../hooks/useClickOutside';
interface NavbarProps {
isMobileMenuOpen: boolean;
onMobileMenuToggle: () => void;
onLinkClick?: () => void;
renderMobileLinksOnly?: boolean;
mobileMenuContent?: React.ReactNode;
}
export const navLinks = [
{ to: '/', label: 'Home' },
{ to: '/rooms', label: 'Rooms' },
{ to: '/about', label: 'About' },
{ to: '/contact', label: 'Contact' },
{ to: '/blog', label: 'Blog' },
];
const Navbar: React.FC<NavbarProps> = ({
isMobileMenuOpen,
onMobileMenuToggle,
onLinkClick,
renderMobileLinksOnly = false,
mobileMenuContent
}) => {
const mobileMenuContainerRef = useRef<HTMLDivElement>(null);
useClickOutside(mobileMenuContainerRef, () => {
if (isMobileMenuOpen) {
onMobileMenuToggle();
}
});
const handleLinkClick = () => {
if (onLinkClick) {
onLinkClick();
}
};
// If only rendering mobile links (for use inside Header's mobile menu container)
if (renderMobileLinksOnly) {
return (
<>
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
onClick={handleLinkClick}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
{link.label}
</Link>
))}
</>
);
}
return (
<div className="relative" ref={mobileMenuContainerRef}>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-1">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="text-white/90 hover:text-[#d4af37]
transition-all duration-300 font-light px-4 py-2
relative group tracking-wide"
>
<span className="relative z-10">{link.label}</span>
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
))}
</nav>
{/* Mobile Menu Button */}
<button
onClick={onMobileMenuToggle}
className="md:hidden p-2 rounded-sm
hover:bg-white/10 border border-transparent
hover:border-[#d4af37]/30 transition-all duration-300"
>
{isMobileMenuOpen ? (
<X className="w-6 h-6 text-[#d4af37]" />
) : (
<Menu className="w-6 h-6 text-white/90" />
)}
</button>
{/* Mobile Menu Dropdown - Absolute positioned */}
{isMobileMenuOpen && (
<div
className="md:hidden absolute right-0 mt-2 w-64
bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f]
rounded-sm shadow-2xl py-2 border border-[#d4af37]/20
z-50 backdrop-blur-xl animate-fade-in max-h-[calc(100vh-120px)]
overflow-y-auto"
>
<div className="flex flex-col space-y-1">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
onClick={handleLinkClick}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
{link.label}
</Link>
))}
{mobileMenuContent && (
<>
<div className="border-t border-[#d4af37]/20 my-2"></div>
{mobileMenuContent}
</>
)}
</div>
</div>
)}
</div>
);
};
export default Navbar;

View File

@@ -1,5 +1,6 @@
export { default as Header } from './Header';
export { default as Footer } from './Footer';
export { default as Navbar } from './Navbar';
export { default as SidebarAdmin } from './SidebarAdmin';
export { default as SidebarStaff } from './SidebarStaff';
export { default as SidebarAccountant } from './SidebarAccountant';

View File

@@ -261,9 +261,9 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
{}
{children && (
<div className="absolute inset-0 flex items-end justify-center z-30 px-2 sm:px-4 md:px-6 lg:px-8 pb-2 sm:pb-4 md:pb-8 lg:pb-12 xl:pb-16 pointer-events-none">
<div className="absolute inset-0 flex items-end justify-center z-50 px-1.5 sm:px-3 md:px-6 lg:px-8 pb-1 sm:pb-2 md:pb-6 lg:pb-12 xl:pb-16 pointer-events-none">
<div className="w-full max-w-6xl pointer-events-auto">
<div className="bg-white/95 rounded-lg shadow-2xl border border-white/20 p-2 sm:p-3 md:p-4 lg:p-6">
<div className="bg-white/95 rounded-md sm:rounded-lg shadow-2xl border border-white/20 p-1.5 sm:p-2.5 md:p-4 lg:p-6">
{children}
</div>
</div>
@@ -277,7 +277,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
<button
onClick={goToPrevious}
type="button"
className="absolute left-2 sm:left-4 top-1/2
className={`absolute left-2 sm:left-4
-translate-y-1/2
bg-white/90
hover:bg-white text-gray-800
@@ -287,7 +287,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
transition-all duration-300 z-40
hover:scale-110 hover:shadow-2xl
active:scale-95 cursor-pointer
group"
group ${children ? 'top-[35%] sm:top-[40%] md:top-1/2' : 'top-1/2'}`}
aria-label="Previous banner"
>
<ChevronLeft className="w-5 h-5 sm:w-6 sm:h-6 transition-transform duration-300 group-hover:-translate-x-1" />
@@ -296,7 +296,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
<button
onClick={goToNext}
type="button"
className="absolute right-2 sm:right-4 top-1/2
className={`absolute right-2 sm:right-4
-translate-y-1/2
bg-white/90
hover:bg-white text-gray-800
@@ -306,7 +306,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
transition-all duration-300 z-40
hover:scale-110 hover:shadow-2xl
active:scale-95 cursor-pointer
group"
group ${children ? 'top-[35%] sm:top-[40%] md:top-1/2' : 'top-1/2'}`}
aria-label="Next banner"
>
<ChevronRight className="w-5 h-5 sm:w-6 sm:h-6 transition-transform duration-300 group-hover:translate-x-1" />
@@ -317,10 +317,10 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
{}
{displayBanners.length > 1 && (
<div
className="absolute bottom-2 sm:bottom-4 left-1/2
className={`absolute left-1/2
-translate-x-1/2 flex gap-2 sm:gap-2.5 z-40 pointer-events-auto
bg-black/40 px-3 py-2 rounded-full
border border-white/10"
border border-white/10 ${children ? 'bottom-16 sm:bottom-20 md:bottom-24 lg:bottom-28' : 'bottom-2 sm:bottom-4'}`}
>
{displayBanners.map((_, index) => (
<button

View File

@@ -106,19 +106,19 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
<div className={`w-full ${isOverlay ? 'bg-transparent shadow-none border-none p-0' : 'luxury-glass rounded-sm shadow-2xl p-6 border border-[#d4af37]/20'} ${className}`}>
{}
{(!isOverlay || !isMobile) && (
<div className={`flex items-center justify-center gap-2 sm:gap-3 ${isOverlay ? 'mb-2 sm:mb-3 md:mb-4 lg:mb-6' : 'mb-6'}`}>
<div className={`${isOverlay ? 'w-0.5 sm:w-1' : 'w-1'} ${isOverlay ? 'h-4 sm:h-6 md:h-8' : 'h-8'} bg-gradient-to-b from-[#d4af37] to-[#c9a227]`}></div>
<h3 className={`${isOverlay ? 'text-sm sm:text-base md:text-lg lg:text-xl xl:text-2xl' : 'text-2xl'} font-serif font-semibold text-gray-900 tracking-tight`}>
<div className={`flex items-center justify-center gap-1.5 sm:gap-2 md:gap-3 ${isOverlay ? 'mb-1 sm:mb-2 md:mb-3 lg:mb-4 xl:mb-6' : 'mb-6'}`}>
<div className={`${isOverlay ? 'w-0.5 sm:w-0.5 md:w-1' : 'w-1'} ${isOverlay ? 'h-3 sm:h-4 md:h-6 lg:h-8' : 'h-8'} bg-gradient-to-b from-[#d4af37] to-[#c9a227]`}></div>
<h3 className={`${isOverlay ? 'text-xs sm:text-sm md:text-base lg:text-lg xl:text-xl' : 'text-2xl'} font-serif font-semibold text-gray-900 tracking-tight`}>
{isOverlay && isMobile ? 'Find Rooms' : 'Find Available Rooms'}
</h3>
<div className={`${isOverlay ? 'w-0.5 sm:w-1' : 'w-1'} ${isOverlay ? 'h-4 sm:h-6 md:h-8' : 'h-8'} bg-gradient-to-b from-[#d4af37] to-[#c9a227]`}></div>
<div className={`${isOverlay ? 'w-0.5 sm:w-0.5 md:w-1' : 'w-1'} ${isOverlay ? 'h-3 sm:h-4 md:h-6 lg:h-8' : 'h-8'} bg-gradient-to-b from-[#d4af37] to-[#c9a227]`}></div>
</div>
)}
<form onSubmit={handleSearch}>
<div className={`grid ${isOverlay ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-12' : 'grid-cols-1 md:grid-cols-12'} ${isOverlay ? 'gap-2 sm:gap-3 md:gap-4' : 'gap-4'} items-end`}>
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-3' : 'md:col-span-3'}>
<label className={`block ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-1 sm:mb-2' : 'mb-2'} tracking-wide`}>Check-in</label>
<div className={`grid ${isOverlay ? 'grid-cols-2 sm:grid-cols-2 lg:grid-cols-12' : 'grid-cols-1 md:grid-cols-12'} ${isOverlay ? 'gap-1.5 sm:gap-2 md:gap-3 lg:gap-4' : 'gap-4'} items-end`}>
<div className={isOverlay ? 'col-span-1 sm:col-span-1 lg:col-span-3' : 'md:col-span-3'}>
<label className={`block ${isOverlay ? 'text-[10px] sm:text-xs md:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-0.5 sm:mb-1 md:mb-2' : 'mb-2'} tracking-wide`}>Check-in</label>
<DatePicker
selected={checkInDate}
onChange={(date) => setCheckInDate(date)}
@@ -126,14 +126,14 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
startDate={checkInDate}
endDate={checkOutDate}
minDate={today}
placeholderText="Check-in"
placeholderText={isOverlay && isMobile ? "In" : "Check-in"}
dateFormat={isOverlay && isMobile ? "dd/MM" : "dd/MM/yyyy"}
className={`luxury-input ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} ${isOverlay ? 'py-1.5 sm:py-2' : ''}`}
className={`luxury-input ${isOverlay ? 'text-[10px] sm:text-xs md:text-sm' : 'text-sm'} ${isOverlay ? 'py-1 sm:py-1.5 md:py-2 px-2 sm:px-3 md:px-4' : ''}`}
/>
</div>
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-3' : 'md:col-span-3'}>
<label className={`block ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-1 sm:mb-2' : 'mb-2'} tracking-wide`}>Check-out</label>
<div className={isOverlay ? 'col-span-1 sm:col-span-1 lg:col-span-3' : 'md:col-span-3'}>
<label className={`block ${isOverlay ? 'text-[10px] sm:text-xs md:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-0.5 sm:mb-1 md:mb-2' : 'mb-2'} tracking-wide`}>Check-out</label>
<DatePicker
selected={checkOutDate}
onChange={(date) => setCheckOutDate(date)}
@@ -141,48 +141,48 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
startDate={checkInDate}
endDate={checkOutDate}
minDate={checkInDate || today}
placeholderText="Check-out"
placeholderText={isOverlay && isMobile ? "Out" : "Check-out"}
dateFormat={isOverlay && isMobile ? "dd/MM" : "dd/MM/yyyy"}
className={`luxury-input ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} ${isOverlay ? 'py-1.5 sm:py-2' : ''}`}
className={`luxury-input ${isOverlay ? 'text-[10px] sm:text-xs md:text-sm' : 'text-sm'} ${isOverlay ? 'py-1 sm:py-1.5 md:py-2 px-2 sm:px-3 md:px-4' : ''}`}
/>
</div>
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-2' : 'md:col-span-2'}>
<label className={`block ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-1 sm:mb-2' : 'mb-2'} tracking-wide`}>Room Type</label>
<div className={`${isOverlay ? 'col-span-1 sm:col-span-1 lg:col-span-2' : 'md:col-span-2'} ${isOverlay && isMobile ? 'hidden sm:block' : ''}`}>
<label className={`block ${isOverlay ? 'text-[10px] sm:text-xs md:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-0.5 sm:mb-1 md:mb-2' : 'mb-2'} tracking-wide`}>Type</label>
<select
value={roomType}
onChange={(e) => setRoomType(e.target.value)}
className={`luxury-input ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} ${isOverlay ? 'py-1.5 sm:py-2' : ''}`}
className={`luxury-input ${isOverlay ? 'text-[10px] sm:text-xs md:text-sm' : 'text-sm'} ${isOverlay ? 'py-1 sm:py-1.5 md:py-2 px-2 sm:px-3 md:px-4' : ''}`}
>
<option value="">All Types</option>
<option value="Standard Room">Standard Room</option>
<option value="Deluxe Room">Deluxe Room</option>
<option value="Luxury Room">Luxury Room</option>
<option value="Family Room">Family Room</option>
<option value="Twin Room">Twin Room</option>
<option value="">All</option>
<option value="Standard Room">Standard</option>
<option value="Deluxe Room">Deluxe</option>
<option value="Luxury Room">Luxury</option>
<option value="Family Room">Family</option>
<option value="Twin Room">Twin</option>
</select>
</div>
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-2' : 'md:col-span-2'}>
<label className={`block ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-1 sm:mb-2' : 'mb-2'} tracking-wide`}>Guests</label>
<div className={`${isOverlay ? 'col-span-1 sm:col-span-1 lg:col-span-2' : 'md:col-span-2'} ${isOverlay && isMobile ? 'hidden sm:block' : ''}`}>
<label className={`block ${isOverlay ? 'text-[10px] sm:text-xs md:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-0.5 sm:mb-1 md:mb-2' : 'mb-2'} tracking-wide`}>Guests</label>
<select
value={guestCount}
onChange={(e) => setGuestCount(Number(e.target.value))}
className={`luxury-input ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} ${isOverlay ? 'py-1.5 sm:py-2' : ''}`}
className={`luxury-input ${isOverlay ? 'text-[10px] sm:text-xs md:text-sm' : 'text-sm'} ${isOverlay ? 'py-1 sm:py-1.5 md:py-2 px-2 sm:px-3 md:px-4' : ''}`}
>
{Array.from({ length: 6 }).map((_, i) => (
<option key={i} value={i + 1}>{i + 1} guest{i !== 0 ? 's' : ''}</option>
<option key={i} value={i + 1}>{i + 1} {isOverlay && isMobile ? '' : 'guest'}{i !== 0 && !(isOverlay && isMobile) ? 's' : ''}</option>
))}
</select>
</div>
<div className={`${isOverlay ? 'sm:col-span-2 lg:col-span-2' : 'md:col-span-2'} flex items-end ${isOverlay ? 'mt-1 sm:mt-2 md:mt-0' : 'mt-3 md:mt-0'}`}>
<div className={`${isOverlay ? 'col-span-2 sm:col-span-2 lg:col-span-2' : 'md:col-span-2'} flex items-end ${isOverlay ? 'mt-0.5 sm:mt-1 md:mt-0' : 'mt-3 md:mt-0'}`}>
<button
type="submit"
disabled={isSearching}
className={`btn-luxury-primary w-full flex items-center justify-center gap-1.5 sm:gap-2 ${isOverlay ? 'text-xs sm:text-sm py-1.5 sm:py-2 md:py-3' : 'text-sm'} relative`}
className={`btn-luxury-primary w-full flex items-center justify-center gap-1 sm:gap-1.5 md:gap-2 ${isOverlay ? 'text-[10px] sm:text-xs md:text-sm py-1 sm:py-1.5 md:py-2 lg:py-3 px-2 sm:px-3 md:px-4' : 'text-sm'} relative`}
>
<Search className={`${isOverlay ? 'w-3 h-3 sm:w-4 sm:h-4' : 'w-4 h-4'} relative z-10`} />
<Search className={`${isOverlay ? 'w-2.5 h-2.5 sm:w-3 sm:h-3 md:w-4 md:h-4' : 'w-4 h-4'} relative z-10`} />
<span className="relative z-10">
{isSearching ? 'Searching...' : 'Search'}
</span>

View File

@@ -417,25 +417,33 @@ const BannerManagementPage: React.FC = () => {
{}
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200 flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-900">
{editingBanner ? 'Edit Banner' : 'Create Banner'}
</h2>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">
{editingBanner ? 'Edit Banner' : 'Create Banner'}
</h2>
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">
{editingBanner ? 'Modify banner information' : 'Create a new promotional banner'}
</p>
</div>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<form onSubmit={handleSubmit} className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">
Title *
</label>
<input
@@ -443,17 +451,17 @@ const BannerManagementPage: React.FC = () => {
required
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
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"
placeholder="Enter banner title"
/>
</div>
{}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-3">
Banner Image *
</label>
{}
<div className="flex space-x-4 mb-3">
<div className="flex flex-wrap gap-2 sm:gap-3 mb-4">
<button
type="button"
onClick={() => {
@@ -462,10 +470,10 @@ const BannerManagementPage: React.FC = () => {
setImagePreview(null);
setFormData({ ...formData, image_url: '' });
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
className={`px-4 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200 ${
useFileUpload
? 'bg-[#d4af37] text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
? 'bg-gradient-to-r from-amber-500 to-amber-600 text-white shadow-lg shadow-amber-500/30'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 border-2 border-slate-200'
}`}
>
Upload File
@@ -477,10 +485,10 @@ const BannerManagementPage: React.FC = () => {
setImageFile(null);
setImagePreview(null);
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
className={`px-4 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200 ${
!useFileUpload
? 'bg-[#d4af37] text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
? 'bg-gradient-to-r from-amber-500 to-amber-600 text-white shadow-lg shadow-amber-500/30'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 border-2 border-slate-200'
}`}
>
Use URL
@@ -489,18 +497,18 @@ const BannerManagementPage: React.FC = () => {
{useFileUpload ? (
<div>
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100 transition-colors">
<label className="flex flex-col items-center justify-center w-full min-h-[140px] sm:min-h-[160px] border-2 border-slate-300 border-dashed rounded-xl cursor-pointer bg-gradient-to-br from-slate-50 to-white hover:border-amber-400 hover:bg-gradient-to-br hover:from-amber-50/50 hover:to-white transition-all duration-200">
{uploadingImage ? (
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<Loader2 className="w-8 h-8 text-[#d4af37] animate-spin mb-2" />
<p className="text-sm text-gray-500">Uploading...</p>
<Loader2 className="w-8 h-8 text-amber-500 animate-spin mb-2" />
<p className="text-sm text-slate-600 font-medium">Uploading...</p>
</div>
) : imagePreview ? (
<div className="relative w-full h-full">
<div className="relative w-full h-full min-h-[140px] sm:min-h-[160px]">
<img
src={imagePreview}
alt="Preview"
className="w-full h-32 object-cover rounded-lg"
className="w-full h-full min-h-[140px] sm:min-h-[160px] object-cover rounded-xl"
/>
<button
type="button"
@@ -509,18 +517,18 @@ const BannerManagementPage: React.FC = () => {
setImagePreview(null);
setFormData({ ...formData, image_url: '' });
}}
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600"
className="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors shadow-lg"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<ImageIcon className="w-10 h-10 mb-2 text-gray-400" />
<p className="mb-2 text-sm text-gray-500">
<div className="flex flex-col items-center justify-center pt-5 pb-6 px-4">
<ImageIcon className="w-10 h-10 sm:w-12 sm:h-12 mb-3 text-slate-400" />
<p className="mb-2 text-sm sm:text-base text-slate-600 font-medium">
<span className="font-semibold">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-gray-500">PNG, JPG, GIF up to 5MB</p>
<p className="text-xs text-slate-500">PNG, JPG, GIF up to 5MB</p>
</div>
)}
<input
@@ -538,14 +546,14 @@ const BannerManagementPage: React.FC = () => {
value={formData.image_url}
onChange={(e) => setFormData({ ...formData, image_url: e.target.value })}
placeholder="https://example.com/image.jpg"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
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"
/>
{formData.image_url && (
<div className="mt-2">
<div className="mt-3">
<img
src={formData.image_url}
alt="Preview"
className="w-full h-32 object-cover rounded-lg"
className="w-full h-40 sm:h-48 object-cover rounded-xl border-2 border-slate-200"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
@@ -557,19 +565,20 @@ const BannerManagementPage: React.FC = () => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
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 resize-y"
placeholder="Enter banner description (optional)"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">
Link URL
</label>
<input
@@ -577,19 +586,19 @@ const BannerManagementPage: React.FC = () => {
value={formData.link}
onChange={(e) => setFormData({ ...formData, link: e.target.value })}
placeholder="https://example.com"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">
Position
</label>
<select
value={formData.position}
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
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 cursor-pointer"
>
<option value="home">Home</option>
<option value="rooms">Rooms</option>
@@ -597,76 +606,78 @@ const BannerManagementPage: React.FC = () => {
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">
Display Order
</label>
<input
type="number"
value={formData.display_order}
onChange={(e) => setFormData({ ...formData, display_order: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
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"
placeholder="0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">
Start Date
</label>
<input
type="date"
value={formData.start_date}
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">
End Date
</label>
<input
type="date"
value={formData.end_date}
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
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"
/>
</div>
</div>
<div className="flex items-center">
<div className="flex items-center gap-3 p-4 bg-slate-50 rounded-xl border-2 border-slate-200">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="w-4 h-4 text-[#d4af37] border-gray-300 rounded focus:ring-[#d4af37]"
className="w-5 h-5 text-amber-500 bg-white border-2 border-slate-300 rounded focus:ring-amber-500/50 focus:ring-2 cursor-pointer transition-all"
/>
<label htmlFor="is_active" className="ml-2 text-sm font-medium text-gray-700">
<label htmlFor="is_active" className="text-sm font-semibold text-slate-700 cursor-pointer">
Active
</label>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<div className="flex flex-col sm:flex-row justify-end gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition-colors"
className="w-full sm:w-auto px-6 py-3 border-2 border-slate-300 rounded-xl text-slate-700 font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
<button
type="submit"
disabled={uploadingImage}
className="px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full sm:w-auto px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl"
>
{uploadingImage ? 'Uploading...' : editingBanner ? 'Update Banner' : 'Create Banner'}
</button>
</div>
</form>
</form>
</div>
</div>
</div>
)}

View File

@@ -530,32 +530,40 @@ const BlogManagementPage: React.FC = () => {
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">
{editingPost ? 'Edit Blog Post' : 'Create Blog Post'}
</h2>
<button
onClick={handleCloseModal}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">
{editingPost ? 'Edit Blog Post' : 'Create Blog Post'}
</h2>
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">
{editingPost ? 'Modify blog post content' : 'Create a new blog post'}
</p>
</div>
<button
onClick={handleCloseModal}
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<form onSubmit={handleSubmit} className="p-4 sm:p-6 space-y-5 sm:space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Title *</label>
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">Title *</label>
<input
type="text"
required
value={formData.title}
onChange={(e) => handleTitleChange(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
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"
placeholder="Enter blog post title"
/>
<p className="mt-1 text-xs text-gray-500">Slug will be auto-generated from title</p>
<p className="mt-1 text-xs text-slate-500">Slug will be auto-generated from title</p>
</div>
<div>
@@ -1148,24 +1156,25 @@ const BlogManagementPage: React.FC = () => {
)}
</div>
<div className="flex justify-end gap-4 pt-4 border-t border-gray-200">
<div className="flex flex-col sm:flex-row justify-end gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={handleCloseModal}
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
className="w-full sm:w-auto px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
className="w-full sm:w-auto px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{saving && <Loader2 className="w-5 h-5 animate-spin" />}
{editingPost ? 'Update' : 'Create'}
</button>
</div>
</form>
</form>
</div>
</div>
</div>
)}

View File

@@ -1,25 +1,29 @@
import React, { useEffect, useState } from 'react';
import { Search, Eye, XCircle, CheckCircle, Loader2, FileText, Plus } from 'lucide-react';
import { Search, Eye, XCircle, CheckCircle, Loader2, FileText, Plus, Mail } from 'lucide-react';
import { bookingService, Booking, invoiceService } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import CreateBookingModal from '../../components/shared/CreateBookingModal';
import { logger } from '../../utils/logger';
const BookingManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [bookings, setBookings] = useState<Booking[]>([]);
const [bookingsWithInvoices, setBookingsWithInvoices] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
const [checkingInvoices, setCheckingInvoices] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [updatingBookingId, setUpdatingBookingId] = useState<number | null>(null);
const [cancellingBookingId, setCancellingBookingId] = useState<number | null>(null);
const [creatingInvoice, setCreatingInvoice] = useState(false);
const [sendingEmail, setSendingEmail] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [filters, setFilters] = useState({
search: '',
@@ -29,6 +33,7 @@ const BookingManagementPage: React.FC = () => {
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const showOnlyWithoutInvoices = searchParams.get('createInvoice') === 'true';
useEffect(() => {
setCurrentPage(1);
@@ -38,6 +43,16 @@ const BookingManagementPage: React.FC = () => {
fetchBookings();
}, [filters, currentPage]);
useEffect(() => {
// Fetch invoices for all bookings to check which have invoices
if (bookings.length > 0) {
fetchBookingsInvoices();
} else {
// Reset invoices set when bookings are cleared
setBookingsWithInvoices(new Set());
}
}, [bookings]);
const fetchBookings = async () => {
try {
setLoading(true);
@@ -58,6 +73,57 @@ const BookingManagementPage: React.FC = () => {
}
};
const fetchBookingsInvoices = async () => {
try {
if (!bookings || bookings.length === 0) {
setBookingsWithInvoices(new Set());
setCheckingInvoices(false);
return;
}
setCheckingInvoices(true);
const invoiceChecks = await Promise.all(
bookings.map(async (booking) => {
try {
// Validate booking ID
if (!booking || !booking.id || isNaN(booking.id) || booking.id <= 0) {
return { bookingId: booking?.id || 0, hasInvoice: false };
}
const response = await invoiceService.getInvoicesByBooking(booking.id);
// Check response structure - handle both possible formats
const invoices = response.data?.invoices || response.data?.data?.invoices || [];
const hasInvoice = Array.isArray(invoices) && invoices.length > 0;
return {
bookingId: booking.id,
hasInvoice: hasInvoice
};
} catch (error: any) {
// Log error but don't fail the entire operation
logger.error(`Error checking invoice for booking ${booking?.id}`, error);
return { bookingId: booking?.id || 0, hasInvoice: false };
}
})
);
const withInvoices = new Set(
invoiceChecks
.filter(check => check.bookingId > 0 && check.hasInvoice)
.map(check => check.bookingId)
);
setBookingsWithInvoices(withInvoices);
} catch (error) {
logger.error('Error checking invoices', error);
// On error, assume no bookings have invoices to avoid blocking the UI
setBookingsWithInvoices(new Set());
} finally {
setCheckingInvoices(false);
}
};
const handleUpdateStatus = async (id: number, status: string) => {
try {
setUpdatingBookingId(id);
@@ -86,26 +152,115 @@ const BookingManagementPage: React.FC = () => {
}
};
const handleCreateInvoice = async (bookingId: number) => {
const handleCreateInvoice = async (bookingId: number, sendEmail: boolean = false) => {
try {
// Validate bookingId before proceeding
if (!bookingId || isNaN(bookingId) || bookingId <= 0) {
toast.error('Invalid booking ID');
return;
}
setCreatingInvoice(true);
const invoiceData = {
booking_id: Number(bookingId),
booking_id: bookingId,
};
const response = await invoiceService.createInvoice(invoiceData);
if (response.status === 'success' && response.data?.invoice) {
toast.success('Invoice created successfully!');
setShowDetailModal(false);
navigate(`/admin/invoices/${response.data.invoice.id}`);
} else {
throw new Error('Failed to create invoice');
// Log the full response for debugging
console.log('Invoice creation response:', JSON.stringify(response, null, 2));
logger.info('Invoice creation response', { response });
// Check response structure - handle different possible formats
let invoice = null;
if (response.status === 'success' && response.data) {
// Try different possible response structures
invoice = response.data.invoice || response.data.data?.invoice || response.data;
console.log('Extracted invoice:', invoice);
}
if (!invoice) {
console.error('Failed to create invoice - no invoice in response', response);
logger.error('Failed to create invoice - no invoice in response', { response });
toast.error(response.message || 'Failed to create invoice - no invoice data received');
return;
}
// Extract and validate invoice ID - handle both number and string types
let invoiceId = invoice.id;
// Log the invoice ID for debugging
console.log('Extracted invoice ID:', { invoiceId, type: typeof invoiceId, invoice });
logger.info('Extracted invoice ID', { invoiceId, type: typeof invoiceId, invoice });
// Convert to number if it's a string
if (typeof invoiceId === 'string') {
invoiceId = parseInt(invoiceId, 10);
}
// Validate invoice ID before navigation
if (!invoiceId || isNaN(invoiceId) || invoiceId <= 0 || !isFinite(invoiceId)) {
console.error('Invalid invoice ID received from server', {
originalInvoiceId: invoice.id,
convertedInvoiceId: invoiceId,
type: typeof invoiceId,
invoice,
response: response.data
});
logger.error('Invalid invoice ID received from server', {
originalInvoiceId: invoice.id,
convertedInvoiceId: invoiceId,
type: typeof invoiceId,
invoice,
response: response.data
});
toast.error(`Failed to create invoice: Invalid invoice ID received (${invoice.id})`);
return;
}
// Ensure it's a number
invoiceId = Number(invoiceId);
toast.success('Invoice created successfully!');
// Send email if requested
if (sendEmail) {
setSendingEmail(true);
try {
await invoiceService.sendInvoiceEmail(invoiceId);
toast.success('Invoice sent via email successfully!');
} catch (emailError: any) {
toast.warning('Invoice created but email sending failed: ' + (emailError.response?.data?.message || emailError.message));
} finally {
setSendingEmail(false);
}
}
setShowDetailModal(false);
// Update the bookings with invoices set immediately
setBookingsWithInvoices(prev => new Set(prev).add(bookingId));
// Remove the createInvoice query param if present
if (showOnlyWithoutInvoices) {
searchParams.delete('createInvoice');
setSearchParams(searchParams);
}
// Delay to ensure invoice is fully committed to database before navigation
// Increased delay to prevent race conditions
setTimeout(() => {
navigate(`/admin/invoices/${invoiceId}`);
}, 500);
} catch (error: any) {
// Don't show "Invalid invoice ID" error if it's from validation - it's already handled above
const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || 'Unable to create invoice';
toast.error(errorMessage);
// Only show error toast if it's not a validation error we've already handled
if (!errorMessage.includes('Invalid invoice ID') && !errorMessage.includes('Invalid Invoice ID')) {
toast.error(errorMessage);
} else {
// Log validation errors but don't show toast (they're handled above)
logger.error('Invoice creation validation error', error);
}
logger.error('Invoice creation error', error);
} finally {
setCreatingInvoice(false);
@@ -182,6 +337,29 @@ const BookingManagementPage: React.FC = () => {
</div>
{}
{showOnlyWithoutInvoices && (
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 border-2 border-amber-200 rounded-xl p-4 mb-6 animate-fade-in">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-amber-600" />
<div>
<h3 className="font-semibold text-amber-900">Select Booking to Create Invoice</h3>
<p className="text-sm text-amber-700 mt-1">Showing only bookings without invoices. Select a booking to create and send an invoice.</p>
</div>
</div>
<button
onClick={() => {
searchParams.delete('createInvoice');
setSearchParams(searchParams);
}}
className="px-4 py-2 text-sm font-medium text-amber-700 hover:text-amber-900 hover:bg-amber-100 rounded-lg transition-colors"
>
Show All Bookings
</button>
</div>
</div>
)}
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="relative group">
@@ -225,7 +403,76 @@ const BookingManagementPage: React.FC = () => {
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{bookings.map((booking, index) => (
{loading || checkingInvoices ? (
<tr>
<td colSpan={7} className="px-8 py-12 text-center">
<div className="flex flex-col items-center justify-center">
<Loader2 className="w-8 h-8 text-amber-600 animate-spin mb-4" />
<p className="text-slate-600 font-medium">
{checkingInvoices ? 'Checking invoices...' : 'Loading bookings...'}
</p>
</div>
</td>
</tr>
) : bookings.length === 0 ? (
<tr>
<td colSpan={7} className="px-8 py-12 text-center">
<div className="flex flex-col items-center justify-center">
<FileText className="w-12 h-12 text-slate-400 mb-4" />
<p className="text-slate-600 font-medium">
{showOnlyWithoutInvoices
? 'No bookings without invoices found'
: 'No bookings found'}
</p>
{showOnlyWithoutInvoices && (
<button
onClick={() => {
searchParams.delete('createInvoice');
setSearchParams(searchParams);
}}
className="mt-4 px-4 py-2 text-sm font-medium text-amber-700 hover:text-amber-900 hover:bg-amber-100 rounded-lg transition-colors"
>
Show All Bookings
</button>
)}
</div>
</td>
</tr>
) : (() => {
const filteredBookings = bookings.filter(booking => {
// If showOnlyWithoutInvoices is true, only show bookings without invoices
if (showOnlyWithoutInvoices) {
return !bookingsWithInvoices.has(booking.id);
}
// Otherwise, show all bookings
return true;
});
if (filteredBookings.length === 0 && showOnlyWithoutInvoices) {
return (
<tr>
<td colSpan={7} className="px-8 py-12 text-center">
<div className="flex flex-col items-center justify-center">
<FileText className="w-12 h-12 text-slate-400 mb-4" />
<p className="text-slate-600 font-medium">
All bookings already have invoices
</p>
<button
onClick={() => {
searchParams.delete('createInvoice');
setSearchParams(searchParams);
}}
className="mt-4 px-4 py-2 text-sm font-medium text-amber-700 hover:text-amber-900 hover:bg-amber-100 rounded-lg transition-colors"
>
Show All Bookings
</button>
</div>
</td>
</tr>
);
}
return filteredBookings.map((booking, index) => (
<tr
key={booking.id}
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
@@ -289,7 +536,14 @@ const BookingManagementPage: React.FC = () => {
})()}
</td>
<td className="px-8 py-5 whitespace-nowrap">
{getStatusBadge(booking.status)}
<div className="flex flex-col gap-1">
{getStatusBadge(booking.status)}
{!bookingsWithInvoices.has(booking.id) && (
<span className="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full font-medium">
No Invoice
</span>
)}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
@@ -348,7 +602,8 @@ const BookingManagementPage: React.FC = () => {
</div>
</td>
</tr>
))}
));
})()}
</tbody>
</table>
</div>
@@ -363,8 +618,8 @@ const BookingManagementPage: React.FC = () => {
{}
{showDetailModal && selectedBooking && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden animate-scale-in border border-slate-200">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl w-full max-w-3xl max-h-[95vh] sm:max-h-[90vh] overflow-hidden animate-scale-in border border-slate-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">
@@ -381,9 +636,8 @@ const BookingManagementPage: React.FC = () => {
</div>
</div>
{}
<div className="p-8 overflow-y-auto max-h-[calc(90vh-120px)]">
<div className="space-y-6">
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="p-4 sm:p-6 md:p-8 space-y-5 sm:space-y-6">
{}
<div className="grid grid-cols-2 gap-6">
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
@@ -422,15 +676,14 @@ const BookingManagementPage: React.FC = () => {
</p>
</div>
{}
<div className="grid grid-cols-2 gap-6">
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div className="bg-gradient-to-br from-slate-50 to-white p-4 sm:p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Check-in Date</label>
<p className="text-base font-semibold text-slate-900">{parseDateLocal(selectedBooking.check_in_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
<p className="text-sm sm:text-base font-semibold text-slate-900">{parseDateLocal(selectedBooking.check_in_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
</div>
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<div className="bg-gradient-to-br from-slate-50 to-white p-4 sm:p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Check-out Date</label>
<p className="text-base font-semibold text-slate-900">{parseDateLocal(selectedBooking.check_out_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
<p className="text-sm sm:text-base font-semibold text-slate-900">{parseDateLocal(selectedBooking.check_out_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
</div>
</div>
@@ -661,25 +914,50 @@ const BookingManagementPage: React.FC = () => {
)}
</div>
{}
<div className="mt-8 pt-6 border-t border-slate-200 flex justify-between items-center">
<button
onClick={() => handleCreateInvoice(selectedBooking.id)}
disabled={creatingInvoice}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl"
>
{creatingInvoice ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Creating Invoice...
</>
) : (
<>
<FileText className="w-5 h-5" />
Create Invoice
</>
<div className="mt-6 sm:mt-8 pt-4 sm:pt-6 border-t border-slate-200 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4">
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 w-full sm:w-auto">
<button
onClick={() => handleCreateInvoice(selectedBooking.id, false)}
disabled={creatingInvoice || sendingEmail || bookingsWithInvoices.has(selectedBooking.id)}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl"
>
{creatingInvoice ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Creating Invoice...
</>
) : bookingsWithInvoices.has(selectedBooking.id) ? (
<>
<FileText className="w-5 h-5" />
Invoice Already Exists
</>
) : (
<>
<FileText className="w-5 h-5" />
Create Invoice
</>
)}
</button>
{!bookingsWithInvoices.has(selectedBooking.id) && (
<button
onClick={() => handleCreateInvoice(selectedBooking.id, true)}
disabled={creatingInvoice || sendingEmail}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl font-semibold hover:from-blue-600 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl"
>
{creatingInvoice || sendingEmail ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{creatingInvoice ? 'Creating...' : 'Sending Email...'}
</>
) : (
<>
<Mail className="w-5 h-5" />
Create & Send Invoice
</>
)}
</button>
)}
</button>
</div>
<button
onClick={() => setShowDetailModal(false)}
className="px-8 py-3 bg-gradient-to-r from-slate-700 to-slate-800 text-white rounded-xl font-semibold hover:from-slate-800 hover:to-slate-900 transition-all duration-200 shadow-lg hover:shadow-xl border border-slate-600"

View File

@@ -684,71 +684,80 @@ const CampaignModal: React.FC<{
onClose: () => void;
editing: boolean;
}> = ({ form, setForm, segments, onSave, onClose, editing }) => (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-start mb-4">
<h4 className="text-lg font-semibold">{editing ? 'Edit Campaign' : 'Create Campaign'}</h4>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X className="w-5 h-5" />
</button>
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
<h4 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">{editing ? 'Edit Campaign' : 'Create Campaign'}</h4>
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">
{editing ? 'Modify campaign details' : 'Create a new email campaign'}
</p>
</div>
<button onClick={onClose} className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400">
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<div className="space-y-4">
<input
type="text"
placeholder="Campaign Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
<input
type="text"
placeholder="Subject"
value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
<select
value={form.campaign_type}
onChange={(e) => setForm({ ...form, campaign_type: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="newsletter">Newsletter</option>
<option value="promotional">Promotional</option>
<option value="transactional">Transactional</option>
<option value="abandoned_booking">Abandoned Booking</option>
<option value="welcome">Welcome</option>
</select>
<select
value={form.segment_id || ''}
onChange={(e) => setForm({ ...form, segment_id: e.target.value ? parseInt(e.target.value) : undefined })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="">No Segment (All Users)</option>
{segments.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<textarea
placeholder="HTML Content"
value={form.html_content}
onChange={(e) => setForm({ ...form, html_content: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
rows={10}
/>
<div className="flex gap-2">
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<input
type="text"
placeholder="Campaign Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
/>
<input
type="text"
placeholder="Subject"
value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
/>
<select
value={form.campaign_type}
onChange={(e) => setForm({ ...form, campaign_type: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
>
<option value="newsletter">Newsletter</option>
<option value="promotional">Promotional</option>
<option value="transactional">Transactional</option>
<option value="abandoned_booking">Abandoned Booking</option>
<option value="welcome">Welcome</option>
</select>
<select
value={form.segment_id || ''}
onChange={(e) => setForm({ ...form, segment_id: e.target.value ? parseInt(e.target.value) : undefined })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
>
<option value="">No Segment (All Users)</option>
{segments.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<textarea
placeholder="HTML Content"
value={form.html_content}
onChange={(e) => setForm({ ...form, html_content: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm resize-y"
rows={10}
/>
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
<button
onClick={onSave}
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
className="w-full sm:flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
{editing ? 'Update' : 'Create'}
</button>
<button
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
className="w-full sm:flex-1 px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
@@ -760,52 +769,59 @@ const SegmentModal: React.FC<{
onSave: () => void;
onClose: () => void;
}> = ({ form, setForm, onSave, onClose }) => (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-2xl w-full">
<div className="flex justify-between items-start mb-4">
<h4 className="text-lg font-semibold">Create Segment</h4>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X className="w-5 h-5" />
</button>
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
<h4 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">Create Segment</h4>
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">Create a new email segment</p>
</div>
<button onClick={onClose} className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400">
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<div className="space-y-4">
<input
type="text"
placeholder="Segment Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
<textarea
placeholder="Description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
rows={3}
/>
<select
value={form.criteria.role}
onChange={(e) => setForm({ ...form, criteria: { ...form.criteria, role: e.target.value } })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="">All Roles</option>
<option value="customer">Customer</option>
<option value="admin">Admin</option>
<option value="staff">Staff</option>
</select>
<div className="flex gap-2">
<button
onClick={onSave}
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<input
type="text"
placeholder="Segment Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
/>
<textarea
placeholder="Description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm resize-y"
rows={3}
/>
<select
value={form.criteria.role}
onChange={(e) => setForm({ ...form, criteria: { ...form.criteria, role: e.target.value } })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
>
Create
</button>
<button
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
<option value="">All Roles</option>
<option value="customer">Customer</option>
<option value="admin">Admin</option>
<option value="staff">Staff</option>
</select>
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
<button
onClick={onSave}
className="w-full sm:flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
Create
</button>
<button
onClick={onClose}
className="w-full sm:flex-1 px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
</div>
</div>
</div>
</div>
@@ -818,49 +834,56 @@ const TemplateModal: React.FC<{
onSave: () => void;
onClose: () => void;
}> = ({ form, setForm, onSave, onClose }) => (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-start mb-4">
<h4 className="text-lg font-semibold">Create Template</h4>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X className="w-5 h-5" />
</button>
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
<h4 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">Create Template</h4>
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">Create a new email template</p>
</div>
<button onClick={onClose} className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400">
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<div className="space-y-4">
<input
type="text"
placeholder="Template Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
<input
type="text"
placeholder="Subject"
value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
<textarea
placeholder="HTML Content"
value={form.html_content}
onChange={(e) => setForm({ ...form, html_content: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
rows={15}
/>
<div className="flex gap-2">
<button
onClick={onSave}
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Create
</button>
<button
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<input
type="text"
placeholder="Template Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
/>
<input
type="text"
placeholder="Subject"
value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
/>
<textarea
placeholder="HTML Content"
value={form.html_content}
onChange={(e) => setForm({ ...form, html_content: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm resize-y"
rows={15}
/>
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
<button
onClick={onSave}
className="w-full sm:flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
Create
</button>
<button
onClick={onClose}
className="w-full sm:flex-1 px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
</div>
</div>
</div>
</div>
@@ -873,54 +896,61 @@ const DripSequenceModal: React.FC<{
onSave: () => void;
onClose: () => void;
}> = ({ form, setForm, onSave, onClose }) => (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-2xl w-full">
<div className="flex justify-between items-start mb-4">
<h4 className="text-lg font-semibold">Create Drip Sequence</h4>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X className="w-5 h-5" />
</button>
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
<h4 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">Create Drip Sequence</h4>
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">Create a new email drip sequence</p>
</div>
<button onClick={onClose} className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400">
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<div className="space-y-4">
<input
type="text"
placeholder="Sequence Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
<textarea
placeholder="Description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
rows={3}
/>
<select
value={form.trigger_event}
onChange={(e) => setForm({ ...form, trigger_event: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="">No Trigger (Manual)</option>
<option value="user_signup">User Signup</option>
<option value="booking_created">Booking Created</option>
<option value="booking_cancelled">Booking Cancelled</option>
<option value="check_in">Check In</option>
<option value="check_out">Check Out</option>
</select>
<div className="flex gap-2">
<button
onClick={onSave}
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<input
type="text"
placeholder="Sequence Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
/>
<textarea
placeholder="Description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm resize-y"
rows={3}
/>
<select
value={form.trigger_event}
onChange={(e) => setForm({ ...form, trigger_event: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
>
Create
</button>
<button
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
<option value="">No Trigger (Manual)</option>
<option value="user_signup">User Signup</option>
<option value="booking_created">Booking Created</option>
<option value="booking_cancelled">Booking Cancelled</option>
<option value="check_in">Check In</option>
<option value="check_out">Check Out</option>
</select>
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
<button
onClick={onSave}
className="w-full sm:flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
Create
</button>
<button
onClick={onClose}
className="w-full sm:flex-1 px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,341 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { ArrowLeft, Save } from 'lucide-react';
import { invoiceService, Invoice, UpdateInvoiceData } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
const InvoiceEditPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const location = useLocation();
const isAccountant = location.pathname.includes('/accountant');
const basePath = isAccountant ? '/accountant' : '/admin';
const [invoice, setInvoice] = useState<Invoice | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<UpdateInvoiceData>({
company_name: '',
company_address: '',
company_phone: '',
company_email: '',
company_tax_id: '',
notes: '',
terms_and_conditions: '',
payment_instructions: '',
status: '',
due_date: '',
tax_rate: 0,
discount_amount: 0,
});
useEffect(() => {
if (id) {
const invoiceId = Number(id);
if (!isNaN(invoiceId) && invoiceId > 0) {
fetchInvoice(invoiceId);
} else {
toast.error('Invalid invoice ID');
navigate(`${basePath}/invoices`);
}
}
}, [id, navigate]);
const fetchInvoice = async (invoiceId: number) => {
try {
setLoading(true);
const response = await invoiceService.getInvoiceById(invoiceId);
if (response.status === 'success' && response.data?.invoice) {
const invoiceData = response.data.invoice;
setInvoice(invoiceData);
setFormData({
company_name: invoiceData.company_name || '',
company_address: invoiceData.company_address || '',
company_phone: invoiceData.company_phone || '',
company_email: invoiceData.company_email || '',
company_tax_id: invoiceData.company_tax_id || '',
// company_logo_url is not editable - always uses admin settings
notes: invoiceData.notes || '',
terms_and_conditions: invoiceData.terms_and_conditions || '',
payment_instructions: invoiceData.payment_instructions || '',
status: invoiceData.status || '',
due_date: invoiceData.due_date ? invoiceData.due_date.split('T')[0] : '',
tax_rate: invoiceData.tax_rate || 0,
discount_amount: invoiceData.discount_amount || 0,
});
} else {
toast.error('Invoice not found');
navigate(`${basePath}/invoices`);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load invoice');
navigate(`${basePath}/invoices`);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!id || !invoice) return;
try {
setSaving(true);
const invoiceId = Number(id);
// Remove company_logo_url from formData to prevent it from being updated
// It should always use the admin settings
const { company_logo_url, ...updateData } = formData as any;
const response = await invoiceService.updateInvoice(invoiceId, updateData);
if (response.status === 'success') {
toast.success('Invoice updated successfully!');
navigate(`${basePath}/invoices/${invoiceId}`);
} else {
toast.error(response.message || 'Failed to update invoice');
}
} catch (error: any) {
toast.error(error.response?.data?.message || error.message || 'Unable to update invoice');
} finally {
setSaving(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name === 'tax_rate' || name === 'discount_amount' ? parseFloat(value) || 0 : value,
}));
};
if (loading) {
return <Loading fullScreen text="Loading invoice..." />;
}
if (!invoice) {
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
<div className="bg-white rounded-lg shadow-md p-8 text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Invoice Not Found</h2>
<button
onClick={() => navigate(`${basePath}/invoices`)}
className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Back to Invoices
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<button
onClick={() => navigate(`${basePath}/invoices/${id}`)}
className="inline-flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to Invoice</span>
</button>
<h1 className="text-2xl font-bold text-gray-900">Edit Invoice #{invoice.invoice_number}</h1>
<div></div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-md p-6 space-y-6">
{/* Company Information */}
<div className="border-b border-gray-200 pb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Company Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Company Name</label>
<input
type="text"
name="company_name"
value={formData.company_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Company Email</label>
<input
type="email"
name="company_email"
value={formData.company_email}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Company Phone</label>
<input
type="text"
name="company_phone"
value={formData.company_phone}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Company Tax ID</label>
<input
type="text"
name="company_tax_id"
value={formData.company_tax_id}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Company Address</label>
<textarea
name="company_address"
value={formData.company_address}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Note:</strong> Company logo is automatically set from admin settings and cannot be changed here.
</p>
</div>
</div>
{/* Invoice Details */}
<div className="border-b border-gray-200 pb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Invoice Details</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select
name="status"
value={formData.status}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="draft">Draft</option>
<option value="sent">Sent</option>
<option value="paid">Paid</option>
<option value="overdue">Overdue</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Due Date</label>
<input
type="date"
name="due_date"
value={formData.due_date}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tax Rate (%)</label>
<input
type="number"
name="tax_rate"
value={formData.tax_rate}
onChange={handleChange}
step="0.01"
min="0"
max="100"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Discount Amount</label>
<input
type="number"
name="discount_amount"
value={formData.discount_amount}
onChange={handleChange}
step="0.01"
min="0"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
{/* Additional Information */}
<div className="border-b border-gray-200 pb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Additional Information</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea
name="notes"
value={formData.notes}
onChange={handleChange}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Terms & Conditions</label>
<textarea
name="terms_and_conditions"
value={formData.terms_and_conditions}
onChange={handleChange}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Instructions</label>
<textarea
name="payment_instructions"
value={formData.payment_instructions}
onChange={handleChange}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-4 pt-4">
<button
type="button"
onClick={() => navigate(`${basePath}/invoices/${id}`)}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
{saving ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Saving...
</>
) : (
<>
<Save className="w-4 h-4" />
Save Changes
</>
)}
</button>
</div>
</form>
</div>
</div>
);
};
export default InvoiceEditPage;

View File

@@ -750,23 +750,31 @@ const LoyaltyManagementPage: React.FC = () => {
{/* Tier Modal */}
{showTierModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">
{editingTier ? 'Edit Tier' : 'Create New Tier'}
</h2>
<button
onClick={() => {
setShowTierModal(false);
resetTierForm();
}}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">
{editingTier ? 'Edit Tier' : 'Create New Tier'}
</h2>
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">
{editingTier ? 'Modify tier information' : 'Create a new loyalty tier'}
</p>
</div>
<button
onClick={() => {
setShowTierModal(false);
resetTierForm();
}}
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<form onSubmit={handleTierSubmit} className="p-6 space-y-4">
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<form onSubmit={handleTierSubmit} className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Level</label>
@@ -886,49 +894,58 @@ const LoyaltyManagementPage: React.FC = () => {
</label>
</div>
<div className="flex justify-end gap-3 pt-4 border-t">
<div className="flex flex-col sm:flex-row justify-end gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowTierModal(false);
resetTierForm();
}}
className="px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
className="w-full sm:w-auto px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
<button
type="submit"
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2"
className="w-full sm:w-auto px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl flex items-center justify-center gap-2"
>
<Save className="w-4 h-4" />
<Save className="w-5 h-5" />
{editingTier ? 'Update Tier' : 'Create Tier'}
</button>
</div>
</form>
</form>
</div>
</div>
</div>
)}
{/* Reward Modal */}
{showRewardModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">
{editingReward ? 'Edit Reward' : 'Create New Reward'}
</h2>
<button
onClick={() => {
setShowRewardModal(false);
resetRewardForm();
}}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-3xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">
{editingReward ? 'Edit Reward' : 'Create New Reward'}
</h2>
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">
{editingReward ? 'Modify reward information' : 'Create a new loyalty reward'}
</p>
</div>
<button
onClick={() => {
setShowRewardModal(false);
resetRewardForm();
}}
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<form onSubmit={handleRewardSubmit} className="p-6 space-y-4">
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<form onSubmit={handleRewardSubmit} className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Reward Name</label>
@@ -1107,26 +1124,27 @@ const LoyaltyManagementPage: React.FC = () => {
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t">
<div className="flex flex-col sm:flex-row justify-end gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowRewardModal(false);
resetRewardForm();
}}
className="px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
className="w-full sm:w-auto px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
<button
type="submit"
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2"
className="w-full sm:w-auto px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl flex items-center justify-center gap-2"
>
<Save className="w-4 h-4" />
<Save className="w-5 h-5" />
{editingReward ? 'Update Reward' : 'Create Reward'}
</button>
</div>
</form>
</form>
</div>
</div>
</div>
)}

View File

@@ -468,24 +468,32 @@ const PackageManagementPage: React.FC = () => {
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-5xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 px-8 py-6 flex justify-between items-center rounded-t-2xl">
<h2 className="text-2xl font-bold text-white">
{editingPackage ? 'Edit Package' : 'Create Package'}
</h2>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="p-2 rounded-lg text-white hover:bg-white/20 transition-colors"
>
<X className="w-6 h-6" />
</button>
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 flex items-center justify-center p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-5xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 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 z-10">
<div className="flex justify-between items-center">
<div>
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">
{editingPackage ? 'Edit Package' : 'Create Package'}
</h2>
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">
{editingPackage ? 'Modify package details' : 'Create a new package'}
</p>
</div>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<form onSubmit={handleSubmit} className="p-4 sm:p-6 md:p-8 space-y-5 sm:space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Name *</label>
@@ -678,25 +686,26 @@ const PackageManagementPage: React.FC = () => {
</div>
</div>
<div className="flex justify-end gap-4 pt-6 border-t border-slate-200">
<div className="flex flex-col sm:flex-row justify-end gap-3 pt-6 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all"
className="w-full sm:w-auto px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
<button
type="submit"
className="px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all shadow-lg hover:shadow-xl"
className="w-full sm:w-auto px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
{editingPackage ? 'Update' : 'Create'} Package
</button>
</div>
</form>
</form>
</div>
</div>
</div>
)}

View File

@@ -2349,101 +2349,110 @@ const PageContentDashboard: React.FC = () => {
{/* Banner Modal */}
{showBannerModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowBannerModal(false)}></div>
<div className="flex min-h-full items-center justify-center p-4">
<div className="relative bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<h3 className="text-2xl font-extrabold text-gray-900">
{editingBanner ? 'Edit Banner' : 'Add Banner'}
</h3>
<button
onClick={() => {
setShowBannerModal(false);
resetBannerForm();
}}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
<div className="fixed inset-0 bg-black/70 backdrop-blur-md" onClick={() => setShowBannerModal(false)}></div>
<div className="flex min-h-full items-center justify-center p-3 sm:p-4">
<div className="relative bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">
{editingBanner ? 'Edit Banner' : 'Add Banner'}
</h3>
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">
{editingBanner ? 'Modify banner information' : 'Create a new promotional banner'}
</p>
</div>
<button
onClick={() => {
setShowBannerModal(false);
resetBannerForm();
}}
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<form onSubmit={handleBannerSubmit} className="p-6 space-y-6">
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<form onSubmit={handleBannerSubmit} className="p-4 sm:p-6 space-y-5 sm:space-y-6">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Title *</label>
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">Title *</label>
<input
type="text"
required
value={bannerFormData.title}
onChange={(e) => setBannerFormData({ ...bannerFormData, title: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200"
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"
placeholder="Banner title"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Description (Optional)</label>
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">Description (Optional)</label>
<textarea
value={bannerFormData.description}
onChange={(e) => setBannerFormData({ ...bannerFormData, description: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 min-h-[100px] resize-y"
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 min-h-[100px] resize-y text-slate-700 font-medium shadow-sm"
placeholder="Banner description text that appears below the title"
rows={3}
/>
<p className="text-xs text-gray-500 mt-1">This description will appear below the title on the banner</p>
<p className="text-xs text-slate-500 mt-1">This description will appear below the title on the banner</p>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Link (Optional)</label>
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">Link (Optional)</label>
<input
type="url"
value={bannerFormData.link_url}
onChange={(e) => setBannerFormData({ ...bannerFormData, link_url: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200"
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"
placeholder="https://example.com"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Display Order</label>
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">Display Order</label>
<input
type="number"
min="0"
value={bannerFormData.display_order}
onChange={(e) => setBannerFormData({ ...bannerFormData, display_order: 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"
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"
placeholder="0"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Start Date (Optional)</label>
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">Start Date (Optional)</label>
<input
type="date"
value={bannerFormData.start_date}
onChange={(e) => setBannerFormData({ ...bannerFormData, start_date: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200"
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"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">End Date (Optional)</label>
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">End Date (Optional)</label>
<input
type="date"
value={bannerFormData.end_date}
onChange={(e) => setBannerFormData({ ...bannerFormData, end_date: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200"
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"
/>
</div>
</div>
<div>
<div className="flex items-center gap-4 mb-3">
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-4">
<button
type="button"
onClick={() => setUseFileUpload(true)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
className={`px-4 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200 ${
useFileUpload
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
? 'bg-gradient-to-r from-amber-500 to-amber-600 text-white shadow-lg shadow-amber-500/30'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 border-2 border-slate-200'
}`}
>
Upload File
@@ -2451,10 +2460,10 @@ const PageContentDashboard: React.FC = () => {
<button
type="button"
onClick={() => setUseFileUpload(false)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
className={`px-4 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200 ${
!useFileUpload
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
? 'bg-gradient-to-r from-amber-500 to-amber-600 text-white shadow-lg shadow-amber-500/30'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 border-2 border-slate-200'
}`}
>
Image URL
@@ -2463,8 +2472,8 @@ const PageContentDashboard: React.FC = () => {
{useFileUpload ? (
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Banner Image *</label>
<div className="border-2 border-dashed border-gray-300 rounded-xl p-6 text-center hover:border-purple-400 transition-colors">
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">Banner Image *</label>
<div className="border-2 border-dashed border-slate-300 rounded-xl p-4 sm:p-6 text-center hover:border-amber-400 transition-all duration-200 bg-gradient-to-br from-slate-50 to-white">
<input
type="file"
accept="image/*"
@@ -2480,27 +2489,27 @@ const PageContentDashboard: React.FC = () => {
<img
src={imagePreview}
alt="Preview"
className="max-w-full max-h-64 rounded-lg mb-4"
className="max-w-full max-h-48 sm:max-h-64 rounded-lg mb-4 border-2 border-slate-200"
/>
) : (
<>
<Upload className="w-12 h-12 text-gray-400 mb-4" />
<span className="text-gray-600 font-medium">Click to upload image</span>
<span className="text-gray-500 text-sm mt-1">PNG, JPG up to 5MB</span>
<Upload className="w-10 h-10 sm:w-12 sm:h-12 text-slate-400 mb-3" />
<span className="text-slate-600 font-medium text-sm sm:text-base">Click to upload image</span>
<span className="text-slate-500 text-xs sm:text-sm mt-1">PNG, JPG up to 5MB</span>
</>
)}
</label>
{uploadingImage && (
<div className="mt-4 flex items-center justify-center gap-2 text-purple-600">
<div className="mt-4 flex items-center justify-center gap-2 text-amber-600">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Uploading...</span>
<span className="text-sm font-medium">Uploading...</span>
</div>
)}
</div>
</div>
) : (
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Image URL *</label>
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">Image URL *</label>
<input
type="url"
required
@@ -2509,14 +2518,14 @@ const PageContentDashboard: React.FC = () => {
setBannerFormData({ ...bannerFormData, image_url: e.target.value });
setImagePreview(e.target.value);
}}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200"
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"
placeholder="https://example.com/image.jpg"
/>
{imagePreview && (
<img
src={imagePreview}
alt="Preview"
className="mt-4 max-w-full max-h-64 rounded-lg"
className="mt-4 max-w-full max-h-48 sm:max-h-64 rounded-lg border-2 border-slate-200"
onError={() => setImagePreview(null)}
/>
)}
@@ -2524,21 +2533,21 @@ const PageContentDashboard: React.FC = () => {
)}
</div>
<div className="flex items-center gap-3 pt-4 border-t border-gray-200">
<div className="flex flex-col sm:flex-row items-center gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
setShowBannerModal(false);
resetBannerForm();
}}
className="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-all duration-200"
className="w-full sm:flex-1 px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
<button
type="submit"
disabled={uploadingImage}
className="flex-1 px-6 py-3 bg-gradient-to-r from-purple-500 to-purple-600 text-white rounded-xl font-semibold hover:from-purple-600 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
className="w-full sm:flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{uploadingImage ? (
<>
@@ -2548,12 +2557,13 @@ const PageContentDashboard: React.FC = () => {
) : (
<>
<Save className="w-5 h-5" />
{editingBanner ? 'Update Banner' : 'Create Banner'}
{editingBanner ? 'Update Banner' : 'Add Banner'}
</>
)}
</button>
</div>
</form>
</form>
</div>
</div>
</div>
</div>

View File

@@ -474,39 +474,54 @@ const IPWhitelistTab: React.FC = () => {
</div>
{showAddModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-4 sm:p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
<h4 className="text-base sm:text-lg font-semibold mb-4">Add IP to Whitelist</h4>
<div className="space-y-4">
<input
type="text"
placeholder="IP Address (e.g., 192.168.1.1 or 192.168.1.0/24)"
value={newIP.ip_address}
onChange={(e) => setNewIP({ ...newIP, ip_address: e.target.value })}
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
/>
<textarea
placeholder="Description (optional)"
value={newIP.description}
onChange={(e) => setNewIP({ ...newIP, description: e.target.value })}
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
rows={3}
/>
<div className="flex flex-col sm:flex-row gap-2">
<button
onClick={handleAdd}
className="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm sm:text-base"
>
Add
</button>
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-md w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
<h4 className="text-base sm:text-lg md:text-xl font-bold text-amber-100">Add IP to Whitelist</h4>
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">Add an IP address to the whitelist</p>
</div>
<button
onClick={() => setShowAddModal(false)}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 text-sm sm:text-base"
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"
>
Cancel
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<input
type="text"
placeholder="IP Address (e.g., 192.168.1.1 or 192.168.1.0/24)"
value={newIP.ip_address}
onChange={(e) => setNewIP({ ...newIP, ip_address: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
/>
<textarea
placeholder="Description (optional)"
value={newIP.description}
onChange={(e) => setNewIP({ ...newIP, description: e.target.value })}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm resize-y"
rows={3}
/>
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
<button
onClick={handleAdd}
className="w-full sm:flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
Add
</button>
<button
onClick={() => setShowAddModal(false)}
className="w-full sm:flex-1 px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
)}
@@ -633,10 +648,24 @@ const IPBlacklistTab: React.FC = () => {
</div>
{showAddModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-4 sm:p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
<h4 className="text-base sm:text-lg font-semibold mb-4">Add IP to Blacklist</h4>
<div className="space-y-4">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-md w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
<h4 className="text-base sm:text-lg md:text-xl font-bold text-amber-100">Add IP to Blacklist</h4>
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">Add an IP address to the blacklist</p>
</div>
<button
onClick={() => setShowAddModal(false)}
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<input
type="text"
placeholder="IP Address"
@@ -651,20 +680,21 @@ const IPBlacklistTab: React.FC = () => {
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
rows={3}
/>
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
<button
onClick={handleAdd}
className="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm sm:text-base"
className="w-full sm:flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
Add
</button>
<button
onClick={() => setShowAddModal(false)}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 text-sm sm:text-base"
className="w-full sm:flex-1 px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
@@ -857,12 +887,28 @@ const OAuthProvidersTab: React.FC = () => {
</div>
{showAddModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-4 sm:p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h4 className="text-base sm:text-lg font-semibold mb-4">
{editingProvider ? 'Edit OAuth Provider' : 'Add OAuth Provider'}
</h4>
<div className="space-y-3 sm:space-y-4">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
<h4 className="text-base sm:text-lg md:text-xl font-bold text-amber-100">
{editingProvider ? 'Edit OAuth Provider' : 'Add OAuth Provider'}
</h4>
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">
{editingProvider ? 'Modify OAuth provider settings' : 'Add a new OAuth provider'}
</p>
</div>
<button
onClick={() => setShowAddModal(false)}
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="p-4 sm:p-6 space-y-3 sm:space-y-4">
<input
type="text"
placeholder="Name (e.g., google, microsoft)"
@@ -940,10 +986,10 @@ const OAuthProvidersTab: React.FC = () => {
<span>SSO Enabled</span>
</label>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-2">
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
<button
onClick={editingProvider ? handleUpdate : handleAdd}
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm sm:text-base"
className="w-full sm:flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
{editingProvider ? 'Update' : 'Add'}
</button>
@@ -953,11 +999,12 @@ const OAuthProvidersTab: React.FC = () => {
setEditingProvider(null);
resetForm();
}}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 text-sm sm:text-base"
className="w-full sm:flex-1 px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -358,8 +358,8 @@ const UserManagementPage: React.FC = () => {
{}
{showModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-hidden border border-slate-200 animate-scale-in">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl w-full max-w-md max-h-[95vh] sm:max-h-[90vh] 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-6 py-5 border-b border-slate-700">
<div className="flex justify-between items-center">
@@ -380,9 +380,8 @@ const UserManagementPage: React.FC = () => {
</div>
</div>
{}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
<form onSubmit={handleSubmit} className="space-y-5">
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<form onSubmit={handleSubmit} className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Name
@@ -460,12 +459,12 @@ const UserManagementPage: React.FC = () => {
<option value="inactive">Inactive</option>
</select>
</div>
<div className="flex gap-3 pt-4 border-t border-slate-200">
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => setShowModal(false)}
disabled={isSubmitting}
className="flex-1 px-6 py-3 border-2 border-slate-300 rounded-xl text-slate-700 font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full sm:flex-1 px-6 py-3 border-2 border-slate-300 rounded-xl text-slate-700 font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>

View File

@@ -6,37 +6,125 @@ import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { formatDate } from '../../utils/format';
import useAuthStore from '../../store/useAuthStore';
const InvoicePage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency();
const { userInfo } = useAuthStore();
const [invoice, setInvoice] = useState<Invoice | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (id) {
fetchInvoice(Number(id));
// Handle both string and number IDs, and check for invalid values like "undefined" or "null"
if (id === 'undefined' || id === 'null' || id === 'NaN' || id === '') {
// Don't show error toast for invalid URL parameters - just redirect
setLoading(false);
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
navigate('/admin/bookings?createInvoice=true');
} else {
navigate('/bookings');
}
return;
}
const invoiceId = Number(id);
// Validate that the ID is a valid number
if (!isNaN(invoiceId) && invoiceId > 0 && isFinite(invoiceId)) {
fetchInvoice(invoiceId);
} else {
// Invalid ID format - redirect without showing error toast
setLoading(false);
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
navigate('/admin/bookings?createInvoice=true');
} else {
navigate('/bookings');
}
}
} else {
// No ID provided - redirect without error message
setLoading(false);
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
navigate('/admin/bookings?createInvoice=true');
} else {
navigate('/bookings');
}
}
}, [id]);
}, [id, navigate, userInfo?.role]);
const fetchInvoice = async (invoiceId: number) => {
const fetchInvoice = async (invoiceId: number, retryCount: number = 0) => {
try {
setLoading(true);
const response = await invoiceService.getInvoiceById(invoiceId);
if (response.status === 'success' && response.data?.invoice) {
setInvoice(response.data.invoice);
setLoading(false);
} else {
throw new Error('Invoice not found');
// Invoice not found in response - retry if first attempt
if (retryCount === 0) {
setTimeout(() => {
fetchInvoice(invoiceId, 1);
}, 500);
return;
}
handleInvoiceNotFound();
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load invoice');
navigate('/bookings');
} finally {
setLoading(false);
// Check if it's the "Invalid invoice ID" error from validation
const errorMessage = error.message || error.response?.data?.message || '';
if (errorMessage.includes('Invalid invoice ID') || errorMessage.includes('Invalid Invoice ID')) {
// This is a validation error - don't show toast, just redirect silently
setLoading(false);
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
navigate('/admin/bookings?createInvoice=true');
} else {
navigate('/bookings');
}
return;
}
// If it's a 404 and we haven't retried yet, retry multiple times with increasing delays
// This handles cases where invoice was just created and might not be immediately available
if (error.response?.status === 404 && retryCount < 3) {
// Wait with increasing delay (500ms, 1000ms, 2000ms)
const delay = 500 * Math.pow(2, retryCount);
setTimeout(() => {
fetchInvoice(invoiceId, retryCount + 1);
}, delay);
return;
}
// Handle invoice not found (404) after retries - show appropriate message
if (error.response?.status === 404) {
handleInvoiceNotFound();
} else {
// Other errors (network, server errors, etc.) - only show toast if not a validation error
if (!errorMessage.includes('Invalid')) {
toast.error(error.response?.data?.message || 'Unable to load invoice');
}
setLoading(false);
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
navigate('/admin/bookings?createInvoice=true');
} else {
navigate('/bookings');
}
}
}
};
const handleInvoiceNotFound = () => {
setLoading(false);
// Don't show error toast - just show the "not found" UI or redirect
// The component will show the "Invoice Not Found" UI if invoice is null
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
// For admin/staff, redirect to bookings to create invoice
navigate('/admin/bookings?createInvoice=true');
}
// For customers, the component will show the "Invoice Not Found" message
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'paid':
@@ -76,21 +164,35 @@ const InvoicePage: React.FC = () => {
return <Loading fullScreen text="Loading invoice..." />;
}
if (!invoice) {
if (!invoice && !loading) {
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
<div className="bg-white rounded-lg shadow-md p-8 text-center">
<FileText className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-gray-900 mb-2">Invoice Not Found</h2>
<p className="text-gray-600 mb-6">The invoice you're looking for doesn't exist.</p>
<Link
to="/bookings"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
Back to Bookings
</Link>
<p className="text-gray-600 mb-6">
{userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant'
? 'No invoice found for this ID. You can create an invoice from the bookings page.'
: 'The invoice you\'re looking for doesn\'t exist.'}
</p>
{userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant' ? (
<Link
to="/admin/bookings?createInvoice=true"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<FileText className="w-5 h-5" />
Create Invoice from Booking
</Link>
) : (
<Link
to="/bookings"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
Back to Bookings
</Link>
)}
</div>
</div>
</div>

View File

@@ -87,20 +87,48 @@ const BookingManagementPage: React.FC = () => {
const handleCreateInvoice = async (bookingId: number) => {
try {
// Validate bookingId before proceeding
if (!bookingId || isNaN(bookingId) || bookingId <= 0) {
toast.error('Invalid booking ID');
return;
}
setCreatingInvoice(true);
const invoiceData = {
booking_id: Number(bookingId),
booking_id: bookingId,
};
const response = await invoiceService.createInvoice(invoiceData);
if (response.status === 'success' && response.data?.invoice) {
toast.success('Invoice created successfully!');
setShowDetailModal(false);
navigate(`/staff/invoices/${response.data.invoice.id}`);
// Extract and validate invoice ID - handle both number and string types
let invoiceId = response.data.invoice.id;
// Convert to number if it's a string
if (typeof invoiceId === 'string') {
invoiceId = parseInt(invoiceId, 10);
}
// Validate invoice ID before navigation
if (invoiceId && !isNaN(invoiceId) && invoiceId > 0 && typeof invoiceId === 'number') {
toast.success('Invoice created successfully!');
setShowDetailModal(false);
// Small delay to ensure invoice is fully committed to database
setTimeout(() => {
navigate(`/staff/invoices/${invoiceId}`);
}, 100);
} else {
console.error('Invalid invoice ID received from server', {
invoiceId,
type: typeof invoiceId,
response: response.data
});
throw new Error(`Invalid invoice ID received from server: ${invoiceId}`);
}
} else {
throw new Error('Failed to create invoice');
console.error('Failed to create invoice - invalid response', { response });
throw new Error(response.message || 'Failed to create invoice - invalid response from server');
}
} catch (error: any) {
const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || 'Unable to create invoice';

View File

@@ -113,7 +113,19 @@ export const getInvoices = async (params?: {
};
export const getInvoiceById = async (id: number): Promise<InvoiceResponse> => {
const response = await apiClient.get<any>(`/invoices/${id}`);
// Validate that id is a valid number
// Convert to number if it's a string
let numericId = id;
if (typeof id === 'string') {
numericId = parseInt(id, 10);
}
if (!numericId || isNaN(numericId) || numericId <= 0 || !isFinite(numericId)) {
console.error('Invalid invoice ID in getInvoiceById', { id, numericId, type: typeof id });
throw new Error('Invalid invoice ID');
}
const response = await apiClient.get<any>(`/invoices/${numericId}`);
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
@@ -124,6 +136,10 @@ export const getInvoiceById = async (id: number): Promise<InvoiceResponse> => {
};
export const getInvoicesByBooking = async (bookingId: number): Promise<InvoiceResponse> => {
// Validate that bookingId is a valid number
if (!bookingId || isNaN(bookingId) || bookingId <= 0) {
throw new Error('Invalid booking ID');
}
const response = await apiClient.get<any>(`/invoices/booking/${bookingId}`);
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
@@ -135,6 +151,10 @@ export const getInvoicesByBooking = async (bookingId: number): Promise<InvoiceRe
};
export const createInvoice = async (data: CreateInvoiceData): Promise<InvoiceResponse> => {
// Validate that booking_id is a valid number
if (!data.booking_id || isNaN(data.booking_id) || data.booking_id <= 0) {
throw new Error('Invalid booking ID');
}
const response = await apiClient.post<any>('/invoices', data);
const responseData = response.data;
// Handle both 'status: success' and 'success: true' formats
@@ -146,6 +166,10 @@ export const createInvoice = async (data: CreateInvoiceData): Promise<InvoiceRes
};
export const updateInvoice = async (id: number, data: UpdateInvoiceData): Promise<InvoiceResponse> => {
// Validate that id is a valid number
if (!id || isNaN(id) || id <= 0) {
throw new Error('Invalid invoice ID');
}
const response = await apiClient.put<any>(`/invoices/${id}`, data);
const responseData = response.data;
// Handle both 'status: success' and 'success: true' formats
@@ -157,6 +181,10 @@ export const updateInvoice = async (id: number, data: UpdateInvoiceData): Promis
};
export const markInvoiceAsPaid = async (id: number, amount?: number): Promise<InvoiceResponse> => {
// Validate that id is a valid number
if (!id || isNaN(id) || id <= 0) {
throw new Error('Invalid invoice ID');
}
const response = await apiClient.post<any>(`/invoices/${id}/mark-paid`, { amount });
const responseData = response.data;
// Handle both 'status: success' and 'success: true' formats
@@ -168,6 +196,10 @@ export const markInvoiceAsPaid = async (id: number, amount?: number): Promise<In
};
export const deleteInvoice = async (id: number): Promise<{ status: string; message: string }> => {
// Validate that id is a valid number
if (!id || isNaN(id) || id <= 0) {
throw new Error('Invalid invoice ID');
}
const response = await apiClient.delete<any>(`/invoices/${id}`);
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
@@ -177,6 +209,20 @@ export const deleteInvoice = async (id: number): Promise<{ status: string; messa
};
};
export const sendInvoiceEmail = async (id: number): Promise<{ status: string; message: string }> => {
// Validate that id is a valid number
if (!id || isNaN(id) || id <= 0) {
throw new Error('Invalid invoice ID');
}
const response = await apiClient.post<any>(`/invoices/${id}/send-email`);
const data = response.data;
// Handle both 'status: success' and 'success: true' formats
return {
status: data.status === 'success' || data.success === true ? 'success' : 'error',
message: data.message || 'Invoice sent successfully',
};
};
const invoiceService = {
getInvoices,
getInvoiceById,
@@ -185,6 +231,7 @@ const invoiceService = {
updateInvoice,
markInvoiceAsPaid,
deleteInvoice,
sendInvoiceEmail,
};
export default invoiceService;