'use client'; import { useEffect, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useSearchParams } from 'next/navigation'; import { useLocale, useTranslations } from 'next-intl'; import type { ColumnDef } from '@tanstack/react-table'; import { CreditCard, Eye, MoreVertical, Package, Pencil, Plus, Sparkles, X } from 'lucide-react'; import { listCsvBookings } from '@/lib/api'; import { Link } from '@/i18n/navigation'; import { PageHeader } from '@/components/ui/PageHeader'; import { Button } from '@/components/ui/button'; import { Callout } from '@/components/ui/callout'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { DataTable } from '@/components/data/data-table'; import { FilterBar, type ActiveFilter } from '@/components/data/filter-bar'; import { SearchInput } from '@/components/data/search-input'; import { BookingDetailDialog } from '@/components/bookings/BookingDetailDialog'; import { BookingStatusBadge, useBookingStatusLabel, } from '@/components/bookings/BookingStatusBadge'; import ExportButton from '@/components/ExportButton'; import { useReservationQuota } from '@/hooks/useReservationQuota'; import { useUrlState } from '@/hooks/use-url-state'; type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote'; // Statuts consideres comme « non finalises » : reservation commencee, non payee. const TO_FINALIZE_STATUSES = ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER']; const ITEMS_PER_PAGE = 20; export default function BookingsListPage() { const t = useTranslations('dashboard.bookingsList'); const tCommon = useTranslations('common'); const tErrors = useTranslations('errors'); const locale = useLocale(); const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US'; const searchParams = useSearchParams(); const url = useUrlState(); const statusLabel = useBookingStatusLabel(); // L'etat de vue vit dans l'URL : la liste est partageable et survit au // rechargement, ce qui n'etait pas le cas avec un etat purement local. const searchTerm = url.get('q') ?? ''; const searchType = (url.get('by') as SearchType) ?? 'route'; const statusFilter = url.get('status') ?? ''; const page = url.getNumber('page', 1); const [showTransferBanner, setShowTransferBanner] = useState(false); const [selectedBooking, setSelectedBooking] = useState(null); useEffect(() => { if (searchParams.get('transfer') === 'declared') setShowTransferBanner(true); }, [searchParams]); const { data: csvData, isLoading, error: csvError, refetch, } = useQuery({ queryKey: ['csv-bookings'], queryFn: () => listCsvBookings({ page: 1, limit: 1000 }), }); const quota = useReservationQuota(); // « Modifier » rouvre le parcours existant (recherche → transporteur → // paiement) pre-rempli, afin qu'un changement redonne lieu a une cotation. const buildEditUrl = (b: any) => { const enabledOptions = Object.keys(b.options || {}) .filter(k => b.options[k]) .join(','); const params = new URLSearchParams({ editBookingId: b.id, origin: b.origin || b.originCity || '', destination: b.destination || b.destinationCity || '', volumeCBM: String(b.volumeCBM ?? ''), weightKG: String(b.weightKG ?? ''), palletCount: String(b.palletCount ?? ''), hasDangerousGoods: String(!!(b.options && b.options.dangerousGoods)), }); if (enabledOptions) params.set('options', enabledOptions); return `/dashboard/search-advanced?${params.toString()}`; }; const statusOptions = [ { value: 'all', label: t('statusFilter.all') }, { value: 'to_finalize', label: t('statusFilter.toFinalize') }, { value: 'PENDING_PAYMENT', label: t('status.pendingPayment') }, { value: 'PENDING_BANK_TRANSFER', label: t('status.pendingBankTransfer') }, { value: 'PENDING', label: t('status.pending') }, { value: 'ACCEPTED', label: t('status.accepted') }, { value: 'REJECTED', label: t('status.rejected') }, { value: 'CANCELLED', label: t('status.cancelled') }, ]; 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 placeholder = t(`searchPlaceholder.${searchType}` as any); const filteredBookings = useMemo(() => { let filtered = (csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const })); if (statusFilter) { filtered = statusFilter === 'to_finalize' ? filtered.filter((b: any) => TO_FINALIZE_STATUSES.includes(b.status)) : filtered.filter((b: any) => b.status === statusFilter); } const term = searchTerm.trim().toLowerCase(); if (term) { 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': return new Date(booking.requestedPickupDate || booking.requestedAt) .toLocaleDateString(dateLocale) .includes(term); case 'quote': // `bookingNumber` est le numero affiche en premiere colonne : sans // lui, rechercher le numero qu'on a sous les yeux ne donnait rien. return ( booking.bookingNumber?.toLowerCase().includes(term) || booking.id?.toLowerCase().includes(term) || booking.quoteNumber?.toLowerCase().includes(term) ); default: return true; } }); } return filtered; }, [csvData, statusFilter, searchTerm, searchType, dateLocale]); const totalBookings = filteredBookings.length; const pageCount = Math.max(1, Math.ceil(totalBookings / ITEMS_PER_PAGE)); const safePage = Math.min(page, pageCount); const startIndex = (safePage - 1) * ITEMS_PER_PAGE; const paginatedBookings = filteredBookings.slice(startIndex, startIndex + ITEMS_PER_PAGE); const shortDate = (value?: string) => value ? new Date(value).toLocaleDateString(dateLocale, { day: '2-digit', month: '2-digit', year: 'numeric', }) : 'N/A'; const columns = useMemo[]>( () => [ { id: 'reference', header: t('columns.reference'), enableSorting: false, meta: { cardRole: 'title', headerLabel: t('columns.reference') }, cell: ({ row }) => { const b = row.original; const fallback = `#${b.bookingId || b.id.slice(0, 8).toUpperCase()}`; return (

{b.bookingNumber || fallback}

{b.bookingNumber && (

{fallback}

)}
); }, }, { id: 'route', header: t('columns.route'), enableSorting: false, meta: { cardRole: 'subtitle', headerLabel: t('columns.route') }, cell: ({ row }) => { const b = row.original; return (

{b.origin} → {b.destination}

{b.carrierName}

); }, }, { id: 'cargo', header: t('columns.cargo'), enableSorting: false, meta: { hideBelow: 'lg', cardRole: 'field', headerLabel: t('columns.cargo') }, cell: ({ row }) => { const b = row.original; return (

{t('units.palletsCount', { count: b.palletCount })} · LCL

{t('units.kg', { value: b.weightKG })} · {t('units.cbm', { value: b.volumeCBM })}

); }, }, { id: 'status', header: t('columns.status'), enableSorting: false, meta: { cardRole: 'field', headerLabel: t('columns.status') }, cell: ({ row }) => , }, { id: 'date', header: t('columns.date'), enableSorting: false, meta: { hideBelow: 'lg', cardRole: 'field', headerLabel: t('columns.date') }, cell: ({ row }) => ( {shortDate(row.original.createdAt || row.original.requestedAt)} ), }, { id: 'actions', header: t('columns.actions'), size: 64, enableSorting: false, meta: { align: 'right', cardRole: 'actions' }, cell: ({ row }) => { const b = row.original; return ( setSelectedBooking(b)}> {t('actions.view')} {b.status === 'PENDING_PAYMENT' && ( <> {t('actions.edit')} {t('actions.pay')} )} ); }, }, ], // eslint-disable-next-line react-hooks/exhaustive-deps [t, dateLocale] ); const activeFilters: ActiveFilter[] = []; if (statusFilter) { activeFilters.push({ key: 'status', label: t('statusFilter.label'), value: statusOptions.find(o => o.value === statusFilter)?.label ?? statusFilter, onRemove: () => url.set('status', undefined), }); } if (searchTerm) { activeFilters.push({ key: 'q', label: searchTypeOptions.find(o => o.value === searchType)?.label ?? t('search'), value: searchTerm, onRemove: () => url.set('q', undefined), }); } const newReservationAction = quota.blocked ? ( ) : ( ); return ( <> `${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 => statusLabel(v) }, { key: 'createdAt', label: t('export.createdAt'), format: v => (v ? new Date(v).toLocaleDateString(dateLocale) : ''), }, ]} /> {newReservationAction} } />
{showTransferBanner && (

{t('transferBanner.message')}

)} {quota.blocked && (

{t('limit.message', { max: quota.max })}

)} url.clear()} labels={{ filters: tCommon('filters'), clearAll: tCommon('clearFilters'), remove: tCommon('removeFilter'), }} filters={ <> } > url.set('q', value || undefined)} placeholder={placeholder} ariaLabel={t('search')} /> refetch(), } : null } empty={{ icon: Package, title: t('empty.title'), description: searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings'), action: newReservationAction, }} caption={t('title')} pagination={{ page: safePage, pageCount, onPageChange: next => url.set('page', next > 1 ? next : undefined), totalItems: totalBookings, pageSize: ITEMS_PER_PAGE, labels: { previous: t('pagination.previous'), next: t('pagination.next'), showing: (from, to, total) => t.rich('pagination.showing', { start: from, end: to, total, b: chunks => {chunks}, }), }, }} />
!open && setSelectedBooking(null)} buildEditUrl={buildEditUrl} /> ); }