xpeditis2.0/apps/frontend/src/components/bookings/BookingListView.tsx
David 76d940d73b feat: ajouter l'onglet Historique des devis et remplacer le statut Paiement en attente
Le statut PENDING_PAYMENT ne designe plus un paiement en attente mais un devis :
une reservation construite dont les frais de booking ne sont pas encore regles.
L'enum backend devient QUOTE, converti en place par migration.

Les deux onglets se partagent desormais la meme liste :
- « Reservations » n'affiche que PENDING, ACCEPTED et REJECTED ;
- « Historique des devis » regroupe QUOTE, PENDING_BANK_TRANSFER et CANCELLED,
  avec les actions propres a un devis (modifier, payer, supprimer).

La table, l'export et les filtres sont extraits dans BookingListView pour eviter
de dupliquer la page entre les deux vues.

Tableau de bord :
- l'alerte « Paiement en attente » disparait des points a traiter, les devis non
  regles ne bloquant aucun envoi en cours ;
- « Sans reponse du transporteur » devient « En attente du transporteur ».

Migration verifiee en transaction annulee sur la base de dev : les lignes
PENDING_PAYMENT deviennent QUOTE, l'enum et le defaut de colonne suivent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZJ5aowZJVAcyPZw5TiNEm
2026-09-14 11:43:57 +02:00

586 lines
20 KiB
TypeScript

'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<any | null>(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<ColumnDef<any, any>[]>(
() => [
{
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 (
<div className="min-w-0">
<p className="truncate font-medium text-brand-navy">{b.bookingNumber || fallback}</p>
{b.bookingNumber && (
<p className="truncate text-body-xs text-neutral-400">{fallback}</p>
)}
</div>
);
},
},
{
id: 'route',
header: t('columns.route'),
enableSorting: false,
meta: { cardRole: 'subtitle', headerLabel: t('columns.route') },
cell: ({ row }) => {
const b = row.original;
return (
<div className="min-w-0">
<p className="truncate text-neutral-700">
{b.origin} → {b.destination}
</p>
<p className="truncate text-body-xs text-neutral-500">{b.carrierName}</p>
</div>
);
},
},
{
id: 'cargo',
header: t('columns.cargo'),
enableSorting: false,
meta: { hideBelow: 'lg', cardRole: 'field', headerLabel: t('columns.cargo') },
cell: ({ row }) => {
const b = row.original;
return (
<div className="min-w-0">
<p className="truncate text-neutral-700">
{t('units.palletsCount', { count: b.palletCount })} · LCL
</p>
<p className="truncate text-body-xs text-neutral-500">
{t('units.kg', { value: b.weightKG })} · {t('units.cbm', { value: b.volumeCBM })}
</p>
</div>
);
},
},
{
id: 'status',
header: t('columns.status'),
enableSorting: false,
meta: { cardRole: 'field', headerLabel: t('columns.status') },
cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
},
{
id: 'date',
header: t('columns.date'),
enableSorting: false,
meta: { hideBelow: 'lg', cardRole: 'field', headerLabel: t('columns.date') },
cell: ({ row }) => (
<span className="whitespace-nowrap text-neutral-500">
{shortDate(row.original.createdAt || row.original.requestedAt)}
</span>
),
},
{
id: 'actions',
header: t('columns.actions'),
size: 64,
enableSorting: false,
meta: { align: 'right', cardRole: 'actions' },
cell: ({ row }) => {
const b = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={t('columns.actions')}>
<MoreVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => setSelectedBooking(b)}>
<Eye />
{t('actions.view')}
</DropdownMenuItem>
{quoteActions && b.status === 'QUOTE' && (
<>
<DropdownMenuItem asChild>
<Link href={buildEditUrl(b)}>
<Pencil />
{t('actions.edit')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/dashboard/booking/${b.id}/pay`}>
<CreditCard />
{t('actions.pay')}
</Link>
</DropdownMenuItem>
{/* 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. */}
<DropdownMenuItem
onSelect={() => remove(b)}
className="text-destructive focus:text-destructive"
>
<Trash2 />
{t('actions.delete')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
},
},
],
// 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 ? (
<Button asChild variant="secondary">
<Link href="/dashboard/settings/subscription">
<Sparkles />
<span className="hidden sm:inline">{t('limit.upgrade')}</span>
<span className="sm:hidden">{t('limit.upgradeShort')}</span>
</Link>
</Button>
) : (
<Button asChild>
<Link href="/dashboard/search-advanced">
<Plus />
{t('new')}
</Link>
</Button>
);
return (
<>
<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 => statusLabel(v) },
{
key: 'createdAt',
label: t('export.createdAt'),
format: v => (v ? new Date(v).toLocaleDateString(dateLocale) : ''),
},
]}
/>
{newReservationAction}
</>
}
/>
<div className="flex flex-col gap-4">
{showTransferBanner && (
<Callout variant="warning" title={t('transferBanner.title')}>
<div className="flex items-start justify-between gap-3">
<p>{t('transferBanner.message')}</p>
<Button
variant="ghost"
size="icon-sm"
className="-mr-1 -mt-1 shrink-0"
onClick={() => setShowTransferBanner(false)}
aria-label={tCommon('dismiss')}
>
<X />
</Button>
</div>
</Callout>
)}
{quota.blocked && (
<Callout variant="warning" title={t('limit.title')}>
<p>{t('limit.message', { max: quota.max })}</p>
<Button asChild variant="secondary" size="sm" className="mt-3">
<Link href="/dashboard/settings/subscription">
<Sparkles />
{t('limit.upgrade')}
</Link>
</Button>
</Callout>
)}
<FilterBar
active={activeFilters}
onClearAll={() => url.clear()}
labels={{
filters: tCommon('filters'),
clearAll: tCommon('clearFilters'),
remove: tCommon('removeFilter'),
}}
filters={
<>
<Select value={searchType} onValueChange={value => url.set('by', value)}>
<SelectTrigger className="w-full md:w-44" aria-label={t('searchType.label')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{searchTypeOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={statusFilter || 'all'}
onValueChange={value => url.set('status', value === 'all' ? undefined : value)}
>
<SelectTrigger className="w-full md:w-48" aria-label={t('statusFilter.label')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
}
>
<SearchInput
value={searchTerm}
onDebouncedChange={value => url.set('q', value || undefined)}
placeholder={placeholder}
ariaLabel={t('search')}
/>
</FilterBar>
<DataTable
columns={columns}
data={paginatedBookings}
loading={isLoading}
error={
csvError
? {
title: tErrors('generic'),
detail: csvError instanceof Error ? csvError.message : undefined,
retryLabel: tCommon('retry'),
onRetry: () => 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 => <span className="font-medium text-brand-navy">{chunks}</span>,
}),
},
}}
/>
</div>
<BookingDetailDialog
booking={selectedBooking}
onOpenChange={open => !open && setSelectedBooking(null)}
buildEditUrl={buildEditUrl}
namespace={namespace}
/>
</>
);
}