xpeditis2.0/apps/frontend/app/[locale]/dashboard/booking/[id]/edit/page.tsx
David bce77edd76 fix(bookings,docs): suppression docs, edition+recalcul prix, menu 3 points, responsive
- Suppression de documents: cause = deleteDocument restreint aux statuts
  PENDING/PENDING_PAYMENT + blocage du dernier document. Alignement sur
  replaceDocument (aucune restriction de statut) et autorisation de 0 document
  (validation domaine assouplie ; creation exige toujours >=1 doc cote service).
- Edition avant paiement: recalcul auto du prix. La page /booking/:id/edit
  relance la recherche de tarif (meme transporteur/conteneur/transit) pour le
  nouveau volume/poids et met a jour fret/FOB/total ; nouveaux champs de prix
  dans UpdateCsvBookingDetailsDto + editDetails.
- Liste reservations: actions regroupees dans un menu 3 points (kebab) en
  positionnement fixed (anti-clipping), desktop + mobile.
- Responsive (<=1280px): tables documents & blog admin en overflow-x-auto,
  menus documents en fixed, grilles de la page edition en sm:.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:56:25 +02:00

586 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Edit Booking Page (before payment)
*
* Lets the owner adjust cargo details (volume, weight, pallets, notes) and manage
* documents of a booking that is still awaiting payment (PENDING_PAYMENT). When the
* cargo changes, the price is recomputed by re-quoting the same rate (carrier +
* container + transit) for the new volume/weight. Then they can continue to payment.
*/
'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation';
import {
ArrowLeft,
Loader2,
AlertTriangle,
Save,
CreditCard,
FileText,
Trash2,
Upload,
Package,
RefreshCw,
} from 'lucide-react';
import { getCsvBooking, updateCsvBookingDetails } from '@/lib/api/bookings';
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
import { upload, del } from '@/lib/api/client';
interface BookingDoc {
id?: string;
type: string;
fileName: string;
url: string;
}
interface BookingData {
id: string;
bookingNumber?: string;
carrierName: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
palletCount: number;
containerType: string;
transitDays: number;
status: string;
notes?: string;
primaryCurrency?: string;
priceUSD?: number;
priceEUR?: number;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
documents: BookingDoc[];
}
interface PricingState {
freightTotal: number;
freightCurrency: string;
fobTotal: number;
fobCurrency: string;
primaryCurrency: string;
priceUSD: number;
priceEUR: number;
}
function formatPrice(price: number, currency: string) {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(price || 0);
}
export default function EditBookingPage() {
const router = useRouter();
const params = useParams();
const bookingId = params.id as string;
const [booking, setBooking] = useState<BookingData | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [volumeCBM, setVolumeCBM] = useState('');
const [weightKG, setWeightKG] = useState('');
const [palletCount, setPalletCount] = useState('');
const [notes, setNotes] = useState('');
const [pricing, setPricing] = useState<PricingState | null>(null);
const [repricing, setRepricing] = useState(false);
const [priceWarning, setPriceWarning] = useState<string | null>(null);
const [uploadingDocs, setUploadingDocs] = useState(false);
const [deletingDocId, setDeletingDocId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const applyBooking = (data: BookingData) => {
setBooking(data);
setVolumeCBM(String(data.volumeCBM ?? ''));
setWeightKG(String(data.weightKG ?? ''));
setPalletCount(String(data.palletCount ?? ''));
setNotes(data.notes ?? '');
setPricing({
freightTotal: data.freightTotal ?? 0,
freightCurrency: data.freightCurrency ?? data.primaryCurrency ?? 'USD',
fobTotal: data.fobTotal ?? 0,
fobCurrency: data.fobCurrency ?? 'EUR',
primaryCurrency: data.primaryCurrency ?? 'USD',
priceUSD: data.priceUSD ?? 0,
priceEUR: data.priceEUR ?? 0,
});
};
const loadBooking = async () => {
try {
const data = (await getCsvBooking(bookingId)) as any as BookingData;
if (data.status !== 'PENDING_PAYMENT') {
// Only bookings awaiting payment can be edited.
router.replace('/dashboard/bookings');
return;
}
applyBooking(data);
} catch (err) {
setError('Impossible de charger la réservation');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (bookingId) loadBooking();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookingId]);
// Recompute the price by re-quoting the same rate for the new volume/weight.
// Returns the new pricing, or null if no matching rate could be found.
const recomputePricing = async (vol: number, wgt: number): Promise<PricingState | null> => {
if (!booking) return null;
const response = await searchCsvRatesWithOffers({
origin: booking.origin,
destination: booking.destination,
volumeCBM: vol,
weightKG: wgt,
hasDangerousGoods: false,
});
const results = response.results || [];
const match =
results.find(
(r: any) =>
r.companyName === booking.carrierName &&
r.containerType === booking.containerType &&
(r.adjustedTransitDays ?? r.transitDays) === booking.transitDays
) ||
results.find(
(r: any) =>
r.companyName === booking.carrierName && r.containerType === booking.containerType
);
if (!match) return null;
const pb = match.priceBreakdown;
const freightTotal = pb.totalFreight;
const freightCurrency = pb.freightCurrency;
const fobTotal = pb.totalFob;
const fobCurrency = pb.fobCurrency;
return {
freightTotal,
freightCurrency,
fobTotal,
fobCurrency,
primaryCurrency: pb.primaryCurrency || 'USD',
priceUSD:
(freightCurrency === 'USD' ? freightTotal : 0) + (fobCurrency === 'USD' ? fobTotal : 0),
priceEUR:
(freightCurrency === 'EUR' ? freightTotal : 0) + (fobCurrency === 'EUR' ? fobTotal : 0),
};
};
// Debounced auto-recompute when volume/weight change (price depends on both).
useEffect(() => {
if (!booking) return;
const vol = parseFloat(volumeCBM);
const wgt = parseFloat(weightKG);
if (!Number.isFinite(vol) || vol <= 0 || !Number.isFinite(wgt) || wgt <= 0) return;
const handle = setTimeout(async () => {
setRepricing(true);
setPriceWarning(null);
try {
const next = await recomputePricing(vol, wgt);
if (next) {
setPricing(next);
} else {
setPriceWarning(
'Tarif introuvable pour cette route — le prix affiché reste celui dorigine.'
);
}
} catch {
setPriceWarning('Impossible de recalculer le prix pour le moment.');
} finally {
setRepricing(false);
}
}, 600);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [volumeCBM, weightKG, booking]);
const persistDetails = async (): Promise<boolean> => {
setError(null);
const vol = parseFloat(volumeCBM);
const wgt = parseFloat(weightKG);
const plt = parseInt(palletCount, 10);
if (!Number.isFinite(vol) || vol <= 0) {
setError('Le volume doit être un nombre positif.');
return false;
}
if (!Number.isFinite(wgt) || wgt <= 0) {
setError('Le poids doit être un nombre positif.');
return false;
}
if (!Number.isFinite(plt) || plt < 0) {
setError('Le nombre de palettes est invalide.');
return false;
}
const updated = (await updateCsvBookingDetails(bookingId, {
volumeCBM: vol,
weightKG: wgt,
palletCount: plt,
notes: notes.trim() || undefined,
...(pricing
? {
priceUSD: pricing.priceUSD,
priceEUR: pricing.priceEUR,
primaryCurrency: pricing.primaryCurrency,
freightTotal: pricing.freightTotal,
freightCurrency: pricing.freightCurrency,
fobTotal: pricing.fobTotal,
fobCurrency: pricing.fobCurrency,
}
: {}),
})) as any as BookingData;
applyBooking(updated);
return true;
};
const handleSave = async () => {
setSaving(true);
setSuccess(null);
try {
const ok = await persistDetails();
if (ok) setSuccess('Modifications enregistrées.');
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur lors de l'enregistrement");
} finally {
setSaving(false);
}
};
const handleContinueToPayment = async () => {
setSaving(true);
setSuccess(null);
try {
const ok = await persistDetails();
if (ok) router.push(`/dashboard/booking/${bookingId}/pay`);
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur lors de l'enregistrement");
} finally {
setSaving(false);
}
};
const handleAddDocuments = async (files: FileList | null) => {
if (!files || files.length === 0) return;
setUploadingDocs(true);
setError(null);
try {
const formData = new FormData();
Array.from(files).forEach(file => formData.append('documents', file));
await upload(`/api/v1/csv-bookings/${bookingId}/documents`, formData);
await loadBooking();
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur lors de l'ajout des documents");
} finally {
setUploadingDocs(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleDeleteDocument = async (docId?: string) => {
if (!docId) return;
if (!confirm('Supprimer ce document ?')) return;
setDeletingDocId(docId);
setError(null);
try {
await del(`/api/v1/csv-bookings/${bookingId}/documents/${docId}`);
await loadBooking();
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur lors de la suppression du document');
} finally {
setDeletingDocId(null);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
<div className="flex items-center space-x-3">
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
<span className="text-gray-600">Chargement...</span>
</div>
</div>
);
}
if (!booking) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50 px-4">
<div className="bg-white rounded-xl shadow-md p-8 max-w-md w-full">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<p className="text-center text-gray-700">{error || 'Réservation introuvable'}</p>
<button
onClick={() => router.push('/dashboard/bookings')}
className="mt-4 w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retour aux réservations
</button>
</div>
</div>
);
}
const transportTotalUSD = (pricing?.priceUSD ?? 0) + (pricing?.priceEUR ?? 0);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-6 sm:py-10 px-4">
<div className="max-w-4xl mx-auto">
<button
onClick={() => router.back()}
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour
</button>
<h1 className="text-2xl font-bold text-gray-900 mb-1">Modifier la réservation</h1>
<p className="text-gray-500 mb-8">
Corrigez les informations de votre réservation avant de procéder au paiement. Le prix est
recalculé automatiquement.
</p>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start space-x-3">
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{success && (
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-green-700 text-sm">{success}</p>
</div>
)}
{/* Read-only rate summary */}
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Réservation
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-gray-500">Transporteur</p>
<p className="font-semibold text-gray-900 break-words">{booking.carrierName}</p>
</div>
<div>
<p className="text-gray-500">Trajet</p>
<p className="font-semibold text-gray-900">
{booking.origin} {booking.destination}
</p>
</div>
<div>
<p className="text-gray-500">Type</p>
<p className="font-semibold text-gray-900">{booking.containerType}</p>
</div>
{booking.bookingNumber && (
<div>
<p className="text-gray-500">Numéro</p>
<p className="font-semibold text-gray-900">{booking.bookingNumber}</p>
</div>
)}
</div>
<p className="text-xs text-gray-400 mt-3">
Le transporteur et le trajet proviennent du tarif sélectionné et ne sont pas modifiables
ici.
</p>
</div>
{/* Editable cargo details */}
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6 space-y-4">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide flex items-center gap-2">
<Package className="h-4 w-4" />
Caractéristiques de la marchandise
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Volume (CBM)</label>
<input
type="number"
min="0"
step="0.01"
value={volumeCBM}
onChange={e => setVolumeCBM(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Poids (kg)</label>
<input
type="number"
min="0"
step="0.01"
value={weightKG}
onChange={e => setWeightKG(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Palettes</label>
<input
type="number"
min="0"
step="1"
value={palletCount}
onChange={e => setPalletCount(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea
rows={3}
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Instructions particulières..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
</div>
{/* Price summary (recomputed) */}
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide">
Prix estimé
</h2>
{repricing && (
<span className="inline-flex items-center gap-1.5 text-xs text-blue-600">
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
Recalcul...
</span>
)}
</div>
{priceWarning && (
<div className="mb-3 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
{priceWarning}
</div>
)}
{pricing && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-500 text-xs mb-0.5">Fret</p>
<p className="font-semibold text-gray-900">
{formatPrice(pricing.freightTotal, pricing.freightCurrency)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-500 text-xs mb-0.5">Frais FOB</p>
<p className="font-semibold text-gray-900">
{formatPrice(pricing.fobTotal, pricing.fobCurrency)}
</p>
</div>
<div className="bg-blue-50 rounded-lg p-3">
<p className="text-blue-500 text-xs mb-0.5">Total transport (USD)</p>
<p className="font-bold text-blue-900">{formatPrice(transportTotalUSD, 'USD')}</p>
</div>
</div>
)}
<p className="text-xs text-gray-400 mt-3">
Fret et frais FOB sont réglés directement au transporteur. Le forfait Xpeditis reste
inchangé.
</p>
</div>
{/* Documents */}
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6">
<div className="flex items-center justify-between mb-3 gap-2 flex-wrap">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide flex items-center gap-2">
<FileText className="h-4 w-4" />
Documents
</h2>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingDocs}
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors disabled:opacity-50"
>
{uploadingDocs ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
Ajouter
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={e => handleAddDocuments(e.target.files)}
/>
</div>
{booking.documents.length === 0 ? (
<p className="text-sm text-gray-400">Aucun document.</p>
) : (
<ul className="divide-y divide-gray-100">
{booking.documents.map((doc, idx) => (
<li
key={doc.id || `${doc.fileName}-${idx}`}
className="flex items-center justify-between py-2.5 gap-3"
>
<div className="flex items-center gap-3 min-w-0">
<FileText className="h-4 w-4 text-gray-400 flex-shrink-0" />
<span className="text-sm text-gray-800 truncate">{doc.fileName}</span>
</div>
{doc.id && (
<button
type="button"
onClick={() => handleDeleteDocument(doc.id)}
disabled={deletingDocId === doc.id}
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors disabled:opacity-50 flex-shrink-0"
title="Supprimer"
>
{deletingDocId === doc.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
)}
</li>
))}
</ul>
)}
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={handleSave}
disabled={saving}
className="flex-1 py-3 bg-white border border-gray-300 text-gray-800 rounded-lg font-semibold hover:bg-gray-50 disabled:opacity-50 flex items-center justify-center gap-2 transition-colors"
>
{saving ? <Loader2 className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
Enregistrer les modifications
</button>
<button
onClick={handleContinueToPayment}
disabled={saving}
className="flex-1 py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2 transition-colors"
>
{saving ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<CreditCard className="h-5 w-5" />
)}
Continuer vers le paiement
</button>
</div>
</div>
</div>
);
}