xpeditis2.0/apps/frontend/app/[locale]/dashboard/bookings/page.tsx
2026-05-12 21:01:52 +02:00

607 lines
26 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listCsvBookings } from '@/lib/api';
import { Link } from '@/i18n/navigation';
import { Plus, Clock } from 'lucide-react';
import ExportButton from '@/components/ExportButton';
import { useSearchParams } from 'next/navigation';
import { PageHeader } from '@/components/ui/PageHeader';
import { useTranslations, useLocale } from 'next-intl';
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
export default function BookingsListPage() {
const t = useTranslations('dashboard.bookingsList');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState('');
const [searchType, setSearchType] = useState<SearchType>('route');
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(1);
const [showTransferBanner, setShowTransferBanner] = useState(false);
const ITEMS_PER_PAGE = 20;
useEffect(() => {
if (searchParams.get('transfer') === 'declared') {
setShowTransferBanner(true);
}
}, [searchParams]);
const {
data: csvData,
isLoading,
error: csvError,
} = useQuery({
queryKey: ['csv-bookings'],
queryFn: () =>
listCsvBookings({
page: 1,
limit: 1000,
}),
});
if (csvError) console.error('CSV bookings error:', csvError);
const filterBookings = (bookings: any[]) => {
let filtered = bookings;
if (statusFilter) {
filtered = filtered.filter((booking: any) => booking.status === statusFilter);
}
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(dateLocale);
return date.includes(term);
case 'quote':
return (
booking.id?.toLowerCase().includes(term) ||
booking.quoteNumber?.toLowerCase().includes(term)
);
default:
return true;
}
});
}
return filtered;
};
const filteredBookings = filterBookings(
(csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const }))
);
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);
const resetPage = () => setPage(1);
const statusOptions = [
{ value: '', label: t('statusFilter.all') },
{ value: 'PENDING', label: t('status.pending') },
{ value: 'ACCEPTED', label: t('status.accepted') },
{ value: 'REJECTED', label: t('status.rejected') },
];
const searchTypeOptions: { value: SearchType; label: string }[] = [
{ value: 'route', label: t('searchType.route') },
{ value: 'pallets', label: t('searchType.pallets') },
{ value: 'weight', label: t('searchType.weight') },
{ value: 'status', label: t('searchType.status') },
{ value: 'date', label: t('searchType.date') },
{ value: 'quote', label: t('searchType.quote') },
];
const getPlaceholder = () => {
const keyMap: Record<SearchType, string> = {
route: 'searchPlaceholder.route',
pallets: 'searchPlaceholder.pallets',
weight: 'searchPlaceholder.weight',
status: 'searchPlaceholder.status',
date: 'searchPlaceholder.date',
quote: 'searchPlaceholder.quote',
};
return t(keyMap[searchType] as any);
};
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 map: Record<string, string> = {
PENDING: t('status.pending'),
ACCEPTED: t('status.accepted'),
REJECTED: t('status.rejected'),
};
return map[status] || status;
};
return (
<div className="space-y-6">
{showTransferBanner && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start justify-between">
<div className="flex items-start space-x-3">
<Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-800">{t('transferBanner.title')}</p>
<p className="text-sm text-amber-700 mt-0.5">{t('transferBanner.message')}</p>
</div>
</div>
<button
onClick={() => setShowTransferBanner(false)}
className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0"
>
</button>
</div>
)}
<PageHeader
title={t('title')}
description={t('description')}
actions={
<>
<ExportButton
data={filteredBookings}
filename={t('exportFilename')}
columns={[
{ key: 'id', label: t('export.id') },
{ key: 'palletCount', label: t('export.pallets'), format: v => `${v || 0}` },
{ key: 'weightKG', label: t('export.weight'), format: v => `${v || 0}` },
{ key: 'volumeCBM', label: t('export.volume'), format: v => `${v || 0}` },
{ key: 'origin', label: t('export.origin') },
{ key: 'destination', label: t('export.destination') },
{ key: 'carrierName', label: t('export.carrier') },
{ key: 'status', label: t('export.status'), format: v => getStatusLabel(v) },
{
key: 'createdAt',
label: t('export.createdAt'),
format: v => (v ? new Date(v).toLocaleDateString(dateLocale) : ''),
},
]}
/>
<Link
href="/dashboard/search-advanced"
className="inline-flex items-center px-3 sm:px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">{t('new')}</span>
</Link>
</>
}
/>
<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">
{t('searchType.label')}
</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">
{t('search')}
</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">
{t('statusFilter.label')}
</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>
<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>
{t('loading')}
</div>
) : paginatedBookings && paginatedBookings.length > 0 ? (
<>
<div className="md:hidden divide-y divide-gray-200">
{paginatedBookings.map((booking: any) => (
<div key={`${booking.type}-${booking.id}`} className="p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<div className="text-sm font-semibold text-gray-900">
{booking.type === 'csv'
? `${booking.origin}${booking.destination}`
: booking.route || 'N/A'}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{booking.type === 'csv' ? booking.carrierName : booking.carrier || ''}
</div>
</div>
<span
className={`px-2 py-1 text-xs font-semibold rounded-full flex-shrink-0 ${getStatusColor(booking.status)}`}
>
{getStatusLabel(booking.status)}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<div className="text-gray-400 uppercase tracking-wide">
{t('mobile.pallets')}
</div>
<div className="font-medium text-gray-900 mt-0.5">
{booking.type === 'csv'
? t('units.palletsShort', { count: booking.palletCount })
: t('units.containersShort', { count: booking.containers?.length || 0 })}
</div>
</div>
<div>
<div className="text-gray-400 uppercase tracking-wide">
{t('mobile.weight')}
</div>
<div className="font-medium text-gray-900 mt-0.5">
{booking.type === 'csv'
? t('units.kg', { value: booking.weightKG })
: booking.totalWeight
? t('units.kg', { value: booking.totalWeight })
: 'N/A'}
</div>
</div>
<div>
<div className="text-gray-400 uppercase tracking-wide">
{t('mobile.date')}
</div>
<div className="font-medium text-gray-900 mt-0.5">
{booking.createdAt || booking.requestedAt
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(
dateLocale,
{ day: '2-digit', month: '2-digit', year: '2-digit' }
)
: 'N/A'}
</div>
</div>
</div>
<div className="text-xs text-gray-400">
{booking.type === 'csv'
? t('mobile.ref', {
id: booking.bookingId || booking.id.slice(0, 8).toUpperCase(),
})
: t('mobile.booking', { number: booking.bookingNumber || '-' })}
</div>
</div>
))}
</div>
<div className="hidden md:block 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">
{t('columns.palletsPackages')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.weight')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.route')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.date')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.quoteNumber')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.bookingNumber')}
</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'
? t('units.palletsCount', { count: booking.palletCount })
: t('units.containersCount', {
count: booking.containers?.length || 0,
})}
</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'
? t('units.kg', { value: booking.weightKG })
: booking.totalWeight
? t('units.kg', { value: booking.totalWeight })
: 'N/A'}
</div>
<div className="text-xs text-gray-500">
{booking.type === 'csv'
? t('units.cbm', { value: booking.volumeCBM })
: booking.totalVolume
? t('units.cbm', { value: booking.totalVolume })
: ''}
</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(
dateLocale,
{
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>
{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"
>
{t('pagination.previous')}
</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"
>
{t('pagination.next')}
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
{t.rich('pagination.showing', {
start: startIndex + 1,
end: Math.min(endIndex, totalBookings),
total: totalBookings,
b: chunks => <span className="font-medium">{chunks}</span>,
})}
</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">{t('pagination.previous')}</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>
{[...Array(totalPages)].map((_, idx) => {
const pageNum = idx + 1;
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">{t('pagination.next')}</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">{t('empty.title')}</h3>
<p className="mt-1 text-sm text-gray-500">
{searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings')}
</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" />
{t('new')}
</Link>
</div>
</div>
)}
</div>
</div>
);
}