updates
This commit is contained in:
Binary file not shown.
@@ -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))
|
||||||
Binary file not shown.
@@ -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()
|
||||||
|
|||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
135
Frontend/src/components/layout/Navbar.tsx
Normal file
135
Frontend/src/components/layout/Navbar.tsx
Normal 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;
|
||||||
|
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
341
Frontend/src/pages/admin/InvoiceEditPage.tsx
Normal file
341
Frontend/src/pages/admin/InvoiceEditPage.tsx
Normal 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;
|
||||||
|
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user