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:
David 2026-09-07 21:40:56 +02:00
parent abe3bfe9ae
commit d89104f49e

View File

@ -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')}
</Link>
</Button> </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>{t('shipper.title')}</CardTitle> <CardTitle>{tList('detail.cargo')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<DescriptionList <dl className="grid gap-4 sm:grid-cols-3">
columns={1} <Field label={t('packing')}>
items={[ {booking.palletCount
{ label: t('shipper.name'), value: booking.shipper.name, emphasis: true }, ? tList('units.palletsCount', { count: booking.palletCount })
{ label: t('shipper.contact'), value: booking.shipper.contactName }, : '—'}
{ label: t('shipper.email'), value: booking.shipper.contactEmail }, </Field>
{ label: t('shipper.phone'), value: booking.shipper.contactPhone }, <Field label={t('volume')}>
]} {booking.volumeCBM ? tList('units.cbm', { value: booking.volumeCBM }) : '—'}
/> </Field>
<Field label={t('weight')}>
{booking.weightKG ? tList('units.kg', { value: booking.weightKG }) : '—'}
</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> </CardContent>
</Card> </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">
{document.fileName}
</span>
<span className="shrink-0 text-body-xs text-neutral-400">
{document.type}
</span>
</li>
))}
</ul>
) : (
<EmptyState size="sm" icon={FileText} title={t('noDocuments')} />
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{price && (
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle>{t('timeline.title')}</CardTitle> <CardTitle>{tList('detail.price')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ol className="relative flex flex-col gap-4"> <p className="font-heading text-h3 text-brand-navy">{price}</p>
<li className="flex gap-3"> <dl className="mt-4 flex flex-col gap-3 border-t border-border pt-4">
<span {booking.freightTotal ? (
aria-hidden="true" <Field label={t('freight')}>
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-brand-blue/10" {booking.freightTotal} {booking.freightCurrency}
> </Field>
<FileText className="size-3 text-brand-blue" /> ) : null}
</span> {booking.fobTotal ? (
<div className="min-w-0"> <Field label={t('destinationCharges')}>
<p className="text-body-sm font-medium text-brand-navy"> {booking.fobTotal} {booking.fobCurrency}
{t('timeline.created')} </Field>
</p> ) : null}
<p className="mt-0.5 text-body-xs text-neutral-500"> {booking.commissionAmountEur ? (
{new Date(booking.createdAt).toLocaleString(dateLocale)} <Field label={t('commission')}>{booking.commissionAmountEur} €</Field>
</p> ) : null}
</div> </dl>
</li>
</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>
);
}