xpeditis2.0/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.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

269 lines
9.7 KiB
TypeScript

'use client';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { ArrowLeft, ArrowRight, CreditCard, FileText, Package, Pencil } from 'lucide-react';
import { getCsvBooking } from '@/lib/api';
import { Link } from '@/i18n/navigation';
import { Button } from '@/components/ui/button';
import { Callout } from '@/components/ui/callout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/ui/PageHeader';
import { EmptyState, ErrorState, PageSpinner } from '@/components/ui/states';
import { BookingStatusBadge } from '@/components/bookings/BookingStatusBadge';
/**
* 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() {
const t = useTranslations('dashboard.bookingDetail');
const tList = useTranslations('dashboard.bookingsList');
const tCommon = useTranslations('common');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const params = useParams();
const bookingId = params.id as string;
const {
data: booking,
isPending,
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 dateTime = (value?: string) =>
value
? new Date(value).toLocaleString(dateLocale, { dateStyle: 'long', timeStyle: 'short' })
: '—';
if (isPending) return <PageSpinner label={tCommon('loading')} />;
if (isError) {
const missing = (error as { statusCode?: number })?.statusCode === 404;
return missing ? (
<EmptyState
icon={Package}
title={t('notFound')}
description={t('notFoundBody')}
action={
<Button asChild variant="outline">
<Link href="/dashboard/bookings">
<ArrowLeft />
{t('back')}
</Link>
</Button>
}
/>
) : (
<ErrorState
title={t('errorTitle')}
description={t('errorBody')}
retryLabel={tCommon('retry')}
onRetry={() => refetch()}
/>
);
}
const unpaid = booking.status === 'QUOTE';
const price = booking.priceEUR
? `${booking.priceEUR} €`
: booking.priceUSD
? `${booking.priceUSD} $`
: null;
return (
<>
<PageHeader
title={booking.bookingNumber ?? `#${booking.id.slice(0, 8).toUpperCase()}`}
description={t('requestedOn', { date: dateTime(booking.requestedAt) })}
actions={
<>
<BookingStatusBadge status={booking.status} />
{unpaid && (
<Button asChild>
<Link href={`/dashboard/booking/${booking.id}/pay`}>
<CreditCard />
{tList('detail.payNow')}
</Link>
</Button>
)}
</>
}
/>
{unpaid && (
<Callout variant="warning" className="mb-4">
{tList('detail.quoteNotice')}
</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="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>
<CardContent className="p-5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<span className="font-heading text-h4 text-brand-navy">{booking.origin}</span>
<span className="flex items-center gap-2 text-neutral-400">
<ArrowRight className="size-4" aria-hidden="true" />
{booking.transitDays ? (
<span className="text-body-xs">
{t('transitDays', { days: booking.transitDays })}
</span>
) : null}
</span>
<span className="font-heading text-h4 text-brand-navy">{booking.destination}</span>
</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>{tList('detail.cargo')}</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-3">
<Field label={t('packing')}>
{booking.palletCount
? tList('units.palletsCount', { count: booking.palletCount })
: '—'}
</Field>
<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>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('documents', { count: booking.documents?.length ?? 0 })}</CardTitle>
</CardHeader>
<CardContent>
{booking.documents?.length ? (
<ul className="flex flex-col gap-2">
{booking.documents.map((document, index) => (
<li
key={index}
className="flex items-center gap-3 rounded-md border border-border px-4 py-3"
>
<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>
</Card>
</div>
<div className="flex flex-col gap-4">
{price && (
<Card>
<CardHeader className="pb-3">
<CardTitle>{tList('detail.price')}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-heading text-h3 text-brand-navy">{price}</p>
<dl className="mt-4 flex flex-col gap-3 border-t border-border pt-4">
{booking.freightTotal ? (
<Field label={t('freight')}>
{booking.freightTotal} {booking.freightCurrency}
</Field>
) : null}
{booking.fobTotal ? (
<Field label={t('destinationCharges')}>
{booking.fobTotal} {booking.fobCurrency}
</Field>
) : null}
{booking.commissionAmountEur ? (
<Field label={t('commission')}>{booking.commissionAmountEur} €</Field>
) : null}
</dl>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('timeline')}</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3">
<Field label={t('requested')}>{dateTime(booking.requestedAt)}</Field>
{booking.respondedAt && (
<Field label={t('responded')}>{dateTime(booking.respondedAt)}</Field>
)}
<Field label={t('reference')}>{booking.id}</Field>
</dl>
</CardContent>
</Card>
<Button asChild variant="outline">
<Link href="/dashboard/bookings">
<ArrowLeft />
{t('back')}
</Link>
</Button>
</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>
);
}