Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
550 lines
19 KiB
TypeScript
550 lines
19 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,
|
|
} 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';
|
|
|
|
// 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();
|
|
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 (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();
|
|
|
|
/**
|
|
* Supprime une reservation impayee, 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()}`;
|
|
};
|
|
|
|
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<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>
|
|
{/* Suppression reservee aux reservations impayees : une fois
|
|
la commission reglee, 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]
|
|
);
|
|
|
|
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|