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: except Exception as e:
db.rollback() db.rollback()
logger.error(f'Error fetching invoices by booking: {str(e)}', exc_info=True) 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)) 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.booking import Booking
from ..models.payment import Payment, PaymentStatus from ..models.payment import Payment, PaymentStatus
from ..models.user import User from ..models.user import User
from ..models.system_settings import SystemSettings
from ..config.logging_config import get_logger from ..config.logging_config import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -29,14 +30,33 @@ class InvoiceService:
@staticmethod @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]: 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 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}) 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: if not booking:
logger.error(f'Booking {booking_id} not found', extra={'booking_id': booking_id, 'request_id': request_id}) logger.error(f'Booking {booking_id} not found', extra={'booking_id': booking_id, 'request_id': request_id})
raise ValueError('Booking not found') raise ValueError('Booking not found')
user = db.query(User).filter(User.id == booking.user_id).first() user = db.query(User).filter(User.id == booking.user_id).first()
if not user: if not user:
raise ValueError('User not found') 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) invoice_number = generate_invoice_number(db, is_proforma=is_proforma)
booking_total = float(booking.total_price) booking_total = float(booking.total_price)
if invoice_amount is not None: if invoice_amount is not None:
@@ -61,7 +81,34 @@ class InvoiceService:
else: else:
status = InvoiceStatus.draft status = InvoiceStatus.draft
paid_date = None 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.add(invoice)
db.flush() db.flush()
services_total = sum((float(su.total_price) for su in booking.service_usages)) 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: if 'tax_rate' in kwargs or 'discount_amount' in kwargs:
tax_rate = kwargs.get('tax_rate', invoice.tax_rate) tax_rate = kwargs.get('tax_rate', invoice.tax_rate)
discount_amount = kwargs.get('discount_amount', invoice.discount_amount) discount_amount = kwargs.get('discount_amount', invoice.discount_amount)
invoice.tax_amount = (invoice.subtotal - discount_amount) * (float(tax_rate) / 100) # Convert decimal types to float for arithmetic operations
invoice.total_amount = invoice.subtotal + invoice.tax_amount - discount_amount subtotal = float(invoice.subtotal) if invoice.subtotal else 0.0
invoice.balance_due = invoice.total_amount - invoice.amount_paid 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: if invoice.balance_due <= 0 and invoice.status != InvoiceStatus.paid:
invoice.status = InvoiceStatus.paid invoice.status = InvoiceStatus.paid
invoice.paid_date = datetime.utcnow() 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 PayPalCancelPage = lazy(() => import('./pages/customer/PayPalCancelPage'));
const BoricaReturnPage = lazy(() => import('./pages/customer/BoricaReturnPage')); const BoricaReturnPage = lazy(() => import('./pages/customer/BoricaReturnPage'));
const InvoicePage = lazy(() => import('./pages/customer/InvoicePage')); const InvoicePage = lazy(() => import('./pages/customer/InvoicePage'));
const InvoiceEditPage = lazy(() => import('./pages/admin/InvoiceEditPage'));
const ProfilePage = lazy(() => import('./pages/customer/ProfilePage')); const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
const LoyaltyPage = lazy(() => import('./pages/customer/LoyaltyPage')); const LoyaltyPage = lazy(() => import('./pages/customer/LoyaltyPage'));
const GroupBookingPage = lazy(() => import('./pages/customer/GroupBookingPage')); 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 UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage'));
const GuestProfilePage = lazy(() => import('./pages/admin/GuestProfilePage')); const GuestProfilePage = lazy(() => import('./pages/admin/GuestProfilePage'));
const GroupBookingManagementPage = lazy(() => import('./pages/admin/GroupBookingManagementPage')); const GroupBookingManagementPage = lazy(() => import('./pages/admin/GroupBookingManagementPage'));
const AdminBookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage'));
const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard')); const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard'));
const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage')); const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage'));
const BusinessDashboardPage = lazy(() => import('./pages/admin/BusinessDashboardPage')); const BusinessDashboardPage = lazy(() => import('./pages/admin/BusinessDashboardPage'));
@@ -467,6 +469,10 @@ function App() {
path="invoices" path="invoices"
element={<InvoiceManagementPage />} element={<InvoiceManagementPage />}
/> />
<Route
path="invoices/:id/edit"
element={<InvoiceEditPage />}
/>
<Route <Route
path="invoices/:id" path="invoices/:id"
element={<InvoicePage />} element={<InvoicePage />}
@@ -487,6 +493,10 @@ function App() {
path="group-bookings" path="group-bookings"
element={<GroupBookingManagementPage />} element={<GroupBookingManagementPage />}
/> />
<Route
path="bookings"
element={<AdminBookingManagementPage />}
/>
<Route <Route
path="rate-plans" path="rate-plans"
element={<RatePlanManagementPage />} element={<RatePlanManagementPage />}
@@ -587,6 +597,14 @@ function App() {
path="invoices" path="invoices"
element={<AccountantInvoiceManagementPage />} element={<AccountantInvoiceManagementPage />}
/> />
<Route
path="invoices/:id/edit"
element={<InvoiceEditPage />}
/>
<Route
path="invoices/:id"
element={<InvoicePage />}
/>
<Route <Route
path="reports" path="reports"
element={<AccountantAnalyticsDashboardPage />} element={<AccountantAnalyticsDashboardPage />}

View File

@@ -4,8 +4,6 @@ import {
Hotel, Hotel,
User, User,
LogOut, LogOut,
Menu,
X,
LogIn, LogIn,
UserPlus, UserPlus,
Heart, Heart,
@@ -20,6 +18,7 @@ import { useCompanySettings } from '../../contexts/CompanySettingsContext';
import { useAuthModal } from '../../contexts/AuthModalContext'; import { useAuthModal } from '../../contexts/AuthModalContext';
import { normalizeImageUrl } from '../../utils/imageUtils'; import { normalizeImageUrl } from '../../utils/imageUtils';
import InAppNotificationBell from '../notifications/InAppNotificationBell'; import InAppNotificationBell from '../notifications/InAppNotificationBell';
import Navbar from './Navbar';
interface HeaderProps { interface HeaderProps {
isAuthenticated?: boolean; isAuthenticated?: boolean;
@@ -76,6 +75,199 @@ const Header: React.FC<HeaderProps> = ({
setIsMobileMenuOpen(false); 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 ( return (
<header className="bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f] sticky top-0 z-50 border-b border-[#d4af37]/20 shadow-2xl"> <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"> <div className="hidden lg:block bg-[#0a0a0a]/50 border-b border-[#d4af37]/10">
@@ -128,58 +320,13 @@ const Header: React.FC<HeaderProps> = ({
</div> </div>
</Link> </Link>
{} <Navbar
<nav className="hidden md:flex items-center isMobileMenuOpen={isMobileMenuOpen}
space-x-1" onMobileMenuToggle={toggleMobileMenu}
> onLinkClick={() => setIsMobileMenuOpen(false)}
<Link mobileMenuContent={mobileMenuContent}
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>
{}
<div className="hidden md:flex items-center <div className="hidden md:flex items-center
space-x-3" space-x-3"
> >
@@ -381,226 +528,7 @@ const Header: React.FC<HeaderProps> = ({
</div> </div>
</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> </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>
</div> </div>
</header> </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 Header } from './Header';
export { default as Footer } from './Footer'; export { default as Footer } from './Footer';
export { default as Navbar } from './Navbar';
export { default as SidebarAdmin } from './SidebarAdmin'; export { default as SidebarAdmin } from './SidebarAdmin';
export { default as SidebarStaff } from './SidebarStaff'; export { default as SidebarStaff } from './SidebarStaff';
export { default as SidebarAccountant } from './SidebarAccountant'; export { default as SidebarAccountant } from './SidebarAccountant';

View File

@@ -261,9 +261,9 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
{} {}
{children && ( {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="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} {children}
</div> </div>
</div> </div>
@@ -277,7 +277,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
<button <button
onClick={goToPrevious} onClick={goToPrevious}
type="button" type="button"
className="absolute left-2 sm:left-4 top-1/2 className={`absolute left-2 sm:left-4
-translate-y-1/2 -translate-y-1/2
bg-white/90 bg-white/90
hover:bg-white text-gray-800 hover:bg-white text-gray-800
@@ -287,7 +287,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
transition-all duration-300 z-40 transition-all duration-300 z-40
hover:scale-110 hover:shadow-2xl hover:scale-110 hover:shadow-2xl
active:scale-95 cursor-pointer active:scale-95 cursor-pointer
group" group ${children ? 'top-[35%] sm:top-[40%] md:top-1/2' : 'top-1/2'}`}
aria-label="Previous banner" 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" /> <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 <button
onClick={goToNext} onClick={goToNext}
type="button" type="button"
className="absolute right-2 sm:right-4 top-1/2 className={`absolute right-2 sm:right-4
-translate-y-1/2 -translate-y-1/2
bg-white/90 bg-white/90
hover:bg-white text-gray-800 hover:bg-white text-gray-800
@@ -306,7 +306,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
transition-all duration-300 z-40 transition-all duration-300 z-40
hover:scale-110 hover:shadow-2xl hover:scale-110 hover:shadow-2xl
active:scale-95 cursor-pointer active:scale-95 cursor-pointer
group" group ${children ? 'top-[35%] sm:top-[40%] md:top-1/2' : 'top-1/2'}`}
aria-label="Next banner" 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" /> <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 && ( {displayBanners.length > 1 && (
<div <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 -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 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) => ( {displayBanners.map((_, index) => (
<button <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}`}> <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) && ( {(!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={`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-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>
<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`}> <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'} {isOverlay && isMobile ? 'Find Rooms' : 'Find Available Rooms'}
</h3> </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> </div>
)} )}
<form onSubmit={handleSearch}> <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={`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 ? 'sm:col-span-1 lg:col-span-3' : 'md:col-span-3'}> <div className={isOverlay ? 'col-span-1 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> <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 <DatePicker
selected={checkInDate} selected={checkInDate}
onChange={(date) => setCheckInDate(date)} onChange={(date) => setCheckInDate(date)}
@@ -126,14 +126,14 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
startDate={checkInDate} startDate={checkInDate}
endDate={checkOutDate} endDate={checkOutDate}
minDate={today} minDate={today}
placeholderText="Check-in" placeholderText={isOverlay && isMobile ? "In" : "Check-in"}
dateFormat={isOverlay && isMobile ? "dd/MM" : "dd/MM/yyyy"} 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>
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-3' : 'md:col-span-3'}> <div className={isOverlay ? 'col-span-1 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> <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 <DatePicker
selected={checkOutDate} selected={checkOutDate}
onChange={(date) => setCheckOutDate(date)} onChange={(date) => setCheckOutDate(date)}
@@ -141,48 +141,48 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
startDate={checkInDate} startDate={checkInDate}
endDate={checkOutDate} endDate={checkOutDate}
minDate={checkInDate || today} minDate={checkInDate || today}
placeholderText="Check-out" placeholderText={isOverlay && isMobile ? "Out" : "Check-out"}
dateFormat={isOverlay && isMobile ? "dd/MM" : "dd/MM/yyyy"} 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>
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-2' : 'md:col-span-2'}> <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-xs sm:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-1 sm:mb-2' : 'mb-2'} tracking-wide`}>Room Type</label> <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 <select
value={roomType} value={roomType}
onChange={(e) => setRoomType(e.target.value)} 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="">All</option>
<option value="Standard Room">Standard Room</option> <option value="Standard Room">Standard</option>
<option value="Deluxe Room">Deluxe Room</option> <option value="Deluxe Room">Deluxe</option>
<option value="Luxury Room">Luxury Room</option> <option value="Luxury Room">Luxury</option>
<option value="Family Room">Family Room</option> <option value="Family Room">Family</option>
<option value="Twin Room">Twin Room</option> <option value="Twin Room">Twin</option>
</select> </select>
</div> </div>
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-2' : 'md:col-span-2'}> <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-xs sm:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-1 sm:mb-2' : 'mb-2'} tracking-wide`}>Guests</label> <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 <select
value={guestCount} value={guestCount}
onChange={(e) => setGuestCount(Number(e.target.value))} 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) => ( {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> </select>
</div> </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 <button
type="submit" type="submit"
disabled={isSearching} 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"> <span className="relative z-10">
{isSearching ? 'Searching...' : 'Search'} {isSearching ? 'Searching...' : 'Search'}
</span> </span>

View File

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

View File

@@ -530,32 +530,40 @@ const BlogManagementPage: React.FC = () => {
{/* Create/Edit Modal */} {/* Create/Edit Modal */}
{showModal && ( {showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-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-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"> <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-white border-b border-gray-200 px-6 py-4 flex items-center justify-between"> <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">
<h2 className="text-xl font-bold text-gray-900"> <div className="flex justify-between items-center">
{editingPost ? 'Edit Blog Post' : 'Create Blog Post'} <div>
</h2> <h2 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">
<button {editingPost ? 'Edit Blog Post' : 'Create Blog Post'}
onClick={handleCloseModal} </h2>
className="text-gray-400 hover:text-gray-600" <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'}
<X className="w-6 h-6" /> </p>
</button> </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> </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> <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 <input
type="text" type="text"
required required
value={formData.title} value={formData.title}
onChange={(e) => handleTitleChange(e.target.value)} 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" 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>
<div> <div>
@@ -1148,24 +1156,25 @@ const BlogManagementPage: React.FC = () => {
)} )}
</div> </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 <button
type="button" type="button"
onClick={handleCloseModal} 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 Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={saving} 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'} {editingPost ? 'Update' : 'Create'}
</button> </button>
</div> </div>
</form> </form>
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,25 +1,29 @@
import React, { useEffect, useState } from 'react'; 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 { bookingService, Booking, invoiceService } from '../../services/api';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading'; import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination'; import Pagination from '../../components/common/Pagination';
import { useFormatCurrency } from '../../hooks/useFormatCurrency'; import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format'; 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 CreateBookingModal from '../../components/shared/CreateBookingModal';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
const BookingManagementPage: React.FC = () => { const BookingManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency(); const { formatCurrency } = useFormatCurrency();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [bookings, setBookings] = useState<Booking[]>([]); const [bookings, setBookings] = useState<Booking[]>([]);
const [bookingsWithInvoices, setBookingsWithInvoices] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [checkingInvoices, setCheckingInvoices] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null); const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false); const [showDetailModal, setShowDetailModal] = useState(false);
const [updatingBookingId, setUpdatingBookingId] = useState<number | null>(null); const [updatingBookingId, setUpdatingBookingId] = useState<number | null>(null);
const [cancellingBookingId, setCancellingBookingId] = useState<number | null>(null); const [cancellingBookingId, setCancellingBookingId] = useState<number | null>(null);
const [creatingInvoice, setCreatingInvoice] = useState(false); const [creatingInvoice, setCreatingInvoice] = useState(false);
const [sendingEmail, setSendingEmail] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
search: '', search: '',
@@ -29,6 +33,7 @@ const BookingManagementPage: React.FC = () => {
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0); const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5; const itemsPerPage = 5;
const showOnlyWithoutInvoices = searchParams.get('createInvoice') === 'true';
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
@@ -38,6 +43,16 @@ const BookingManagementPage: React.FC = () => {
fetchBookings(); fetchBookings();
}, [filters, currentPage]); }, [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 () => { const fetchBookings = async () => {
try { try {
setLoading(true); 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) => { const handleUpdateStatus = async (id: number, status: string) => {
try { try {
setUpdatingBookingId(id); setUpdatingBookingId(id);
@@ -86,26 +152,115 @@ const BookingManagementPage: React.FC = () => {
} }
}; };
const handleCreateInvoice = async (bookingId: number) => { const handleCreateInvoice = async (bookingId: number, sendEmail: boolean = false) => {
try { try {
// Validate bookingId before proceeding
if (!bookingId || isNaN(bookingId) || bookingId <= 0) {
toast.error('Invalid booking ID');
return;
}
setCreatingInvoice(true); setCreatingInvoice(true);
const invoiceData = { const invoiceData = {
booking_id: Number(bookingId), booking_id: bookingId,
}; };
const response = await invoiceService.createInvoice(invoiceData); const response = await invoiceService.createInvoice(invoiceData);
if (response.status === 'success' && response.data?.invoice) { // Log the full response for debugging
toast.success('Invoice created successfully!'); console.log('Invoice creation response:', JSON.stringify(response, null, 2));
setShowDetailModal(false); logger.info('Invoice creation response', { response });
navigate(`/admin/invoices/${response.data.invoice.id}`);
} else { // Check response structure - handle different possible formats
throw new Error('Failed to create invoice'); 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) { } 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'; 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); logger.error('Invoice creation error', error);
} finally { } finally {
setCreatingInvoice(false); setCreatingInvoice(false);
@@ -182,6 +337,29 @@ const BookingManagementPage: React.FC = () => {
</div> </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="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="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="relative group"> <div className="relative group">
@@ -225,7 +403,76 @@ const BookingManagementPage: React.FC = () => {
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-slate-100"> <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 <tr
key={booking.id} 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" 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>
<td className="px-8 py-5 whitespace-nowrap"> <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>
<td className="px-8 py-5 whitespace-nowrap text-right"> <td className="px-8 py-5 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
@@ -348,7 +602,8 @@ const BookingManagementPage: React.FC = () => {
</div> </div>
</td> </td>
</tr> </tr>
))} ));
})()}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -363,8 +618,8 @@ const BookingManagementPage: React.FC = () => {
{} {}
{showDetailModal && selectedBooking && ( {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="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-3xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden animate-scale-in border border-slate-200"> <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="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@@ -381,9 +636,8 @@ const BookingManagementPage: React.FC = () => {
</div> </div>
</div> </div>
{} <div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="p-8 overflow-y-auto max-h-[calc(90vh-120px)]"> <div className="p-4 sm:p-6 md:p-8 space-y-5 sm:space-y-6">
<div className="space-y-6">
{} {}
<div className="grid grid-cols-2 gap-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"> <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> </p>
</div> </div>
{} <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div className="grid grid-cols-2 gap-6"> <div className="bg-gradient-to-br from-slate-50 to-white p-4 sm:p-5 rounded-xl border border-slate-200">
<div className="bg-gradient-to-br from-slate-50 to-white 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> <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>
<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> <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>
</div> </div>
@@ -661,25 +914,50 @@ const BookingManagementPage: React.FC = () => {
)} )}
</div> </div>
{} <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="mt-8 pt-6 border-t border-slate-200 flex justify-between items-center"> <div className="flex flex-col sm:flex-row gap-2 sm:gap-3 w-full sm:w-auto">
<button <button
onClick={() => handleCreateInvoice(selectedBooking.id)} onClick={() => handleCreateInvoice(selectedBooking.id, false)}
disabled={creatingInvoice} 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" 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 ? ( {creatingInvoice ? (
<> <>
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />
Creating Invoice... Creating Invoice...
</> </>
) : ( ) : bookingsWithInvoices.has(selectedBooking.id) ? (
<> <>
<FileText className="w-5 h-5" /> <FileText className="w-5 h-5" />
Create Invoice 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 <button
onClick={() => setShowDetailModal(false)} 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" 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; onClose: () => void;
editing: boolean; editing: boolean;
}> = ({ form, setForm, segments, onSave, onClose, editing }) => ( }> = ({ 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="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-xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto"> <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="flex justify-between items-start mb-4"> <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">
<h4 className="text-lg font-semibold">{editing ? 'Edit Campaign' : 'Create Campaign'}</h4> <div className="flex justify-between items-center">
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"> <div>
<X className="w-5 h-5" /> <h4 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">{editing ? 'Edit Campaign' : 'Create Campaign'}</h4>
</button> <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>
<div className="space-y-4"> <div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<input <div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
type="text" <input
placeholder="Campaign Name" type="text"
value={form.name} placeholder="Campaign Name"
onChange={(e) => setForm({ ...form, name: e.target.value })} value={form.name}
className="w-full px-4 py-2 border border-gray-300 rounded-lg" 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" <input
placeholder="Subject" type="text"
value={form.subject} placeholder="Subject"
onChange={(e) => setForm({ ...form, subject: e.target.value })} value={form.subject}
className="w-full px-4 py-2 border border-gray-300 rounded-lg" 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} <select
onChange={(e) => setForm({ ...form, campaign_type: e.target.value })} value={form.campaign_type}
className="w-full px-4 py-2 border border-gray-300 rounded-lg" 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="newsletter">Newsletter</option>
<option value="transactional">Transactional</option> <option value="promotional">Promotional</option>
<option value="abandoned_booking">Abandoned Booking</option> <option value="transactional">Transactional</option>
<option value="welcome">Welcome</option> <option value="abandoned_booking">Abandoned Booking</option>
</select> <option value="welcome">Welcome</option>
<select </select>
value={form.segment_id || ''} <select
onChange={(e) => setForm({ ...form, segment_id: e.target.value ? parseInt(e.target.value) : undefined })} value={form.segment_id || ''}
className="w-full px-4 py-2 border border-gray-300 rounded-lg" 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 value="">No Segment (All Users)</option>
<option key={s.id} value={s.id}>{s.name}</option> {segments.map(s => (
))} <option key={s.id} value={s.id}>{s.name}</option>
</select> ))}
<textarea </select>
placeholder="HTML Content" <textarea
value={form.html_content} placeholder="HTML Content"
onChange={(e) => setForm({ ...form, html_content: e.target.value })} value={form.html_content}
className="w-full px-4 py-2 border border-gray-300 rounded-lg" onChange={(e) => setForm({ ...form, html_content: e.target.value })}
rows={10} 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 gap-2"> />
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
<button <button
onClick={onSave} 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'} {editing ? 'Update' : 'Create'}
</button> </button>
<button <button
onClick={onClose} 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 Cancel
</button> </button>
</div> </div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -760,52 +769,59 @@ const SegmentModal: React.FC<{
onSave: () => void; onSave: () => void;
onClose: () => void; onClose: () => void;
}> = ({ form, setForm, onSave, onClose }) => ( }> = ({ form, setForm, onSave, onClose }) => (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-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-xl p-6 max-w-2xl w-full"> <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="flex justify-between items-start mb-4"> <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">
<h4 className="text-lg font-semibold">Create Segment</h4> <div className="flex justify-between items-center">
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"> <div>
<X className="w-5 h-5" /> <h4 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">Create Segment</h4>
</button> <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>
<div className="space-y-4"> <div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<input <div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
type="text" <input
placeholder="Segment Name" type="text"
value={form.name} placeholder="Segment Name"
onChange={(e) => setForm({ ...form, name: e.target.value })} value={form.name}
className="w-full px-4 py-2 border border-gray-300 rounded-lg" 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" <textarea
value={form.description} placeholder="Description"
onChange={(e) => setForm({ ...form, description: e.target.value })} value={form.description}
className="w-full px-4 py-2 border border-gray-300 rounded-lg" onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={3} 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} <select
onChange={(e) => setForm({ ...form, criteria: { ...form.criteria, role: e.target.value } })} value={form.criteria.role}
className="w-full px-4 py-2 border border-gray-300 rounded-lg" 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"
<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"
> >
Create <option value="">All Roles</option>
</button> <option value="customer">Customer</option>
<button <option value="admin">Admin</option>
onClick={onClose} <option value="staff">Staff</option>
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300" </select>
> <div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
Cancel <button
</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> </div>
</div> </div>
@@ -818,49 +834,56 @@ const TemplateModal: React.FC<{
onSave: () => void; onSave: () => void;
onClose: () => void; onClose: () => void;
}> = ({ form, setForm, onSave, onClose }) => ( }> = ({ form, setForm, onSave, onClose }) => (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-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-xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto"> <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="flex justify-between items-start mb-4"> <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">
<h4 className="text-lg font-semibold">Create Template</h4> <div className="flex justify-between items-center">
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"> <div>
<X className="w-5 h-5" /> <h4 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">Create Template</h4>
</button> <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>
<div className="space-y-4"> <div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<input <div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
type="text" <input
placeholder="Template Name" type="text"
value={form.name} placeholder="Template Name"
onChange={(e) => setForm({ ...form, name: e.target.value })} value={form.name}
className="w-full px-4 py-2 border border-gray-300 rounded-lg" 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" <input
placeholder="Subject" type="text"
value={form.subject} placeholder="Subject"
onChange={(e) => setForm({ ...form, subject: e.target.value })} value={form.subject}
className="w-full px-4 py-2 border border-gray-300 rounded-lg" 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" <textarea
value={form.html_content} placeholder="HTML Content"
onChange={(e) => setForm({ ...form, html_content: e.target.value })} value={form.html_content}
className="w-full px-4 py-2 border border-gray-300 rounded-lg" onChange={(e) => setForm({ ...form, html_content: e.target.value })}
rows={15} 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 gap-2"> />
<button <div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
onClick={onSave} <button
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600" 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> Create
<button </button>
onClick={onClose} <button
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300" 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> Cancel
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -873,54 +896,61 @@ const DripSequenceModal: React.FC<{
onSave: () => void; onSave: () => void;
onClose: () => void; onClose: () => void;
}> = ({ form, setForm, onSave, onClose }) => ( }> = ({ form, setForm, onSave, onClose }) => (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-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-xl p-6 max-w-2xl w-full"> <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="flex justify-between items-start mb-4"> <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">
<h4 className="text-lg font-semibold">Create Drip Sequence</h4> <div className="flex justify-between items-center">
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"> <div>
<X className="w-5 h-5" /> <h4 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">Create Drip Sequence</h4>
</button> <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>
<div className="space-y-4"> <div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<input <div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
type="text" <input
placeholder="Sequence Name" type="text"
value={form.name} placeholder="Sequence Name"
onChange={(e) => setForm({ ...form, name: e.target.value })} value={form.name}
className="w-full px-4 py-2 border border-gray-300 rounded-lg" 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" <textarea
value={form.description} placeholder="Description"
onChange={(e) => setForm({ ...form, description: e.target.value })} value={form.description}
className="w-full px-4 py-2 border border-gray-300 rounded-lg" onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={3} 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} <select
onChange={(e) => setForm({ ...form, trigger_event: e.target.value })} value={form.trigger_event}
className="w-full px-4 py-2 border border-gray-300 rounded-lg" 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"
<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"
> >
Create <option value="">No Trigger (Manual)</option>
</button> <option value="user_signup">User Signup</option>
<button <option value="booking_created">Booking Created</option>
onClick={onClose} <option value="booking_cancelled">Booking Cancelled</option>
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300" <option value="check_in">Check In</option>
> <option value="check_out">Check Out</option>
Cancel </select>
</button> <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> </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 */} {/* Tier Modal */}
{showTierModal && ( {showTierModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-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-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"> <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-white border-b border-gray-200 px-6 py-4 flex items-center justify-between"> <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">
<h2 className="text-xl font-bold text-gray-900"> <div className="flex justify-between items-center">
{editingTier ? 'Edit Tier' : 'Create New Tier'} <div>
</h2> <h2 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">
<button {editingTier ? 'Edit Tier' : 'Create New Tier'}
onClick={() => { </h2>
setShowTierModal(false); <p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">
resetTierForm(); {editingTier ? 'Modify tier information' : 'Create a new loyalty tier'}
}} </p>
className="text-gray-400 hover:text-gray-600" </div>
> <button
<X className="w-5 h-5" /> onClick={() => {
</button> 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> </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 className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Level</label> <label className="block text-sm font-medium text-gray-700 mb-1">Level</label>
@@ -886,49 +894,58 @@ const LoyaltyManagementPage: React.FC = () => {
</label> </label>
</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 <button
type="button" type="button"
onClick={() => { onClick={() => {
setShowTierModal(false); setShowTierModal(false);
resetTierForm(); 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 Cancel
</button> </button>
<button <button
type="submit" 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'} {editingTier ? 'Update Tier' : 'Create Tier'}
</button> </button>
</div> </div>
</form> </form>
</div>
</div> </div>
</div> </div>
)} )}
{/* Reward Modal */} {/* Reward Modal */}
{showRewardModal && ( {showRewardModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-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-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto"> <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-white border-b border-gray-200 px-6 py-4 flex items-center justify-between"> <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">
<h2 className="text-xl font-bold text-gray-900"> <div className="flex justify-between items-center">
{editingReward ? 'Edit Reward' : 'Create New Reward'} <div>
</h2> <h2 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">
<button {editingReward ? 'Edit Reward' : 'Create New Reward'}
onClick={() => { </h2>
setShowRewardModal(false); <p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">
resetRewardForm(); {editingReward ? 'Modify reward information' : 'Create a new loyalty reward'}
}} </p>
className="text-gray-400 hover:text-gray-600" </div>
> <button
<X className="w-5 h-5" /> onClick={() => {
</button> 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> </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 className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Reward Name</label> <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> </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 <button
type="button" type="button"
onClick={() => { onClick={() => {
setShowRewardModal(false); setShowRewardModal(false);
resetRewardForm(); 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 Cancel
</button> </button>
<button <button
type="submit" 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'} {editingReward ? 'Update Reward' : 'Create Reward'}
</button> </button>
</div> </div>
</form> </form>
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -468,24 +468,32 @@ const PackageManagementPage: React.FC = () => {
{/* Create/Edit Modal */} {/* Create/Edit Modal */}
{showModal && ( {showModal && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4"> <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 shadow-2xl max-w-5xl w-full max-h-[90vh] overflow-y-auto"> <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-amber-500 to-amber-600 px-8 py-6 flex justify-between items-center rounded-t-2xl"> <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">
<h2 className="text-2xl font-bold text-white"> <div className="flex justify-between items-center">
{editingPackage ? 'Edit Package' : 'Create Package'} <div>
</h2> <h2 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">
<button {editingPackage ? 'Edit Package' : 'Create Package'}
onClick={() => { </h2>
setShowModal(false); <p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">
resetForm(); {editingPackage ? 'Modify package details' : 'Create a new package'}
}} </p>
className="p-2 rounded-lg text-white hover:bg-white/20 transition-colors" </div>
> <button
<X className="w-6 h-6" /> onClick={() => {
</button> 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> </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 className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Name *</label> <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> </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 <button
type="button" type="button"
onClick={() => { onClick={() => {
setShowModal(false); setShowModal(false);
resetForm(); 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 Cancel
</button> </button>
<button <button
type="submit" 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 {editingPackage ? 'Update' : 'Create'} Package
</button> </button>
</div> </div>
</form> </form>
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -2349,101 +2349,110 @@ const PageContentDashboard: React.FC = () => {
{/* Banner Modal */} {/* Banner Modal */}
{showBannerModal && ( {showBannerModal && (
<div className="fixed inset-0 z-50 overflow-y-auto"> <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="fixed inset-0 bg-black/70 backdrop-blur-md" onClick={() => setShowBannerModal(false)}></div>
<div className="flex min-h-full items-center justify-center p-4"> <div className="flex min-h-full items-center justify-center p-3 sm:p-4">
<div className="relative bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"> <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-white border-b border-gray-200 px-6 py-4 flex items-center justify-between"> <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">
<h3 className="text-2xl font-extrabold text-gray-900"> <div className="flex justify-between items-center">
{editingBanner ? 'Edit Banner' : 'Add Banner'} <div>
</h3> <h3 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">
<button {editingBanner ? 'Edit Banner' : 'Add Banner'}
onClick={() => { </h3>
setShowBannerModal(false); <p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">
resetBannerForm(); {editingBanner ? 'Modify banner information' : 'Create a new promotional banner'}
}} </p>
className="text-gray-400 hover:text-gray-600 transition-colors" </div>
> <button
<X className="w-6 h-6" /> onClick={() => {
</button> 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> </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> <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 <input
type="text" type="text"
required required
value={bannerFormData.title} value={bannerFormData.title}
onChange={(e) => setBannerFormData({ ...bannerFormData, title: e.target.value })} 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" placeholder="Banner title"
/> />
</div> </div>
<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 <textarea
value={bannerFormData.description} value={bannerFormData.description}
onChange={(e) => setBannerFormData({ ...bannerFormData, description: e.target.value })} 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" placeholder="Banner description text that appears below the title"
rows={3} 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>
<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 <input
type="url" type="url"
value={bannerFormData.link_url} value={bannerFormData.link_url}
onChange={(e) => setBannerFormData({ ...bannerFormData, link_url: e.target.value })} 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" placeholder="https://example.com"
/> />
</div> </div>
<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 <input
type="number" type="number"
min="0" min="0"
value={bannerFormData.display_order} value={bannerFormData.display_order}
onChange={(e) => setBannerFormData({ ...bannerFormData, display_order: parseInt(e.target.value) || 0 })} 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>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <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 <input
type="date" type="date"
value={bannerFormData.start_date} value={bannerFormData.start_date}
onChange={(e) => setBannerFormData({ ...bannerFormData, start_date: e.target.value })} 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>
<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 <input
type="date" type="date"
value={bannerFormData.end_date} value={bannerFormData.end_date}
onChange={(e) => setBannerFormData({ ...bannerFormData, end_date: e.target.value })} 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>
<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 <button
type="button" type="button"
onClick={() => setUseFileUpload(true)} 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 useFileUpload
? 'bg-purple-500 text-white' ? 'bg-gradient-to-r from-amber-500 to-amber-600 text-white shadow-lg shadow-amber-500/30'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : 'bg-slate-100 text-slate-700 hover:bg-slate-200 border-2 border-slate-200'
}`} }`}
> >
Upload File Upload File
@@ -2451,10 +2460,10 @@ const PageContentDashboard: React.FC = () => {
<button <button
type="button" type="button"
onClick={() => setUseFileUpload(false)} 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 !useFileUpload
? 'bg-purple-500 text-white' ? 'bg-gradient-to-r from-amber-500 to-amber-600 text-white shadow-lg shadow-amber-500/30'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : 'bg-slate-100 text-slate-700 hover:bg-slate-200 border-2 border-slate-200'
}`} }`}
> >
Image URL Image URL
@@ -2463,8 +2472,8 @@ const PageContentDashboard: React.FC = () => {
{useFileUpload ? ( {useFileUpload ? (
<div> <div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Banner Image *</label> <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-gray-300 rounded-xl p-6 text-center hover:border-purple-400 transition-colors"> <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 <input
type="file" type="file"
accept="image/*" accept="image/*"
@@ -2480,27 +2489,27 @@ const PageContentDashboard: React.FC = () => {
<img <img
src={imagePreview} src={imagePreview}
alt="Preview" 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" /> <Upload className="w-10 h-10 sm:w-12 sm:h-12 text-slate-400 mb-3" />
<span className="text-gray-600 font-medium">Click to upload image</span> <span className="text-slate-600 font-medium text-sm sm:text-base">Click to upload image</span>
<span className="text-gray-500 text-sm mt-1">PNG, JPG up to 5MB</span> <span className="text-slate-500 text-xs sm:text-sm mt-1">PNG, JPG up to 5MB</span>
</> </>
)} )}
</label> </label>
{uploadingImage && ( {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" /> <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>
</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 <input
type="url" type="url"
required required
@@ -2509,14 +2518,14 @@ const PageContentDashboard: React.FC = () => {
setBannerFormData({ ...bannerFormData, image_url: e.target.value }); setBannerFormData({ ...bannerFormData, image_url: e.target.value });
setImagePreview(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" placeholder="https://example.com/image.jpg"
/> />
{imagePreview && ( {imagePreview && (
<img <img
src={imagePreview} src={imagePreview}
alt="Preview" 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)} onError={() => setImagePreview(null)}
/> />
)} )}
@@ -2524,21 +2533,21 @@ const PageContentDashboard: React.FC = () => {
)} )}
</div> </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 <button
type="button" type="button"
onClick={() => { onClick={() => {
setShowBannerModal(false); setShowBannerModal(false);
resetBannerForm(); 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 Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={uploadingImage} 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 ? ( {uploadingImage ? (
<> <>
@@ -2548,12 +2557,13 @@ const PageContentDashboard: React.FC = () => {
) : ( ) : (
<> <>
<Save className="w-5 h-5" /> <Save className="w-5 h-5" />
{editingBanner ? 'Update Banner' : 'Create Banner'} {editingBanner ? 'Update Banner' : 'Add Banner'}
</> </>
)} )}
</button> </button>
</div> </div>
</form> </form>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -474,39 +474,54 @@ const IPWhitelistTab: React.FC = () => {
</div> </div>
{showAddModal && ( {showAddModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-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-xl p-4 sm:p-6 max-w-md w-full max-h-[90vh] overflow-y-auto"> <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">
<h4 className="text-base sm:text-lg font-semibold mb-4">Add IP to Whitelist</h4> <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="space-y-4"> <div className="flex justify-between items-center">
<input <div>
type="text" <h4 className="text-base sm:text-lg md:text-xl font-bold text-amber-100">Add IP to Whitelist</h4>
placeholder="IP Address (e.g., 192.168.1.1 or 192.168.1.0/24)" <p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">Add an IP address to the whitelist</p>
value={newIP.ip_address} </div>
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>
<button <button
onClick={() => setShowAddModal(false)} 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> </button>
</div> </div>
</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>
</div> </div>
)} )}
@@ -633,10 +648,24 @@ const IPBlacklistTab: React.FC = () => {
</div> </div>
{showAddModal && ( {showAddModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-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-xl p-4 sm:p-6 max-w-md w-full max-h-[90vh] overflow-y-auto"> <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">
<h4 className="text-base sm:text-lg font-semibold mb-4">Add IP to Blacklist</h4> <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="space-y-4"> <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 <input
type="text" type="text"
placeholder="IP Address" 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" className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
rows={3} 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 <button
onClick={handleAdd} 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 Add
</button> </button>
<button <button
onClick={() => setShowAddModal(false)} 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 Cancel
</button> </button>
</div> </div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -857,12 +887,28 @@ const OAuthProvidersTab: React.FC = () => {
</div> </div>
{showAddModal && ( {showAddModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-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-xl p-4 sm:p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"> <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">
<h4 className="text-base sm:text-lg font-semibold mb-4"> <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">
{editingProvider ? 'Edit OAuth Provider' : 'Add OAuth Provider'} <div className="flex justify-between items-center">
</h4> <div>
<div className="space-y-3 sm:space-y-4"> <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 <input
type="text" type="text"
placeholder="Name (e.g., google, microsoft)" placeholder="Name (e.g., google, microsoft)"
@@ -940,10 +986,10 @@ const OAuthProvidersTab: React.FC = () => {
<span>SSO Enabled</span> <span>SSO Enabled</span>
</label> </label>
</div> </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 <button
onClick={editingProvider ? handleUpdate : handleAdd} 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'} {editingProvider ? 'Update' : 'Add'}
</button> </button>
@@ -953,11 +999,12 @@ const OAuthProvidersTab: React.FC = () => {
setEditingProvider(null); setEditingProvider(null);
resetForm(); 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 Cancel
</button> </button>
</div> </div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -358,8 +358,8 @@ const UserManagementPage: React.FC = () => {
{} {}
{showModal && ( {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="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-3xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-hidden border border-slate-200 animate-scale-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="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"> <div className="flex justify-between items-center">
@@ -380,9 +380,8 @@ const UserManagementPage: React.FC = () => {
</div> </div>
</div> </div>
{} <div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]"> <form onSubmit={handleSubmit} className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<form onSubmit={handleSubmit} className="space-y-5">
<div> <div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2"> <label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Name Name
@@ -460,12 +459,12 @@ const UserManagementPage: React.FC = () => {
<option value="inactive">Inactive</option> <option value="inactive">Inactive</option>
</select> </select>
</div> </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 <button
type="button" type="button"
onClick={() => setShowModal(false)} onClick={() => setShowModal(false)}
disabled={isSubmitting} 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 Cancel
</button> </button>

View File

@@ -6,37 +6,125 @@ import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading'; import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency'; import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { formatDate } from '../../utils/format'; import { formatDate } from '../../utils/format';
import useAuthStore from '../../store/useAuthStore';
const InvoicePage: React.FC = () => { const InvoicePage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency(); const { formatCurrency } = useFormatCurrency();
const { userInfo } = useAuthStore();
const [invoice, setInvoice] = useState<Invoice | null>(null); const [invoice, setInvoice] = useState<Invoice | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
if (id) { 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 { try {
setLoading(true); setLoading(true);
const response = await invoiceService.getInvoiceById(invoiceId); const response = await invoiceService.getInvoiceById(invoiceId);
if (response.status === 'success' && response.data?.invoice) { if (response.status === 'success' && response.data?.invoice) {
setInvoice(response.data.invoice); setInvoice(response.data.invoice);
setLoading(false);
} else { } 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) { } catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load invoice'); // Check if it's the "Invalid invoice ID" error from validation
navigate('/bookings'); const errorMessage = error.message || error.response?.data?.message || '';
} finally { if (errorMessage.includes('Invalid invoice ID') || errorMessage.includes('Invalid Invoice ID')) {
setLoading(false); // 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) => { const getStatusIcon = (status: string) => {
switch (status) { switch (status) {
case 'paid': case 'paid':
@@ -76,21 +164,35 @@ const InvoicePage: React.FC = () => {
return <Loading fullScreen text="Loading invoice..." />; return <Loading fullScreen text="Loading invoice..." />;
} }
if (!invoice) { if (!invoice && !loading) {
return ( return (
<div className="min-h-screen bg-gray-50 py-8"> <div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4"> <div className="max-w-4xl mx-auto px-4">
<div className="bg-white rounded-lg shadow-md p-8 text-center"> <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" /> <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> <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> <p className="text-gray-600 mb-6">
<Link {userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant'
to="/bookings" ? 'No invoice found for this ID. You can create an invoice from the bookings page.'
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" : 'The invoice you\'re looking for doesn\'t exist.'}
> </p>
<ArrowLeft className="w-5 h-5" /> {userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant' ? (
Back to Bookings <Link
</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> </div>
</div> </div>

View File

@@ -87,20 +87,48 @@ const BookingManagementPage: React.FC = () => {
const handleCreateInvoice = async (bookingId: number) => { const handleCreateInvoice = async (bookingId: number) => {
try { try {
// Validate bookingId before proceeding
if (!bookingId || isNaN(bookingId) || bookingId <= 0) {
toast.error('Invalid booking ID');
return;
}
setCreatingInvoice(true); setCreatingInvoice(true);
const invoiceData = { const invoiceData = {
booking_id: Number(bookingId), booking_id: bookingId,
}; };
const response = await invoiceService.createInvoice(invoiceData); const response = await invoiceService.createInvoice(invoiceData);
if (response.status === 'success' && response.data?.invoice) { if (response.status === 'success' && response.data?.invoice) {
toast.success('Invoice created successfully!'); // Extract and validate invoice ID - handle both number and string types
setShowDetailModal(false); let invoiceId = response.data.invoice.id;
navigate(`/staff/invoices/${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 { } 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) { } catch (error: any) {
const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || 'Unable to create invoice'; 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> => { 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; const data = response.data;
// Handle both 'status: success' and 'success: true' formats // Handle both 'status: success' and 'success: true' formats
return { return {
@@ -124,6 +136,10 @@ export const getInvoiceById = async (id: number): Promise<InvoiceResponse> => {
}; };
export const getInvoicesByBooking = async (bookingId: 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 response = await apiClient.get<any>(`/invoices/booking/${bookingId}`);
const data = response.data; const data = response.data;
// Handle both 'status: success' and 'success: true' formats // 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> => { 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 response = await apiClient.post<any>('/invoices', data);
const responseData = response.data; const responseData = response.data;
// Handle both 'status: success' and 'success: true' formats // 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> => { 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 response = await apiClient.put<any>(`/invoices/${id}`, data);
const responseData = response.data; const responseData = response.data;
// Handle both 'status: success' and 'success: true' formats // 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> => { 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 response = await apiClient.post<any>(`/invoices/${id}/mark-paid`, { amount });
const responseData = response.data; const responseData = response.data;
// Handle both 'status: success' and 'success: true' formats // 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 }> => { 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 response = await apiClient.delete<any>(`/invoices/${id}`);
const data = response.data; const data = response.data;
// Handle both 'status: success' and 'success: true' formats // 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 = { const invoiceService = {
getInvoices, getInvoices,
getInvoiceById, getInvoiceById,
@@ -185,6 +231,7 @@ const invoiceService = {
updateInvoice, updateInvoice,
markInvoiceAsPaid, markInvoiceAsPaid,
deleteInvoice, deleteInvoice,
sendInvoiceEmail,
}; };
export default invoiceService; export default invoiceService;