feat(booking-edit): reprise via le parcours existant pré-rempli

Remplace la page d'édition autonome par une reprise du parcours de réservation :
"Modifier" ré-ouvre la recherche pré-remplie (origine/destination/volume/poids/
palettes reconstruits), l'utilisateur ajuste si besoin, choisit une compagnie
(résultats) et passe au paiement — la réservation existante est mise à jour au
lieu d'en créer une nouvelle.

- Backend: PATCH /csv-bookings/:id/rate + UpdateCsvBookingRateDto + service
  updateBookingRate + domaine editFromRate (carrier/route/conteneur/transit/
  cargo/prix modifiables avant paiement).
- Front: search-advanced pré-rempli via query (+ editBookingId, démarre à
  l'étape colis) ; results en mode édition met à jour puis redirige vers /pay ;
  liens "Modifier" (liste, modale, paiement) pointent vers le parcours ;
  suppression de la page /booking/:id/edit autonome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David 2026-07-02 14:36:19 +02:00
parent 25fe78ccfc
commit 6cdbf29bf7
12 changed files with 481 additions and 620 deletions

View File

@ -48,6 +48,7 @@ import {
CsvBookingListResponseDto, CsvBookingListResponseDto,
CsvBookingStatsDto, CsvBookingStatsDto,
UpdateCsvBookingDetailsDto, UpdateCsvBookingDetailsDto,
UpdateCsvBookingRateDto,
} from '../dto/csv-booking.dto'; } from '../dto/csv-booking.dto';
/** /**
@ -592,6 +593,37 @@ export class CsvBookingsController {
return await this.csvBookingService.updateBookingDetails(id, userId, dto); return await this.csvBookingService.updateBookingDetails(id, userId, dto);
} }
/**
* Re-apply a full rate selection before payment
*
* PATCH /api/v1/csv-bookings/:id/rate
*/
@Patch(':id/rate')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Update booking rate/route before payment',
description:
'Re-apply a rate selection (carrier, route, container, transit, cargo, price) to a PENDING_PAYMENT booking. Only the owner can edit.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({
status: 200,
description: 'Booking rate updated successfully',
type: CsvBookingResponseDto,
})
@ApiResponse({ status: 400, description: 'Booking cannot be edited (invalid status or values)' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Booking not found' })
async updateBookingRate(
@Param('id') id: string,
@Body() dto: UpdateCsvBookingRateDto,
@Request() req: any
): Promise<CsvBookingResponseDto> {
const userId = req.user.id;
return await this.csvBookingService.updateBookingRate(id, userId, dto);
}
/** /**
* Add documents to an existing booking * Add documents to an existing booking
* *

View File

@ -249,6 +249,105 @@ export class UpdateCsvBookingDetailsDto {
fobCurrency?: string; fobCurrency?: string;
} }
/**
* Update CSV Booking Rate DTO
*
* Full re-selection of a rate before payment (carrier + route + container +
* transit + cargo + price). Used when the user re-runs the search and picks a
* (possibly different) rate for a PENDING_PAYMENT booking.
*/
export class UpdateCsvBookingRateDto {
@ApiProperty({ example: 'SSC Consolidation' })
@IsString()
@MinLength(2)
@MaxLength(200)
carrierName: string;
@ApiProperty({ example: 'bookings@example.com' })
@IsEmail()
carrierEmail: string;
@ApiProperty({ example: 'FRLIO' })
@IsString()
@MaxLength(5)
origin: string;
@ApiProperty({ example: 'AUADL' })
@IsString()
@MaxLength(5)
destination: string;
@ApiProperty({ example: 'LCL' })
@IsString()
@MaxLength(50)
containerType: string;
@ApiProperty({ example: 46 })
@IsNumber()
@Min(1)
transitDays: number;
@ApiProperty({ example: 12.5 })
@IsNumber()
@Min(0.01)
volumeCBM: number;
@ApiProperty({ example: 1500 })
@IsNumber()
@Min(0.01)
weightKG: number;
@ApiProperty({ example: 5 })
@IsNumber()
@Min(0)
palletCount: number;
@ApiProperty({ example: 0 })
@IsNumber()
@Min(0)
priceUSD: number;
@ApiProperty({ example: 258 })
@IsNumber()
@Min(0)
priceEUR: number;
@ApiProperty({ example: 'EUR' })
@IsString()
@MaxLength(3)
primaryCurrency: string;
@ApiPropertyOptional({ example: 150 })
@IsOptional()
@IsNumber()
@Min(0)
freightTotal?: number;
@ApiPropertyOptional({ example: 'EUR' })
@IsOptional()
@IsString()
@MaxLength(3)
freightCurrency?: string;
@ApiPropertyOptional({ example: 108 })
@IsOptional()
@IsNumber()
@Min(0)
fobTotal?: number;
@ApiPropertyOptional({ example: 'EUR' })
@IsOptional()
@IsString()
@MaxLength(3)
fobCurrency?: string;
@ApiPropertyOptional({ example: 'Handle with care' })
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
}
/** /**
* Document DTO for response * Document DTO for response
*/ */

View File

@ -32,6 +32,7 @@ import {
CsvBookingListResponseDto, CsvBookingListResponseDto,
CsvBookingStatsDto, CsvBookingStatsDto,
UpdateCsvBookingDetailsDto, UpdateCsvBookingDetailsDto,
UpdateCsvBookingRateDto,
} from '../dto/csv-booking.dto'; } from '../dto/csv-booking.dto';
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto'; import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
import { SubscriptionService } from './subscription.service'; import { SubscriptionService } from './subscription.service';
@ -1074,6 +1075,60 @@ export class CsvBookingService {
return this.toResponseDto(updatedBooking); return this.toResponseDto(updatedBooking);
} }
/**
* Re-apply a full rate selection to a booking before payment (user action).
*
* Used when the user re-runs the search and picks a (possibly different) rate:
* carrier, route, container, transit, cargo and price are all replaced. Only
* the owner can edit, and only while the booking is PENDING_PAYMENT.
*/
async updateBookingRate(
id: string,
userId: string,
dto: UpdateCsvBookingRateDto
): Promise<CsvBookingResponseDto> {
this.logger.log(`Updating booking rate ${id} by user ${userId}`);
const booking = await this.csvBookingRepository.findById(id);
if (!booking) {
throw new NotFoundException('Booking not found');
}
if (booking.userId !== userId) {
throw new NotFoundException('Booking not found');
}
try {
booking.editFromRate({
carrierName: dto.carrierName,
carrierEmail: dto.carrierEmail,
origin: PortCode.create(dto.origin),
destination: PortCode.create(dto.destination),
containerType: dto.containerType,
transitDays: dto.transitDays,
volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG,
palletCount: dto.palletCount,
priceUSD: dto.priceUSD,
priceEUR: dto.priceEUR,
primaryCurrency: dto.primaryCurrency,
freightTotal: dto.freightTotal,
freightCurrency: dto.freightCurrency,
fobTotal: dto.fobTotal,
fobCurrency: dto.fobCurrency,
notes: dto.notes,
});
} catch (err) {
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking rate');
}
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${id} rate updated`);
return this.toResponseDto(updatedBooking);
}
/** /**
* Get bookings for a user (paginated) * Get bookings for a user (paginated)
*/ */

View File

@ -63,10 +63,12 @@ export class CsvBooking {
public readonly id: string, public readonly id: string,
public readonly userId: string, public readonly userId: string,
public readonly organizationId: string, public readonly organizationId: string,
public readonly carrierName: string, // Carrier + route + transit + container — editable before payment when the
public readonly carrierEmail: string, // user re-runs the search and picks another rate (see editFromRate).
public readonly origin: PortCode, public carrierName: string,
public readonly destination: PortCode, public carrierEmail: string,
public origin: PortCode,
public destination: PortCode,
// Cargo characteristics — editable before payment (see editDetails). // Cargo characteristics — editable before payment (see editDetails).
public volumeCBM: number, public volumeCBM: number,
public weightKG: number, public weightKG: number,
@ -75,8 +77,8 @@ export class CsvBooking {
public priceUSD: number, public priceUSD: number,
public priceEUR: number, public priceEUR: number,
public primaryCurrency: string, public primaryCurrency: string,
public readonly transitDays: number, public transitDays: number,
public readonly containerType: string, public containerType: string,
public status: CsvBookingStatus, public status: CsvBookingStatus,
public readonly documents: CsvBookingDocument[], public readonly documents: CsvBookingDocument[],
public readonly confirmationToken: string, public readonly confirmationToken: string,
@ -367,6 +369,69 @@ export class CsvBooking {
} }
} }
/**
* Re-apply a full rate selection before payment: the user re-ran the search
* and picked a rate, so carrier, route, container, transit, cargo and price
* are all replaced. Only allowed while the booking is PENDING_PAYMENT.
*
* @throws Error if the booking is not PENDING_PAYMENT or values are invalid
*/
editFromRate(data: {
carrierName: string;
carrierEmail: string;
origin: PortCode;
destination: PortCode;
containerType: string;
transitDays: number;
volumeCBM: number;
weightKG: number;
palletCount: number;
priceUSD: number;
priceEUR: number;
primaryCurrency: string;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
notes?: string;
}): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error(
`Cannot edit booking with status ${this.status}. Only PENDING_PAYMENT bookings can be edited.`
);
}
if (!data.carrierName || data.carrierName.trim().length === 0) {
throw new Error('Carrier name is required');
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!data.carrierEmail || !emailRegex.test(data.carrierEmail)) {
throw new Error('Invalid carrier email format');
}
if (data.volumeCBM <= 0) throw new Error('Volume must be positive');
if (data.weightKG <= 0) throw new Error('Weight must be positive');
if (data.palletCount < 0) throw new Error('Pallet count cannot be negative');
if (data.transitDays <= 0) throw new Error('Transit days must be positive');
if (data.priceUSD < 0 || data.priceEUR < 0) throw new Error('Price cannot be negative');
this.carrierName = data.carrierName;
this.carrierEmail = data.carrierEmail;
this.origin = data.origin;
this.destination = data.destination;
this.containerType = data.containerType;
this.transitDays = data.transitDays;
this.volumeCBM = data.volumeCBM;
this.weightKG = data.weightKG;
this.palletCount = data.palletCount;
this.priceUSD = data.priceUSD;
this.priceEUR = data.priceEUR;
this.primaryCurrency = data.primaryCurrency;
this.freightTotal = data.freightTotal;
this.freightCurrency = data.freightCurrency;
this.fobTotal = data.fobTotal;
this.fobCurrency = data.fobCurrency;
if (data.notes !== undefined) this.notes = data.notes;
}
/** /**
* Check if booking has expired (7 days without response) * Check if booking has expired (7 days without response)
* *

View File

@ -1,593 +0,0 @@
/**
* 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 || [];
// 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 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>
);
}

View File

@ -227,7 +227,18 @@ export default function PayCommissionPage() {
Finalisez votre booking en réglant le forfait par booking Finalisez votre booking en réglant le forfait par booking
</p> </p>
<button <button
onClick={() => router.push(`/dashboard/booking/${bookingId}/edit`)} onClick={() => {
const params = new URLSearchParams({
editBookingId: bookingId,
origin: booking.origin || '',
destination: booking.destination || '',
volumeCBM: String(booking.volumeCBM ?? ''),
weightKG: String(booking.weightKG ?? ''),
palletCount: String(booking.palletCount ?? ''),
hasDangerousGoods: 'false',
});
router.push(`/dashboard/search-advanced?${params.toString()}`);
}}
className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors" className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />

View File

@ -30,6 +30,21 @@ export default function BookingsListPage() {
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null); const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null);
const ITEMS_PER_PAGE = 20; const ITEMS_PER_PAGE = 20;
// "Modifier" reopens the existing booking flow (search → carrier choice →
// payment) pre-filled with the booking's data, so a change re-quotes the price.
const buildEditUrl = (b: any) => {
const params = new URLSearchParams({
editBookingId: b.id,
origin: b.origin || b.originCity || '',
destination: b.destination || b.destinationCity || '',
volumeCBM: String(b.volumeCBM ?? ''),
weightKG: String(b.weightKG ?? ''),
palletCount: String(b.palletCount ?? ''),
hasDangerousGoods: 'false',
});
return `/dashboard/search-advanced?${params.toString()}`;
};
// Kebab (⋮) actions menu — anchored with fixed positioning so it is never // Kebab (⋮) actions menu — anchored with fixed positioning so it is never
// clipped by the scrollable table container. // clipped by the scrollable table container.
const toggleMenu = (e: React.MouseEvent, bookingId: string) => { const toggleMenu = (e: React.MouseEvent, bookingId: string) => {
@ -677,7 +692,7 @@ export default function BookingsListPage() {
{b.status === 'PENDING_PAYMENT' && ( {b.status === 'PENDING_PAYMENT' && (
<> <>
<Link <Link
href={`/dashboard/booking/${b.id}/edit`} href={buildEditUrl(b)}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50" className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
> >
<Pencil className="h-4 w-4 text-gray-500" /> <Pencil className="h-4 w-4 text-gray-500" />
@ -806,7 +821,7 @@ export default function BookingsListPage() {
</p> </p>
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<Link <Link
href={`/dashboard/booking/${selectedBooking.id}/edit`} href={buildEditUrl(selectedBooking)}
onClick={() => setSelectedBooking(null)} onClick={() => setSelectedBooking(null)}
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors"
> >

View File

@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from '@/i18n/navigation'; import { useRouter } from '@/i18n/navigation';
import { useSearchParams } from 'next/navigation';
import { Search, Loader2 } from 'lucide-react'; import { Search, Loader2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
@ -53,6 +54,8 @@ interface SearchForm {
export default function AdvancedSearchPage() { export default function AdvancedSearchPage() {
const t = useTranslations('dashboard.rateSearch'); const t = useTranslations('dashboard.rateSearch');
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const [editBookingId, setEditBookingId] = useState<string | null>(null);
const [searchForm, setSearchForm] = useState<SearchForm>({ const [searchForm, setSearchForm] = useState<SearchForm>({
origin: '', origin: '',
destination: '', destination: '',
@ -137,6 +140,76 @@ export default function AdvancedSearchPage() {
} }
}, [searchForm.origin, destinationsData]); }, [searchForm.origin, destinationsData]);
// Prefill the form when re-editing an existing unpaid booking (or from any
// deep link with query params). Runs once on mount.
useEffect(() => {
const originParam = searchParams.get('origin');
const destinationParam = searchParams.get('destination');
const editId = searchParams.get('editBookingId');
if (!originParam && !destinationParam && !editId) return;
if (editId) setEditBookingId(editId);
const vol = parseFloat(searchParams.get('volumeCBM') || '0');
const wgt = parseFloat(searchParams.get('weightKG') || '0');
const pallets = parseInt(searchParams.get('palletCount') || '0', 10);
const dg = searchParams.get('hasDangerousGoods') === 'true';
// Reconstruct a single representative package that reproduces the aggregate
// totals (the booking only stores totals, not individual packages).
const q = pallets > 0 ? pallets : 1;
const perVolM3 = vol > 0 ? vol / q : 0;
const sideCm = perVolM3 > 0 ? Math.cbrt(perVolM3) * 100 : 0;
const pkg = {
type: (pallets > 0 ? 'palette' : 'colis') as Package['type'],
quantity: q,
length: sideCm,
width: sideCm,
height: sideCm,
weight: wgt > 0 ? wgt / q : 0,
stackable: true,
};
setSearchForm(prev => ({
...prev,
origin: originParam || prev.origin,
destination: destinationParam || prev.destination,
dangerousGoods: dg,
packages: vol > 0 || wgt > 0 || pallets > 0 ? [pkg] : prev.packages,
}));
if (originParam) setOriginSearch(originParam);
if (destinationParam) setDestinationSearch(destinationParam);
// Start at the cargo (packages) step as requested.
setCurrentStep(2);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Once port lists load, resolve the prefilled codes to full port objects so
// the selection UI reflects them (and destination validation passes).
useEffect(() => {
if (searchForm.origin && !selectedOriginPort) {
const p = (originsData?.origins || []).find(o => o.code === searchForm.origin);
if (p) {
setSelectedOriginPort(p);
setOriginSearch(p.displayName);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [originsData, searchForm.origin]);
useEffect(() => {
if (searchForm.destination && !selectedDestinationPort) {
const p = (destinationsData?.destinations || []).find(
d => d.code === searchForm.destination
);
if (p) {
setSelectedDestinationPort(p);
setDestinationSearch(p.displayName);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [destinationsData, searchForm.destination]);
const calculateTotals = () => { const calculateTotals = () => {
let totalVolumeCBM = 0; let totalVolumeCBM = 0;
let totalWeightKG = 0; let totalWeightKG = 0;
@ -172,6 +245,10 @@ export default function AdvancedSearchPage() {
requiresAppointment: searchForm.appointment.toString(), requiresAppointment: searchForm.appointment.toString(),
}); });
if (editBookingId) {
params.set('editBookingId', editBookingId);
}
router.push(`/dashboard/search-advanced/results?${params.toString()}`); router.push(`/dashboard/search-advanced/results?${params.toString()}`);
}; };

View File

@ -5,6 +5,7 @@ import { useSearchParams } from 'next/navigation';
import { useRouter } from '@/i18n/navigation'; import { useRouter } from '@/i18n/navigation';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import { searchCsvRatesWithOffers } from '@/lib/api/rates'; import { searchCsvRatesWithOffers } from '@/lib/api/rates';
import { updateCsvBookingRate } from '@/lib/api/bookings';
import type { CsvRateSearchResult } from '@/types/rates'; import type { CsvRateSearchResult } from '@/types/rates';
import { import {
Search, Search,
@ -38,6 +39,59 @@ export default function SearchResultsPage() {
const volumeCBM = parseFloat(searchParams.get('volumeCBM') || '0'); const volumeCBM = parseFloat(searchParams.get('volumeCBM') || '0');
const weightKG = parseFloat(searchParams.get('weightKG') || '0'); const weightKG = parseFloat(searchParams.get('weightKG') || '0');
const palletCount = parseInt(searchParams.get('palletCount') || '0'); const palletCount = parseInt(searchParams.get('palletCount') || '0');
// When present, we are re-selecting a rate for an existing unpaid booking:
// update it in place and go straight to payment (skip the new-booking form).
const editBookingId = searchParams.get('editBookingId');
const [submittingId, setSubmittingId] = useState<string | null>(null);
const [selectError, setSelectError] = useState<string | null>(null);
const handleSelect = async (option: CsvRateSearchResult, key: string) => {
if (editBookingId) {
setSubmittingId(key);
setSelectError(null);
try {
const pb = option.priceBreakdown;
const freightTotal = pb.totalFreight;
const freightCurrency = pb.freightCurrency;
const fobTotal = pb.totalFob;
const fobCurrency = pb.fobCurrency;
await updateCsvBookingRate(editBookingId, {
carrierName: option.companyName,
carrierEmail: option.companyEmail,
origin: option.origin,
destination: option.destination,
containerType: option.containerType,
transitDays: option.adjustedTransitDays ?? option.transitDays,
volumeCBM,
weightKG,
palletCount,
priceUSD:
(freightCurrency === 'USD' ? freightTotal : 0) +
(fobCurrency === 'USD' ? fobTotal : 0),
priceEUR:
(freightCurrency === 'EUR' ? freightTotal : 0) +
(fobCurrency === 'EUR' ? fobTotal : 0),
primaryCurrency: pb.primaryCurrency || 'USD',
freightTotal,
freightCurrency,
fobTotal,
fobCurrency,
});
router.push(`/dashboard/booking/${editBookingId}/pay`);
} catch (err) {
setSelectError(
err instanceof Error ? err.message : 'Erreur lors de la mise à jour de la réservation'
);
setSubmittingId(null);
}
return;
}
const rateData = encodeURIComponent(JSON.stringify(option));
router.push(
`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`
);
};
const performSearch = useCallback(async () => { const performSearch = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@ -260,6 +314,17 @@ export default function SearchResultsPage() {
</div> </div>
</div> </div>
{editBookingId && (
<div className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">
{t('editMode')}
</div>
)}
{selectError && (
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{selectError}
</div>
)}
{/* Best Options */} {/* Best Options */}
{bestOptions && ( {bestOptions && (
<div className="mb-12"> <div className="mb-12">
@ -323,15 +388,15 @@ export default function SearchResultsPage() {
</div> </div>
<button <button
onClick={() => { onClick={() => handleSelect(card.option!, card.type)}
const rateData = encodeURIComponent(JSON.stringify(card.option)); disabled={submittingId === card.type}
router.push( className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors disabled:opacity-60`}
`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`
);
}}
className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors`}
> >
{t('select')} {submittingId === card.type
? '...'
: editBookingId
? t('selectAndPay')
: t('select')}
</button> </button>
</div> </div>
</div> </div>
@ -416,15 +481,15 @@ export default function SearchResultsPage() {
)} )}
</div> </div>
<button <button
onClick={() => { onClick={() => handleSelect(result, `all-${index}`)}
const rateData = encodeURIComponent(JSON.stringify(result)); disabled={submittingId === `all-${index}`}
router.push( className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-60"
`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`
);
}}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
> >
{t('selectShort')} {submittingId === `all-${index}`
? '...'
: editBookingId
? t('selectAndPay')
: t('selectShort')}
</button> </button>
</div> </div>
</div> </div>

View File

@ -619,7 +619,9 @@
}, },
"validUntil": "✓ Valid until {date}", "validUntil": "✓ Valid until {date}",
"surcharges": "Applicable surcharges", "surcharges": "Applicable surcharges",
"selectShort": "Select" "selectShort": "Select",
"selectAndPay": "Select and pay",
"editMode": "Editing an existing booking: pick a carrier to update your booking, then proceed to payment."
} }
}, },
"notificationsPage": { "notificationsPage": {

View File

@ -619,7 +619,9 @@
}, },
"validUntil": "✓ Valide jusqu'au {date}", "validUntil": "✓ Valide jusqu'au {date}",
"surcharges": "Surcharges applicables", "surcharges": "Surcharges applicables",
"selectShort": "Sélectionner" "selectShort": "Sélectionner",
"selectAndPay": "Choisir et payer",
"editMode": "Modification d'une réservation existante : choisissez une compagnie pour mettre à jour votre réservation, puis passez au paiement."
} }
}, },
"notificationsPage": { "notificationsPage": {

View File

@ -289,6 +289,37 @@ export async function updateCsvBookingDetails(
return patch<CsvBookingResponse>(`/api/v1/csv-bookings/${id}/details`, data); return patch<CsvBookingResponse>(`/api/v1/csv-bookings/${id}/details`, data);
} }
/**
* Re-apply a full rate selection to a booking awaiting payment
* PATCH /api/v1/csv-bookings/:id/rate
*/
export interface UpdateCsvBookingRateRequest {
carrierName: string;
carrierEmail: string;
origin: string;
destination: string;
containerType: string;
transitDays: number;
volumeCBM: number;
weightKG: number;
palletCount: number;
priceUSD: number;
priceEUR: number;
primaryCurrency: string;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
notes?: string;
}
export async function updateCsvBookingRate(
id: string,
data: UpdateCsvBookingRateRequest
): Promise<CsvBookingResponse> {
return patch<CsvBookingResponse>(`/api/v1/csv-bookings/${id}/rate`, data);
}
/** /**
* Accept a CSV booking (public endpoint, no auth required) * Accept a CSV booking (public endpoint, no auth required)
* POST /api/v1/csv-bookings/:token/accept * POST /api/v1/csv-bookings/:token/accept