xpeditis2.0/apps/frontend/app/dashboard/bookings/page.tsx
2026-02-10 17:16:35 +01:00

495 lines
22 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';
import { Plus } from 'lucide-react';
import ExportButton from '@/components/ExportButton';
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>
<div className="flex items-center space-x-3">
<ExportButton
data={filteredBookings}
filename="reservations"
columns={[
{ key: 'id', label: 'ID' },
{ key: 'palletCount', label: 'Palettes', format: (v) => `${v || 0}` },
{ key: 'weightKG', label: 'Poids (kg)', format: (v) => `${v || 0}` },
{ key: 'volumeCBM', label: 'Volume (CBM)', format: (v) => `${v || 0}` },
{ key: 'origin', label: 'Origine' },
{ key: 'destination', label: 'Destination' },
{ key: 'carrierName', label: 'Transporteur' },
{ key: 'status', label: 'Statut', format: (v) => {
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
};
return labels[v] || v;
}},
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
]}
/>
<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"
>
<Plus className="mr-2 h-4 w-4" />
Nouvelle Réservation
</Link>
</div>
</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-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Devis
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Booking
</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-left text-sm font-medium">
{booking.type === 'csv'
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy">
{booking.bookingNumber || '-'}
</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"
>
<Plus className="mr-2 h-4 w-4" />
Nouvelle Réservation
</Link>
</div>
</div>
)}
</div>
</div>
);
}