/** * 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(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [volumeCBM, setVolumeCBM] = useState(''); const [weightKG, setWeightKG] = useState(''); const [palletCount, setPalletCount] = useState(''); const [notes, setNotes] = useState(''); const [pricing, setPricing] = useState(null); const [repricing, setRepricing] = useState(false); const [priceWarning, setPriceWarning] = useState(null); const [uploadingDocs, setUploadingDocs] = useState(false); const [deletingDocId, setDeletingDocId] = useState(null); const fileInputRef = useRef(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 => { if (!booking) return null; const response = await searchCsvRatesWithOffers({ origin: booking.origin, destination: booking.destination, volumeCBM: vol, weightKG: wgt, hasDangerousGoods: false, }); const results = response.results || []; // The rate search returns the carrier as a slug (e.g. "ssc-consolidation") // while the booking stores the display name ("SSC Consolidation"), so carriers // are compared on a normalized form. Container + transit uniquely identify the // originally chosen offer for the route, and are the primary match keys. const norm = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); const carrier = norm(booking.carrierName); const sameContainer = (r: any) => r.containerType === booking.containerType; const sameTransit = (r: any) => (r.adjustedTransitDays ?? r.transitDays) === booking.transitDays; const sameCarrier = (r: any) => norm(r.companyName) === carrier; const match = results.find((r: any) => sameCarrier(r) && sameContainer(r) && sameTransit(r)) || results.find((r: any) => sameContainer(r) && sameTransit(r)) || results.find((r: any) => sameCarrier(r) && sameContainer(r)) || results.find((r: any) => sameCarrier(r)) || results.find((r: any) => sameContainer(r)) || null; 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 d’origine.' ); } } 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 => { 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 (
Chargement...
); } if (!booking) { return (

{error || 'Réservation introuvable'}

); } const transportTotalUSD = (pricing?.priceUSD ?? 0) + (pricing?.priceEUR ?? 0); return (

Modifier la réservation

Corrigez les informations de votre réservation avant de procéder au paiement. Le prix est recalculé automatiquement.

{error && (

{error}

)} {success && (

{success}

)} {/* Read-only rate summary */}

Réservation

Transporteur

{booking.carrierName}

Trajet

{booking.origin} → {booking.destination}

Type

{booking.containerType}

{booking.bookingNumber && (

Numéro

{booking.bookingNumber}

)}

Le transporteur et le trajet proviennent du tarif sélectionné et ne sont pas modifiables ici.

{/* Editable cargo details */}

Caractéristiques de la marchandise

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" />
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" />
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" />