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

View File

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