'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, Trash2, X, type LucideIcon, } from 'lucide-react'; import { deleteCsvBooking, 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 { useConfirm } from '@/components/ui/use-confirm'; import { useToast } from '@/components/ui/toast'; 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'; const ITEMS_PER_PAGE = 20; export interface BookingListViewProps { /** * Namespace de traduction de la vue (`dashboard.bookingsList` ou * `dashboard.quotesList`). Les libelles de statut, eux, restent communs. */ namespace: string; /** Statuts affiches par la vue. Le reste est pris en charge par l'autre onglet. */ statuses: string[]; /** * Actions propres a un devis non regle : modifier, payer, supprimer. Une fois * les frais de booking regles, la demande est partie chez le transporteur. */ quoteActions?: boolean; /** Bandeau de confirmation apres declaration d'un virement. */ transferBanner?: boolean; /** Icone de l'etat vide. */ emptyIcon?: LucideIcon; } /** * Liste de reservations filtree par statut. * * Les deux onglets (« Reservations » et « Historique des devis ») partagent la * meme table, le meme export et les memes filtres : seuls les statuts couverts * et les actions disponibles les distinguent. */ export function BookingListView({ namespace, statuses, quoteActions = false, transferBanner = false, emptyIcon = Package, }: BookingListViewProps) { const t = useTranslations(namespace as any); 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(); const confirm = useConfirm(); const { toast } = useToast(); // 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 (transferBanner && searchParams.get('transfer') === 'declared') { setShowTransferBanner(true); } }, [searchParams, transferBanner]); const { data: csvData, isLoading, error: csvError, refetch, } = useQuery({ queryKey: ['csv-bookings'], queryFn: () => listCsvBookings({ page: 1, limit: 1000 }), }); const quota = useReservationQuota(); /** * Supprime un devis non regle, apres confirmation. * * L'action est destructrice et sans annulation : la confirmation nomme la * reservation concernee plutot que de demander « etes-vous sur ? ». */ const remove = async (b: any) => { const reference = b.bookingNumber || b.quoteNumber || `${b.origin} → ${b.destination}`; const confirmed = await confirm({ title: t('deleteConfirm.title'), description: t('deleteConfirm.body', { reference }), confirmLabel: t('actions.delete'), destructive: true, }); if (!confirmed) return; try { await deleteCsvBooking(b.id); toast.success(t('deleteConfirm.done')); await refetch(); } catch { toast.error(t('deleteConfirm.failed')); } }; // « 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()}`; }; // Le filtre de statut ne propose que les statuts couverts par la vue : y // laisser les autres donnerait des listes systematiquement vides. const statusOptions = useMemo( () => [ { value: 'all', label: t('statusFilter.all') }, ...statuses.map(status => ({ value: status, label: statusLabel(status) })), ], // eslint-disable-next-line react-hooks/exhaustive-deps [t, statuses.join(',')] ); 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(() => { // La vue ne connait que ses statuts : une reservation reglee n'apparait pas // dans l'historique des devis, et inversement. let filtered = (csvData?.bookings || []) .filter((b: any) => statuses.includes(b.status)) .map(b => ({ ...b, type: 'csv' as const })); if (statusFilter) { filtered = 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; // eslint-disable-next-line react-hooks/exhaustive-deps }, [csvData, statusFilter, searchTerm, searchType, dateLocale, statuses.join(',')]); 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')} {quoteActions && b.status === 'QUOTE' && ( <> {t('actions.edit')} {t('actions.pay')} {/* Suppression reservee aux devis non regles : une fois les frais de booking payes, la reservation est partie chez le transporteur et ne peut plus qu'etre annulee. */} remove(b)} className="text-destructive focus:text-destructive" > {t('actions.delete')} )} ); }, }, ], // eslint-disable-next-line react-hooks/exhaustive-deps [t, dateLocale, quoteActions] ); 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: emptyIcon, 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} namespace={namespace} /> ); }