472 lines
20 KiB
TypeScript
472 lines
20 KiB
TypeScript
/**
|
||
* Bookings List Page
|
||
*
|
||
* Display all bookings (standard + CSV) with filters and search
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { listBookings, listCsvBookings } from '@/lib/api';
|
||
import Link from 'next/link';
|
||
|
||
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
||
|
||
export default function BookingsListPage() {
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [searchType, setSearchType] = useState<SearchType>('route');
|
||
const [statusFilter, setStatusFilter] = useState('');
|
||
const [page, setPage] = useState(1);
|
||
const ITEMS_PER_PAGE = 20;
|
||
|
||
// Fetch CSV bookings (fetch all for client-side filtering and pagination)
|
||
const { data: csvData, isLoading, error: csvError } = useQuery({
|
||
queryKey: ['csv-bookings'],
|
||
queryFn: () =>
|
||
listCsvBookings({
|
||
page: 1,
|
||
limit: 1000, // Fetch all bookings for client-side filtering
|
||
}),
|
||
});
|
||
|
||
// Log errors for debugging
|
||
if (csvError) console.error('CSV bookings error:', csvError);
|
||
|
||
// Filter bookings based on search term, search type, and status
|
||
const filterBookings = (bookings: any[]) => {
|
||
let filtered = bookings;
|
||
|
||
// Filter by status first (if status filter is active)
|
||
if (statusFilter) {
|
||
filtered = filtered.filter((booking: any) => booking.status === statusFilter);
|
||
}
|
||
|
||
// Then filter by search term if provided
|
||
if (searchTerm.trim()) {
|
||
const term = searchTerm.toLowerCase();
|
||
|
||
filtered = filtered.filter((booking: any) => {
|
||
switch (searchType) {
|
||
case 'pallets':
|
||
return booking.palletCount?.toString().includes(term);
|
||
case 'weight':
|
||
return booking.weightKG?.toString().includes(term);
|
||
case 'route':
|
||
const origin = booking.originCity?.toLowerCase() || '';
|
||
const destination = booking.destinationCity?.toLowerCase() || '';
|
||
return origin.includes(term) || destination.includes(term);
|
||
case 'status':
|
||
return booking.status?.toLowerCase().includes(term);
|
||
case 'date':
|
||
const date = new Date(booking.requestedPickupDate || booking.requestedAt).toLocaleDateString('fr-FR');
|
||
return date.includes(term);
|
||
case 'quote':
|
||
return booking.id?.toLowerCase().includes(term) || booking.quoteNumber?.toLowerCase().includes(term);
|
||
default:
|
||
return true;
|
||
}
|
||
});
|
||
}
|
||
|
||
return filtered;
|
||
};
|
||
|
||
// Get all filtered bookings
|
||
const filteredBookings = filterBookings((csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const })));
|
||
|
||
// Calculate pagination
|
||
const totalBookings = filteredBookings.length;
|
||
const totalPages = Math.ceil(totalBookings / ITEMS_PER_PAGE);
|
||
const startIndex = (page - 1) * ITEMS_PER_PAGE;
|
||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||
const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
|
||
|
||
// Reset page to 1 when filters change
|
||
const resetPage = () => setPage(1);
|
||
|
||
const statusOptions = [
|
||
{ value: '', label: 'Tous les statuts' },
|
||
{ value: 'PENDING', label: 'En attente' },
|
||
{ value: 'ACCEPTED', label: 'Accepté' },
|
||
{ value: 'REJECTED', label: 'Refusé' },
|
||
];
|
||
|
||
const searchTypeOptions = [
|
||
{ value: 'route', label: 'Route (Origine/Destination)' },
|
||
{ value: 'pallets', label: 'Palettes/Colis' },
|
||
{ value: 'weight', label: 'Poids (kg)' },
|
||
{ value: 'status', label: 'Statut' },
|
||
{ value: 'date', label: 'Date' },
|
||
{ value: 'quote', label: 'N° Devis' },
|
||
];
|
||
|
||
const getPlaceholder = () => {
|
||
switch (searchType) {
|
||
case 'pallets':
|
||
return 'Rechercher par nombre de palettes...';
|
||
case 'weight':
|
||
return 'Rechercher par poids en kg...';
|
||
case 'route':
|
||
return 'Rechercher par ville (origine ou destination)...';
|
||
case 'status':
|
||
return 'Rechercher par statut...';
|
||
case 'date':
|
||
return 'Rechercher par date (JJ/MM/AAAA)...';
|
||
case 'quote':
|
||
return 'Rechercher par numéro de devis...';
|
||
default:
|
||
return 'Rechercher...';
|
||
}
|
||
};
|
||
|
||
const getStatusColor = (status: string) => {
|
||
const colors: Record<string, string> = {
|
||
PENDING: 'bg-yellow-100 text-yellow-800',
|
||
ACCEPTED: 'bg-green-100 text-green-800',
|
||
REJECTED: 'bg-red-100 text-red-800',
|
||
};
|
||
return colors[status] || 'bg-gray-100 text-gray-800';
|
||
};
|
||
|
||
const getStatusLabel = (status: string) => {
|
||
const labels: Record<string, string> = {
|
||
PENDING: 'En attente',
|
||
ACCEPTED: 'Accepté',
|
||
REJECTED: 'Refusé',
|
||
};
|
||
return labels[status] || status;
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900">Réservations</h1>
|
||
<p className="text-sm text-gray-500 mt-1">Gérez et suivez vos envois</p>
|
||
</div>
|
||
<Link
|
||
href="/dashboard/search-advanced"
|
||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||
>
|
||
<span className="mr-2">➕</span>
|
||
Nouvelle Réservation
|
||
</Link>
|
||
</div>
|
||
|
||
{/* Filters */}
|
||
<div className="bg-white rounded-lg shadow p-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
<div>
|
||
<label htmlFor="searchType" className="sr-only">
|
||
Type de recherche
|
||
</label>
|
||
<select
|
||
id="searchType"
|
||
value={searchType}
|
||
onChange={e => {
|
||
setSearchType(e.target.value as SearchType);
|
||
resetPage();
|
||
}}
|
||
className="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"
|
||
>
|
||
{searchTypeOptions.map(option => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="md:col-span-2">
|
||
<label htmlFor="search" className="sr-only">
|
||
Rechercher
|
||
</label>
|
||
<div className="relative">
|
||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||
<svg
|
||
className="h-5 w-5 text-gray-400"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
id="search"
|
||
value={searchTerm}
|
||
onChange={e => {
|
||
setSearchTerm(e.target.value);
|
||
resetPage();
|
||
}}
|
||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||
placeholder={getPlaceholder()}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="status" className="sr-only">
|
||
Statut
|
||
</label>
|
||
<select
|
||
id="status"
|
||
value={statusFilter}
|
||
onChange={e => {
|
||
setStatusFilter(e.target.value);
|
||
resetPage();
|
||
}}
|
||
className="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"
|
||
>
|
||
{statusOptions.map(option => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bookings Table */}
|
||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||
{isLoading ? (
|
||
<div className="px-6 py-12 text-center text-gray-500">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||
Chargement des réservations...
|
||
</div>
|
||
) : paginatedBookings && paginatedBookings.length > 0 ? (
|
||
<>
|
||
<div className="overflow-x-auto">
|
||
<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">
|
||
Palettes/Colis
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
Poids
|
||
</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">
|
||
Statut
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
Date
|
||
</th>
|
||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
N° Devis
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{paginatedBookings.map((booking: any) => (
|
||
<tr key={`${booking.type}-${booking.id}`} className="hover:bg-gray-50">
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="text-sm font-medium text-gray-900">
|
||
{booking.type === 'csv'
|
||
? `${booking.palletCount} palette${booking.palletCount > 1 ? 's' : ''}`
|
||
: `${booking.containers?.length || 0} conteneur${booking.containers?.length > 1 ? 's' : ''}`}
|
||
</div>
|
||
<div className="text-xs text-gray-500">
|
||
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="text-sm font-medium text-gray-900">
|
||
{booking.type === 'csv'
|
||
? `${booking.weightKG} kg`
|
||
: booking.totalWeight
|
||
? `${booking.totalWeight} kg`
|
||
: 'N/A'}
|
||
</div>
|
||
<div className="text-xs text-gray-500">
|
||
{booking.type === 'csv'
|
||
? `${booking.volumeCBM} CBM`
|
||
: booking.totalVolume
|
||
? `${booking.totalVolume} CBM`
|
||
: ''}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<div className="text-sm text-gray-900">
|
||
{booking.type === 'csv'
|
||
? `${booking.origin} → ${booking.destination}`
|
||
: booking.route || 'N/A'}
|
||
</div>
|
||
<div className="text-sm text-gray-500">
|
||
{booking.type === 'csv'
|
||
? `${booking.carrierName}`
|
||
: booking.carrier || ''}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<span
|
||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
|
||
booking.status
|
||
)}`}
|
||
>
|
||
{getStatusLabel(booking.status)}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||
{(booking.createdAt || booking.requestedAt)
|
||
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString('fr-FR', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
})
|
||
: 'N/A'}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||
<Link
|
||
href={
|
||
booking.type === 'csv'
|
||
? `/dashboard/csv-bookings/${booking.id}`
|
||
: `/dashboard/bookings/${booking.id}`
|
||
}
|
||
className="text-blue-600 hover:text-blue-900"
|
||
>
|
||
{booking.type === 'csv'
|
||
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
|
||
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
|
||
</Link>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||
<div className="flex-1 flex justify-between sm:hidden">
|
||
<button
|
||
onClick={() => setPage(page - 1)}
|
||
disabled={page === 1}
|
||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
|
||
>
|
||
Précédent
|
||
</button>
|
||
<button
|
||
onClick={() => setPage(page + 1)}
|
||
disabled={page >= totalPages}
|
||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
|
||
>
|
||
Suivant
|
||
</button>
|
||
</div>
|
||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||
<div>
|
||
<p className="text-sm text-gray-700">
|
||
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
|
||
<span className="font-medium">{Math.min(endIndex, totalBookings)}</span> sur{' '}
|
||
<span className="font-medium">{totalBookings}</span> résultat{totalBookings > 1 ? 's' : ''}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||
<button
|
||
onClick={() => setPage(page - 1)}
|
||
disabled={page === 1}
|
||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
|
||
>
|
||
<span className="sr-only">Précédent</span>
|
||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Page numbers */}
|
||
{[...Array(totalPages)].map((_, idx) => {
|
||
const pageNum = idx + 1;
|
||
// Show first page, last page, current page, and pages around current
|
||
const showPage = pageNum === 1 ||
|
||
pageNum === totalPages ||
|
||
(pageNum >= page - 1 && pageNum <= page + 1);
|
||
|
||
if (!showPage && pageNum === 2) {
|
||
return <span key={pageNum} className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">...</span>;
|
||
}
|
||
if (!showPage && pageNum === totalPages - 1) {
|
||
return <span key={pageNum} className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">...</span>;
|
||
}
|
||
if (!showPage) return null;
|
||
|
||
return (
|
||
<button
|
||
key={pageNum}
|
||
onClick={() => setPage(pageNum)}
|
||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||
page === pageNum
|
||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
{pageNum}
|
||
</button>
|
||
);
|
||
})}
|
||
|
||
<button
|
||
onClick={() => setPage(page + 1)}
|
||
disabled={page >= totalPages}
|
||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
|
||
>
|
||
<span className="sr-only">Suivant</span>
|
||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||
</svg>
|
||
</button>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="px-6 py-12 text-center">
|
||
<svg
|
||
className="mx-auto h-12 w-12 text-gray-400"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||
/>
|
||
</svg>
|
||
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucune réservation trouvée</h3>
|
||
<p className="mt-1 text-sm text-gray-500">
|
||
{searchTerm || statusFilter
|
||
? 'Essayez d\'ajuster vos filtres'
|
||
: 'Commencez par créer votre première réservation'}
|
||
</p>
|
||
<div className="mt-6">
|
||
<Link
|
||
href="/dashboard/search-advanced"
|
||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||
>
|
||
<span className="mr-2">➕</span>
|
||
Nouvelle Réservation
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|