diff --git a/Backend/src/routes/__pycache__/invoice_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/invoice_routes.cpython-312.pyc index d897a559..d741b9c1 100644 Binary files a/Backend/src/routes/__pycache__/invoice_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/invoice_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/invoice_routes.py b/Backend/src/routes/invoice_routes.py index 2300a5e1..86876e47 100644 --- a/Backend/src/routes/invoice_routes.py +++ b/Backend/src/routes/invoice_routes.py @@ -166,4 +166,38 @@ async def get_invoices_by_booking(booking_id: int, current_user: User=Depends(ge except Exception as e: db.rollback() logger.error(f'Error fetching invoices by booking: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + +@router.post('/{id}/send-email') +async def send_invoice_email(request: Request, id: int, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): + try: + invoice = db.query(Invoice).filter(Invoice.id == id).first() + if not invoice: + raise HTTPException(status_code=404, detail='Invoice not found') + + from ..routes.booking_routes import _generate_invoice_email_html + from ..models.user import User as UserModel + from ..utils.mailer import send_email + + invoice_dict = InvoiceService.invoice_to_dict(invoice) + invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma) + invoice_type = 'Proforma Invoice' if invoice.is_proforma else 'Invoice' + + user = db.query(UserModel).filter(UserModel.id == invoice.user_id).first() + if not user: + raise HTTPException(status_code=404, detail='User not found') + + await send_email( + to=user.email, + subject=f'{invoice_type} {invoice.invoice_number}', + html=invoice_html + ) + + logger.info(f'{invoice_type} {invoice.invoice_number} sent to {user.email}') + return success_response(message=f'{invoice_type} sent successfully to {user.email}') + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f'Error sending invoice email: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc b/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc index 9dc912e0..07e1b940 100644 Binary files a/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc and b/Backend/src/services/__pycache__/invoice_service.cpython-312.pyc differ diff --git a/Backend/src/services/invoice_service.py b/Backend/src/services/invoice_service.py index 5119e961..db5b2bc2 100644 --- a/Backend/src/services/invoice_service.py +++ b/Backend/src/services/invoice_service.py @@ -6,6 +6,7 @@ from ..models.invoice import Invoice, InvoiceItem, InvoiceStatus from ..models.booking import Booking from ..models.payment import Payment, PaymentStatus from ..models.user import User +from ..models.system_settings import SystemSettings from ..config.logging_config import get_logger logger = get_logger(__name__) @@ -29,14 +30,33 @@ class InvoiceService: @staticmethod def create_invoice_from_booking(booking_id: int, db: Session, created_by_id: Optional[int]=None, tax_rate: float=0.0, discount_amount: float=0.0, due_days: int=30, is_proforma: bool=False, invoice_amount: Optional[float]=None, request_id: Optional[str]=None, **kwargs) -> Dict[str, Any]: from sqlalchemy.orm import selectinload + from ..models.service_usage import ServiceUsage + from ..models.room import Room + from ..models.room_type import RoomType + from ..models.service import Service + logger.info(f'Creating invoice from booking {booking_id}', extra={'booking_id': booking_id, 'request_id': request_id}) - booking = db.query(Booking).options(selectinload(Booking.service_usages).selectinload('service'), selectinload(Booking.room).selectinload('room_type'), selectinload(Booking.payments)).filter(Booking.id == booking_id).first() + booking = db.query(Booking).options( + selectinload(Booking.service_usages).selectinload(ServiceUsage.service), + selectinload(Booking.room).selectinload(Room.room_type), + selectinload(Booking.payments) + ).filter(Booking.id == booking_id).first() if not booking: logger.error(f'Booking {booking_id} not found', extra={'booking_id': booking_id, 'request_id': request_id}) raise ValueError('Booking not found') user = db.query(User).filter(User.id == booking.user_id).first() if not user: raise ValueError('User not found') + + # Get tax_rate from system settings if not provided or is 0 + if tax_rate == 0.0: + tax_rate_setting = db.query(SystemSettings).filter(SystemSettings.key == 'tax_rate').first() + if tax_rate_setting and tax_rate_setting.value: + try: + tax_rate = float(tax_rate_setting.value) + except (ValueError, TypeError): + tax_rate = 0.0 + invoice_number = generate_invoice_number(db, is_proforma=is_proforma) booking_total = float(booking.total_price) if invoice_amount is not None: @@ -61,7 +81,34 @@ class InvoiceService: else: status = InvoiceStatus.draft paid_date = None - invoice = Invoice(invoice_number=invoice_number, booking_id=booking_id, user_id=booking.user_id, issue_date=datetime.utcnow(), due_date=datetime.utcnow() + timedelta(days=due_days), paid_date=paid_date, subtotal=subtotal, tax_rate=tax_rate, tax_amount=tax_amount, discount_amount=discount_amount, total_amount=total_amount, amount_paid=amount_paid, balance_due=balance_due, status=status, is_proforma=is_proforma, company_name=kwargs.get('company_name'), company_address=kwargs.get('company_address'), company_phone=kwargs.get('company_phone'), company_email=kwargs.get('company_email'), company_tax_id=kwargs.get('company_tax_id'), company_logo_url=kwargs.get('company_logo_url'), customer_name=user.full_name or f'{user.email}', customer_email=user.email, customer_address=user.address, customer_phone=user.phone, customer_tax_id=kwargs.get('customer_tax_id'), notes=kwargs.get('notes'), terms_and_conditions=kwargs.get('terms_and_conditions'), payment_instructions=kwargs.get('payment_instructions'), created_by_id=created_by_id) + # Get company information from system settings if not provided + company_name = kwargs.get('company_name') + company_address = kwargs.get('company_address') + company_phone = kwargs.get('company_phone') + company_email = kwargs.get('company_email') + company_tax_id = kwargs.get('company_tax_id') + company_logo_url = kwargs.get('company_logo_url') + + # If company info not provided, fetch from system settings + if not company_name or not company_address or not company_phone or not company_email: + company_settings = db.query(SystemSettings).filter( + SystemSettings.key.in_(['company_name', 'company_address', 'company_phone', 'company_email', 'company_logo_url']) + ).all() + + settings_dict = {setting.key: setting.value for setting in company_settings if setting.value} + + if not company_name and settings_dict.get('company_name'): + company_name = settings_dict['company_name'] + if not company_address and settings_dict.get('company_address'): + company_address = settings_dict['company_address'] + if not company_phone and settings_dict.get('company_phone'): + company_phone = settings_dict['company_phone'] + if not company_email and settings_dict.get('company_email'): + company_email = settings_dict['company_email'] + if not company_logo_url and settings_dict.get('company_logo_url'): + company_logo_url = settings_dict['company_logo_url'] + + invoice = Invoice(invoice_number=invoice_number, booking_id=booking_id, user_id=booking.user_id, issue_date=datetime.utcnow(), due_date=datetime.utcnow() + timedelta(days=due_days), paid_date=paid_date, subtotal=subtotal, tax_rate=tax_rate, tax_amount=tax_amount, discount_amount=discount_amount, total_amount=total_amount, amount_paid=amount_paid, balance_due=balance_due, status=status, is_proforma=is_proforma, company_name=company_name, company_address=company_address, company_phone=company_phone, company_email=company_email, company_tax_id=company_tax_id, company_logo_url=company_logo_url, customer_name=user.full_name or f'{user.email}', customer_email=user.email, customer_address=user.address, customer_phone=user.phone, customer_tax_id=kwargs.get('customer_tax_id'), notes=kwargs.get('notes'), terms_and_conditions=kwargs.get('terms_and_conditions'), payment_instructions=kwargs.get('payment_instructions'), created_by_id=created_by_id) db.add(invoice) db.flush() services_total = sum((float(su.total_price) for su in booking.service_usages)) @@ -110,9 +157,15 @@ class InvoiceService: if 'tax_rate' in kwargs or 'discount_amount' in kwargs: tax_rate = kwargs.get('tax_rate', invoice.tax_rate) discount_amount = kwargs.get('discount_amount', invoice.discount_amount) - invoice.tax_amount = (invoice.subtotal - discount_amount) * (float(tax_rate) / 100) - invoice.total_amount = invoice.subtotal + invoice.tax_amount - discount_amount - invoice.balance_due = invoice.total_amount - invoice.amount_paid + # Convert decimal types to float for arithmetic operations + subtotal = float(invoice.subtotal) if invoice.subtotal else 0.0 + discount_amount = float(discount_amount) if discount_amount else 0.0 + tax_rate = float(tax_rate) if tax_rate else 0.0 + amount_paid = float(invoice.amount_paid) if invoice.amount_paid else 0.0 + + invoice.tax_amount = (subtotal - discount_amount) * (tax_rate / 100) + invoice.total_amount = subtotal + invoice.tax_amount - discount_amount + invoice.balance_due = invoice.total_amount - amount_paid if invoice.balance_due <= 0 and invoice.status != InvoiceStatus.paid: invoice.status = InvoiceStatus.paid invoice.paid_date = datetime.utcnow() diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index bb43b132..9575c996 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -55,6 +55,7 @@ const PayPalReturnPage = lazy(() => import('./pages/customer/PayPalReturnPage')) const PayPalCancelPage = lazy(() => import('./pages/customer/PayPalCancelPage')); const BoricaReturnPage = lazy(() => import('./pages/customer/BoricaReturnPage')); const InvoicePage = lazy(() => import('./pages/customer/InvoicePage')); +const InvoiceEditPage = lazy(() => import('./pages/admin/InvoiceEditPage')); const ProfilePage = lazy(() => import('./pages/customer/ProfilePage')); const LoyaltyPage = lazy(() => import('./pages/customer/LoyaltyPage')); const GroupBookingPage = lazy(() => import('./pages/customer/GroupBookingPage')); @@ -75,6 +76,7 @@ const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagement const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage')); const GuestProfilePage = lazy(() => import('./pages/admin/GuestProfilePage')); const GroupBookingManagementPage = lazy(() => import('./pages/admin/GroupBookingManagementPage')); +const AdminBookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage')); const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard')); const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage')); const BusinessDashboardPage = lazy(() => import('./pages/admin/BusinessDashboardPage')); @@ -467,6 +469,10 @@ function App() { path="invoices" element={} /> + } + /> } @@ -487,6 +493,10 @@ function App() { path="group-bookings" element={} /> + } + /> } @@ -587,6 +597,14 @@ function App() { path="invoices" element={} /> + } + /> + } + /> } diff --git a/Frontend/src/components/layout/Header.tsx b/Frontend/src/components/layout/Header.tsx index 48f964d6..93f90c9c 100644 --- a/Frontend/src/components/layout/Header.tsx +++ b/Frontend/src/components/layout/Header.tsx @@ -4,8 +4,6 @@ import { Hotel, User, LogOut, - Menu, - X, LogIn, UserPlus, Heart, @@ -20,6 +18,7 @@ import { useCompanySettings } from '../../contexts/CompanySettingsContext'; import { useAuthModal } from '../../contexts/AuthModalContext'; import { normalizeImageUrl } from '../../utils/imageUtils'; import InAppNotificationBell from '../notifications/InAppNotificationBell'; +import Navbar from './Navbar'; interface HeaderProps { isAuthenticated?: boolean; @@ -76,6 +75,199 @@ const Header: React.FC = ({ setIsMobileMenuOpen(false); }; + // Mobile menu content with user authentication + const mobileMenuContent = ( + <> + {!isAuthenticated ? ( + <> + + + + ) : ( + <> +
+ Hello, {userInfo?.name} +
+ + 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" + > + + Profile + + {userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant' && ( + <> + + 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" + > + + Favorites + + + 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" + > + + My Bookings + + + 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" + > + + Loyalty Program + + + 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" + > + + Group Bookings + + + )} + {userInfo?.role === 'admin' && ( + + 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" + > + + Admin + + )} + {userInfo?.role === 'staff' && ( + + 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" + > + + Staff Dashboard + + )} + {userInfo?.role === 'accountant' && ( + + 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" + > + + Accountant Dashboard + + )} +
+ + + )} + + ); + return (
@@ -128,58 +320,13 @@ const Header: React.FC = ({
- {} - + setIsMobileMenuOpen(false)} + mobileMenuContent={mobileMenuContent} + /> - {}
@@ -381,226 +528,7 @@ const Header: React.FC = ({
)} - - - - {isMobileMenuOpen && ( -
-
- 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 - - 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 - - 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 - - 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 - - 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 - - -
- {!isAuthenticated ? ( - <> - - - - ) : ( - <> -
- Hello, {userInfo?.name} -
- - 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" - > - - Profile - - {userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && ( - <> - - 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" - > - - Favorites - - - 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" - > - - My Bookings - - - )} - {userInfo?.role === 'admin' && ( - - 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" - > - - Admin - - )} - {userInfo?.role === 'staff' && ( - - 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" - > - - Staff Dashboard - - )} - - - )} -
-
-
- )}
diff --git a/Frontend/src/components/layout/Navbar.tsx b/Frontend/src/components/layout/Navbar.tsx new file mode 100644 index 00000000..c5626472 --- /dev/null +++ b/Frontend/src/components/layout/Navbar.tsx @@ -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 = ({ + isMobileMenuOpen, + onMobileMenuToggle, + onLinkClick, + renderMobileLinksOnly = false, + mobileMenuContent +}) => { + const mobileMenuContainerRef = useRef(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.label} + + ))} + + ); + } + + return ( +
+ {/* Desktop Navigation */} + + + {/* Mobile Menu Button */} + + + {/* Mobile Menu Dropdown - Absolute positioned */} + {isMobileMenuOpen && ( +
+
+ {navLinks.map((link) => ( + + {link.label} + + ))} + {mobileMenuContent && ( + <> +
+ {mobileMenuContent} + + )} +
+
+ )} +
+ ); +}; + +export default Navbar; + diff --git a/Frontend/src/components/layout/index.ts b/Frontend/src/components/layout/index.ts index 8af7503a..7af0b450 100644 --- a/Frontend/src/components/layout/index.ts +++ b/Frontend/src/components/layout/index.ts @@ -1,5 +1,6 @@ export { default as Header } from './Header'; export { default as Footer } from './Footer'; +export { default as Navbar } from './Navbar'; export { default as SidebarAdmin } from './SidebarAdmin'; export { default as SidebarStaff } from './SidebarStaff'; export { default as SidebarAccountant } from './SidebarAccountant'; diff --git a/Frontend/src/components/rooms/BannerCarousel.tsx b/Frontend/src/components/rooms/BannerCarousel.tsx index ec30ff48..caa3c278 100644 --- a/Frontend/src/components/rooms/BannerCarousel.tsx +++ b/Frontend/src/components/rooms/BannerCarousel.tsx @@ -261,9 +261,9 @@ const BannerCarousel: React.FC = ({ {} {children && ( -
+
-
+
{children}
@@ -277,7 +277,7 @@ const BannerCarousel: React.FC = ({ +
+
+
+
+
+

+ {editingBanner ? 'Edit Banner' : 'Create Banner'} +

+

+ {editingBanner ? 'Modify banner information' : 'Create a new promotional banner'} +

+
+ +
- +
+
-
{}
-