Some checks failed
CI/CD Pipeline / Discord Notification (Failure) (push) Blocked by required conditions
CI/CD Pipeline / Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Deployment Summary (push) Blocked by required conditions
CI/CD Pipeline / Deploy to Portainer (push) Blocked by required conditions
CI/CD Pipeline / Discord Notification (Success) (push) Blocked by required conditions
CI/CD Pipeline / Backend - Build, Test & Push (push) Failing after 1m20s
CI/CD Pipeline / Frontend - Build, Test & Push (push) Has been cancelled
414 lines
17 KiB
TypeScript
414 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { getAllBookings } from '@/lib/api/admin';
|
|
|
|
interface Booking {
|
|
id: string;
|
|
bookingNumber?: string;
|
|
bookingId?: string;
|
|
type?: string;
|
|
status: string;
|
|
// CSV bookings use these fields
|
|
origin?: string;
|
|
destination?: string;
|
|
carrierName?: string;
|
|
// Regular bookings use these fields
|
|
originPort?: {
|
|
code: string;
|
|
name: string;
|
|
};
|
|
destinationPort?: {
|
|
code: string;
|
|
name: string;
|
|
};
|
|
carrier?: string;
|
|
containerType: string;
|
|
quantity?: number;
|
|
price?: number;
|
|
primaryCurrency?: string;
|
|
totalPrice?: {
|
|
amount: number;
|
|
currency: string;
|
|
};
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
requestedAt?: string;
|
|
organizationId?: string;
|
|
userId?: string;
|
|
}
|
|
|
|
export default function AdminBookingsPage() {
|
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
|
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
|
const [filterStatus, setFilterStatus] = useState('all');
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
|
|
// Helper function to get formatted quote number
|
|
const getQuoteNumber = (booking: Booking): string => {
|
|
if (booking.type === 'csv') {
|
|
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
|
|
}
|
|
return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`;
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchBookings();
|
|
}, []);
|
|
|
|
const fetchBookings = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await getAllBookings();
|
|
setBookings(response.bookings || []);
|
|
setError(null);
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to load bookings');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
const colors: Record<string, string> = {
|
|
draft: 'bg-gray-100 text-gray-800',
|
|
pending: 'bg-yellow-100 text-yellow-800',
|
|
confirmed: 'bg-blue-100 text-blue-800',
|
|
in_transit: 'bg-purple-100 text-purple-800',
|
|
delivered: 'bg-green-100 text-green-800',
|
|
cancelled: 'bg-red-100 text-red-800',
|
|
};
|
|
return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
|
|
};
|
|
|
|
const filteredBookings = bookings
|
|
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
|
|
.filter(booking => {
|
|
if (searchTerm === '') return true;
|
|
const searchLower = searchTerm.toLowerCase();
|
|
const quoteNumber = getQuoteNumber(booking).toLowerCase();
|
|
return (
|
|
quoteNumber.includes(searchLower) ||
|
|
booking.bookingNumber?.toLowerCase().includes(searchLower) ||
|
|
booking.carrier?.toLowerCase().includes(searchLower) ||
|
|
booking.carrierName?.toLowerCase().includes(searchLower) ||
|
|
booking.origin?.toLowerCase().includes(searchLower) ||
|
|
booking.destination?.toLowerCase().includes(searchLower)
|
|
);
|
|
});
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-96">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
|
<p className="mt-4 text-gray-600">Loading bookings...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Booking Management</h1>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
View and manage all bookings across the platform
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Search</label>
|
|
<input
|
|
type="text"
|
|
placeholder="Search by booking number or carrier..."
|
|
value={searchTerm}
|
|
onChange={e => setSearchTerm(e.target.value)}
|
|
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Status Filter</label>
|
|
<select
|
|
value={filterStatus}
|
|
onChange={e => setFilterStatus(e.target.value)}
|
|
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
>
|
|
<option value="all">All Statuses</option>
|
|
<option value="draft">Draft</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="confirmed">Confirmed</option>
|
|
<option value="in_transit">In Transit</option>
|
|
<option value="delivered">Delivered</option>
|
|
<option value="cancelled">Cancelled</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
<div className="text-sm text-gray-500">Total Réservations</div>
|
|
<div className="text-2xl font-bold text-gray-900">{bookings.length}</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
<div className="text-sm text-gray-500">En Attente</div>
|
|
<div className="text-2xl font-bold text-yellow-600">
|
|
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
<div className="text-sm text-gray-500">Acceptées</div>
|
|
<div className="text-2xl font-bold text-green-600">
|
|
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
<div className="text-sm text-gray-500">Rejetées</div>
|
|
<div className="text-2xl font-bold text-red-600">
|
|
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Message */}
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Bookings Table */}
|
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Numéro de devis
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Route
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Transporteur
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Conteneur
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Statut
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Prix
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{filteredBookings.map(booking => (
|
|
<tr key={booking.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{getQuoteNumber(booking)}
|
|
</div>
|
|
<div className="text-xs text-gray-500">
|
|
{new Date(booking.createdAt || booking.requestedAt || '').toLocaleDateString()}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-900">
|
|
{booking.originPort ? `${booking.originPort.code} → ${booking.destinationPort?.code}` : `${booking.origin} → ${booking.destination}`}
|
|
</div>
|
|
<div className="text-xs text-gray-500">
|
|
{booking.originPort ? `${booking.originPort.name} → ${booking.destinationPort?.name}` : ''}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{booking.carrier || booking.carrierName || 'N/A'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-900">{booking.containerType}</div>
|
|
<div className="text-xs text-gray-500">
|
|
{booking.quantity ? `Qty: ${booking.quantity}` : ''}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}>
|
|
{booking.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{booking.totalPrice
|
|
? `${booking.totalPrice.amount.toLocaleString()} ${booking.totalPrice.currency}`
|
|
: booking.price
|
|
? `${booking.price.toLocaleString()} ${booking.primaryCurrency || 'USD'}`
|
|
: 'N/A'
|
|
}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
<button
|
|
onClick={() => {
|
|
setSelectedBooking(booking);
|
|
setShowDetailsModal(true);
|
|
}}
|
|
className="text-blue-600 hover:text-blue-900"
|
|
>
|
|
View Details
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Details Modal */}
|
|
{showDetailsModal && selectedBooking && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
|
|
<div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h2 className="text-xl font-bold">Booking Details</h2>
|
|
<button
|
|
onClick={() => {
|
|
setShowDetailsModal(false);
|
|
setSelectedBooking(null);
|
|
}}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
>
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-500">Numéro de devis</label>
|
|
<div className="mt-1 text-lg font-semibold">
|
|
{getQuoteNumber(selectedBooking)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-500">Statut</label>
|
|
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
|
|
{selectedBooking.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<h3 className="text-sm font-medium text-gray-900 mb-3">Route Information</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-500">Origin</label>
|
|
<div className="mt-1">
|
|
{selectedBooking.originPort ? (
|
|
<>
|
|
<div className="font-semibold">{selectedBooking.originPort.code}</div>
|
|
<div className="text-sm text-gray-600">{selectedBooking.originPort.name}</div>
|
|
</>
|
|
) : (
|
|
<div className="font-semibold">{selectedBooking.origin}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-500">Destination</label>
|
|
<div className="mt-1">
|
|
{selectedBooking.destinationPort ? (
|
|
<>
|
|
<div className="font-semibold">{selectedBooking.destinationPort.code}</div>
|
|
<div className="text-sm text-gray-600">{selectedBooking.destinationPort.name}</div>
|
|
</>
|
|
) : (
|
|
<div className="font-semibold">{selectedBooking.destination}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<h3 className="text-sm font-medium text-gray-900 mb-3">Shipping Details</h3>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-500">Carrier</label>
|
|
<div className="mt-1 font-semibold">
|
|
{selectedBooking.carrier || selectedBooking.carrierName || 'N/A'}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-500">Container Type</label>
|
|
<div className="mt-1 font-semibold">{selectedBooking.containerType}</div>
|
|
</div>
|
|
{selectedBooking.quantity && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-500">Quantity</label>
|
|
<div className="mt-1 font-semibold">{selectedBooking.quantity}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<h3 className="text-sm font-medium text-gray-900 mb-3">Pricing</h3>
|
|
<div className="text-2xl font-bold text-blue-600">
|
|
{selectedBooking.totalPrice
|
|
? `${selectedBooking.totalPrice.amount.toLocaleString()} ${selectedBooking.totalPrice.currency}`
|
|
: selectedBooking.price
|
|
? `${selectedBooking.price.toLocaleString()} ${selectedBooking.primaryCurrency || 'USD'}`
|
|
: 'N/A'
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<h3 className="text-sm font-medium text-gray-900 mb-3">Timeline</h3>
|
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<label className="block text-gray-500">Created</label>
|
|
<div className="mt-1">
|
|
{new Date(selectedBooking.createdAt || selectedBooking.requestedAt || '').toLocaleString()}
|
|
</div>
|
|
</div>
|
|
{selectedBooking.updatedAt && (
|
|
<div>
|
|
<label className="block text-gray-500">Last Updated</label>
|
|
<div className="mt-1">{new Date(selectedBooking.updatedAt).toLocaleString()}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end space-x-2 mt-6 pt-4 border-t">
|
|
<button
|
|
onClick={() => {
|
|
setShowDetailsModal(false);
|
|
setSelectedBooking(null);
|
|
}}
|
|
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|