xpeditis2.0/apps/frontend/app/[locale]/dashboard/bookings/page.tsx
2026-08-13 12:26:46 +02:00

502 lines
17 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, 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<any | null>(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':
return (
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<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>
{b.status === 'PENDING_PAYMENT' && (
<>
<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>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
},
},
],
// 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 ? (
<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: 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 => <span className="font-medium text-brand-navy">{chunks}</span>,
}),
},
}}
/>
</div>
<BookingDetailDialog
booking={selectedBooking}
onOpenChange={open => !open && setSelectedBooking(null)}
buildEditUrl={buildEditUrl}
/>
</>
);
}