fix(ui): la page de detail lit csv-bookings, la table bookings n existe pas
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
This commit is contained in:
parent
abe3bfe9ae
commit
d89104f49e
@ -3,61 +3,68 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { ArrowLeft, Download, FileText, Package } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, CreditCard, FileText, Package, Pencil } from 'lucide-react';
|
||||||
|
|
||||||
import { getBooking } from '@/lib/api';
|
import { getCsvBooking } from '@/lib/api';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { Badge, type BadgeProps } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Callout } from '@/components/ui/callout';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { PageHeader } from '@/components/ui/PageHeader';
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
import { EmptyState, PageSpinner } from '@/components/ui/states';
|
import { EmptyState, ErrorState, PageSpinner } from '@/components/ui/states';
|
||||||
import { useToast } from '@/components/ui/toast';
|
import { BookingStatusBadge } from '@/components/bookings/BookingStatusBadge';
|
||||||
import { DescriptionList } from '@/components/data/description-list';
|
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
|
|
||||||
draft: 'neutral',
|
|
||||||
pending: 'warning',
|
|
||||||
confirmed: 'info',
|
|
||||||
in_transit: 'info',
|
|
||||||
delivered: 'success',
|
|
||||||
cancelled: 'danger',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail d'une reservation.
|
||||||
|
*
|
||||||
|
* La page interrogeait `GET /bookings/:id` et affichait un modele — expediteur,
|
||||||
|
* destinataire, conteneurs — qui n'existe pas dans ce produit : la table
|
||||||
|
* `bookings` n'a jamais ete creee, les reservations vivent dans `csv_bookings`.
|
||||||
|
* Elle repondait donc 500 et tournait indefiniment. C'est ce qui donnait
|
||||||
|
* l'impression qu'un clic sur une notification « ne faisait rien » : la
|
||||||
|
* navigation avait bien lieu, la destination ne s'affichait jamais.
|
||||||
|
*
|
||||||
|
* Elle lit desormais `GET /csv-bookings/:id`, la meme source que la liste.
|
||||||
|
*/
|
||||||
export default function BookingDetailPage() {
|
export default function BookingDetailPage() {
|
||||||
const t = useTranslations('dashboard.bookingDetail');
|
const t = useTranslations('dashboard.bookingDetail');
|
||||||
|
const tList = useTranslations('dashboard.bookingsList');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const bookingId = params.id as string;
|
const bookingId = params.id as string;
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { data: booking, isLoading } = useQuery({
|
const {
|
||||||
queryKey: ['booking', bookingId],
|
data: booking,
|
||||||
queryFn: () => getBooking(bookingId),
|
isPending,
|
||||||
enabled: !!bookingId,
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['csv-booking', bookingId],
|
||||||
|
queryFn: () => getCsvBooking(bookingId),
|
||||||
|
enabled: Boolean(bookingId),
|
||||||
|
// Une reservation introuvable ne le deviendra pas en reessayant : trois
|
||||||
|
// tentatives ne faisaient que prolonger le tourniquet.
|
||||||
|
retry: (failureCount, err) =>
|
||||||
|
(err as { statusCode?: number })?.statusCode === 404 ? false : failureCount < 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusLabel = (status: string) => {
|
const dateTime = (value?: string) =>
|
||||||
try {
|
value
|
||||||
return t(`status.${status}` as any);
|
? new Date(value).toLocaleString(dateLocale, { dateStyle: 'long', timeStyle: 'short' })
|
||||||
} catch {
|
: '—';
|
||||||
return status;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Le telechargement PDF n'est pas encore branche cote back : on le signale
|
if (isPending) return <PageSpinner label={tCommon('loading')} />;
|
||||||
// par une notification plutot que par une alerte bloquante du navigateur.
|
|
||||||
const downloadPDF = () => toast.info(t('pdfNotImplemented'));
|
|
||||||
|
|
||||||
if (isLoading) return <PageSpinner label={tCommon('loading')} />;
|
if (isError) {
|
||||||
|
const missing = (error as { statusCode?: number })?.statusCode === 404;
|
||||||
if (!booking) {
|
return missing ? (
|
||||||
return (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Package}
|
icon={Package}
|
||||||
title={t('notFound')}
|
title={t('notFound')}
|
||||||
|
description={t('notFoundBody')}
|
||||||
action={
|
action={
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href="/dashboard/bookings">
|
<Link href="/dashboard/bookings">
|
||||||
@ -67,173 +74,195 @@ export default function BookingDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<ErrorState
|
||||||
|
title={t('errorTitle')}
|
||||||
|
description={t('errorBody')}
|
||||||
|
retryLabel={tCommon('retry')}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unpaid = booking.status === 'PENDING_PAYMENT';
|
||||||
|
const price = booking.priceEUR
|
||||||
|
? `${booking.priceEUR} €`
|
||||||
|
: booking.priceUSD
|
||||||
|
? `${booking.priceUSD} $`
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={booking.bookingNumber}
|
title={booking.bookingNumber ?? `#${booking.id.slice(0, 8).toUpperCase()}`}
|
||||||
description={t('createdOn', {
|
description={t('requestedOn', { date: dateTime(booking.requestedAt) })}
|
||||||
date: new Date(booking.createdAt).toLocaleDateString(dateLocale),
|
|
||||||
})}
|
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Badge variant={STATUS_VARIANT[booking.status] ?? 'neutral'}>
|
<BookingStatusBadge status={booking.status} />
|
||||||
{statusLabel(booking.status)}
|
{unpaid && (
|
||||||
</Badge>
|
<Button asChild>
|
||||||
<Button variant="outline" onClick={downloadPDF}>
|
<Link href={`/dashboard/booking/${booking.id}/pay`}>
|
||||||
<Download />
|
<CreditCard />
|
||||||
{t('downloadPdf')}
|
{tList('detail.payNow')}
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{unpaid && (
|
||||||
|
<Callout variant="warning" className="mb-4">
|
||||||
|
{tList('detail.pendingPaymentNotice')}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{booking.status === 'REJECTED' && booking.rejectionReason && (
|
||||||
|
<Callout variant="danger" className="mb-4" title={t('rejected')}>
|
||||||
|
{booking.rejectionReason}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
<div className="flex flex-col gap-4 lg:col-span-2">
|
<div className="flex flex-col gap-4 lg:col-span-2">
|
||||||
|
{/* La route est ce qu'un transitaire regarde en premier : elle occupe
|
||||||
|
la tete de page, pas une ligne de tableau parmi d'autres. */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardContent className="p-5">
|
||||||
<CardTitle>{t('cargo.title')}</CardTitle>
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
</CardHeader>
|
<span className="font-heading text-h4 text-brand-navy">{booking.origin}</span>
|
||||||
<CardContent>
|
<span className="flex items-center gap-2 text-neutral-400">
|
||||||
<DescriptionList
|
<ArrowRight className="size-4" aria-hidden="true" />
|
||||||
columns={1}
|
{booking.transitDays ? (
|
||||||
items={[
|
<span className="text-body-xs">
|
||||||
{ label: t('cargo.description'), value: booking.cargoDescription },
|
{t('transitDays', { days: booking.transitDays })}
|
||||||
...(booking.specialInstructions
|
</span>
|
||||||
? [
|
) : null}
|
||||||
{
|
</span>
|
||||||
label: t('cargo.specialInstructions'),
|
<span className="font-heading text-h4 text-brand-navy">{booking.destination}</span>
|
||||||
value: booking.specialInstructions,
|
</div>
|
||||||
},
|
{booking.carrierName && (
|
||||||
]
|
<p className="mt-2 text-body-sm text-neutral-500">
|
||||||
: []),
|
{t('carrier', { carrier: booking.carrierName })}
|
||||||
]}
|
</p>
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle>
|
|
||||||
{t('containers.title', { count: booking.containers?.length || 0 })}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{booking.containers && booking.containers.length > 0 ? (
|
|
||||||
<ul className="flex flex-col gap-3">
|
|
||||||
{booking.containers.map((container, index) => (
|
|
||||||
<li
|
|
||||||
key={container.id || index}
|
|
||||||
className="rounded-md border border-border p-4"
|
|
||||||
>
|
|
||||||
<DescriptionList
|
|
||||||
items={[
|
|
||||||
{ label: t('containers.type'), value: container.type },
|
|
||||||
...(container.containerNumber
|
|
||||||
? [{ label: t('containers.number'), value: container.containerNumber }]
|
|
||||||
: []),
|
|
||||||
...(container.sealNumber
|
|
||||||
? [{ label: t('containers.seal'), value: container.sealNumber }]
|
|
||||||
: []),
|
|
||||||
...(container.vgm
|
|
||||||
? [{ label: t('containers.vgm'), value: container.vgm }]
|
|
||||||
: []),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<EmptyState size="sm" icon={Package} title={t('containers.title', { count: 0 })} />
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<Card>
|
||||||
<Card>
|
<CardHeader className="pb-3">
|
||||||
<CardHeader className="pb-3">
|
<CardTitle>{tList('detail.cargo')}</CardTitle>
|
||||||
<CardTitle>{t('shipper.title')}</CardTitle>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent>
|
||||||
<CardContent>
|
<dl className="grid gap-4 sm:grid-cols-3">
|
||||||
<DescriptionList
|
<Field label={t('packing')}>
|
||||||
columns={1}
|
{booking.palletCount
|
||||||
items={[
|
? tList('units.palletsCount', { count: booking.palletCount })
|
||||||
{ label: t('shipper.name'), value: booking.shipper.name, emphasis: true },
|
: '—'}
|
||||||
{ label: t('shipper.contact'), value: booking.shipper.contactName },
|
</Field>
|
||||||
{ label: t('shipper.email'), value: booking.shipper.contactEmail },
|
<Field label={t('volume')}>
|
||||||
{ label: t('shipper.phone'), value: booking.shipper.contactPhone },
|
{booking.volumeCBM ? tList('units.cbm', { value: booking.volumeCBM }) : '—'}
|
||||||
]}
|
</Field>
|
||||||
/>
|
<Field label={t('weight')}>
|
||||||
</CardContent>
|
{booking.weightKG ? tList('units.kg', { value: booking.weightKG }) : '—'}
|
||||||
</Card>
|
</Field>
|
||||||
|
</dl>
|
||||||
|
{booking.notes && (
|
||||||
|
<p className="mt-4 border-t border-border pt-4 text-body-sm leading-6 text-neutral-600">
|
||||||
|
{booking.notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>{t('consignee.title')}</CardTitle>
|
<CardTitle>{t('documents', { count: booking.documents?.length ?? 0 })}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DescriptionList
|
{booking.documents?.length ? (
|
||||||
columns={1}
|
<ul className="flex flex-col gap-2">
|
||||||
items={[
|
{booking.documents.map((document, index) => (
|
||||||
{ label: t('consignee.name'), value: booking.consignee.name, emphasis: true },
|
<li
|
||||||
{ label: t('consignee.contact'), value: booking.consignee.contactName },
|
key={index}
|
||||||
{ label: t('consignee.email'), value: booking.consignee.contactEmail },
|
className="flex items-center gap-3 rounded-md border border-border px-4 py-3"
|
||||||
{ label: t('consignee.phone'), value: booking.consignee.contactPhone },
|
>
|
||||||
]}
|
<FileText className="size-4 shrink-0 text-neutral-400" aria-hidden="true" />
|
||||||
/>
|
<span className="min-w-0 flex-1 truncate text-body-sm text-neutral-700">
|
||||||
</CardContent>
|
{document.fileName}
|
||||||
</Card>
|
</span>
|
||||||
</div>
|
<span className="shrink-0 text-body-xs text-neutral-400">
|
||||||
|
{document.type}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<EmptyState size="sm" icon={FileText} title={t('noDocuments')} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Card>
|
{price && (
|
||||||
<CardHeader className="pb-3">
|
<Card>
|
||||||
<CardTitle>{t('timeline.title')}</CardTitle>
|
<CardHeader className="pb-3">
|
||||||
</CardHeader>
|
<CardTitle>{tList('detail.price')}</CardTitle>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<ol className="relative flex flex-col gap-4">
|
<CardContent>
|
||||||
<li className="flex gap-3">
|
<p className="font-heading text-h3 text-brand-navy">{price}</p>
|
||||||
<span
|
<dl className="mt-4 flex flex-col gap-3 border-t border-border pt-4">
|
||||||
aria-hidden="true"
|
{booking.freightTotal ? (
|
||||||
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-brand-blue/10"
|
<Field label={t('freight')}>
|
||||||
>
|
{booking.freightTotal} {booking.freightCurrency}
|
||||||
<FileText className="size-3 text-brand-blue" />
|
</Field>
|
||||||
</span>
|
) : null}
|
||||||
<div className="min-w-0">
|
{booking.fobTotal ? (
|
||||||
<p className="text-body-sm font-medium text-brand-navy">
|
<Field label={t('destinationCharges')}>
|
||||||
{t('timeline.created')}
|
{booking.fobTotal} {booking.fobCurrency}
|
||||||
</p>
|
</Field>
|
||||||
<p className="mt-0.5 text-body-xs text-neutral-500">
|
) : null}
|
||||||
{new Date(booking.createdAt).toLocaleString(dateLocale)}
|
{booking.commissionAmountEur ? (
|
||||||
</p>
|
<Field label={t('commission')}>{booking.commissionAmountEur} €</Field>
|
||||||
</div>
|
) : null}
|
||||||
</li>
|
</dl>
|
||||||
</ol>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>{t('info.title')}</CardTitle>
|
<CardTitle>{t('timeline')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DescriptionList
|
<dl className="flex flex-col gap-3">
|
||||||
columns={1}
|
<Field label={t('requested')}>{dateTime(booking.requestedAt)}</Field>
|
||||||
items={[
|
{booking.respondedAt && (
|
||||||
{ label: t('info.bookingId'), value: booking.id },
|
<Field label={t('responded')}>{dateTime(booking.respondedAt)}</Field>
|
||||||
{
|
)}
|
||||||
label: t('info.lastUpdated'),
|
<Field label={t('reference')}>{booking.id}</Field>
|
||||||
value: new Date(booking.updatedAt).toLocaleString(dateLocale),
|
</dl>
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/dashboard/bookings">
|
||||||
|
<ArrowLeft />
|
||||||
|
{t('back')}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-label uppercase text-neutral-400">{label}</dt>
|
||||||
|
<dd className="mt-1 break-words text-body-sm text-neutral-700">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user