From 6cdbf29bf7ba4c476583a910daff12a061c4b4d8 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 2 Jul 2026 14:36:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(booking-edit):=20reprise=20via=20le=20parc?= =?UTF-8?q?ours=20existant=20pr=C3=A9-rempli?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../controllers/csv-bookings.controller.ts | 32 + .../src/application/dto/csv-booking.dto.ts | 99 +++ .../services/csv-booking.service.ts | 55 ++ .../src/domain/entities/csv-booking.entity.ts | 77 ++- .../dashboard/booking/[id]/edit/page.tsx | 593 ------------------ .../dashboard/booking/[id]/pay/page.tsx | 13 +- .../app/[locale]/dashboard/bookings/page.tsx | 19 +- .../dashboard/search-advanced/page.tsx | 77 +++ .../search-advanced/results/page.tsx | 97 ++- apps/frontend/messages/en.json | 4 +- apps/frontend/messages/fr.json | 4 +- apps/frontend/src/lib/api/bookings.ts | 31 + 12 files changed, 481 insertions(+), 620 deletions(-) delete mode 100644 apps/frontend/app/[locale]/dashboard/booking/[id]/edit/page.tsx diff --git a/apps/backend/src/application/controllers/csv-bookings.controller.ts b/apps/backend/src/application/controllers/csv-bookings.controller.ts index 3601f6d..a6b4721 100644 --- a/apps/backend/src/application/controllers/csv-bookings.controller.ts +++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts @@ -48,6 +48,7 @@ import { CsvBookingListResponseDto, CsvBookingStatsDto, UpdateCsvBookingDetailsDto, + UpdateCsvBookingRateDto, } from '../dto/csv-booking.dto'; /** @@ -592,6 +593,37 @@ export class CsvBookingsController { 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 { + const userId = req.user.id; + return await this.csvBookingService.updateBookingRate(id, userId, dto); + } + /** * Add documents to an existing booking * diff --git a/apps/backend/src/application/dto/csv-booking.dto.ts b/apps/backend/src/application/dto/csv-booking.dto.ts index 5d4137d..d553dbf 100644 --- a/apps/backend/src/application/dto/csv-booking.dto.ts +++ b/apps/backend/src/application/dto/csv-booking.dto.ts @@ -249,6 +249,105 @@ export class UpdateCsvBookingDetailsDto { 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 */ diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index 84e4805..f4abcd6 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -32,6 +32,7 @@ import { CsvBookingListResponseDto, CsvBookingStatsDto, UpdateCsvBookingDetailsDto, + UpdateCsvBookingRateDto, } from '../dto/csv-booking.dto'; import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto'; import { SubscriptionService } from './subscription.service'; @@ -1074,6 +1075,60 @@ export class CsvBookingService { 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 { + 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) */ diff --git a/apps/backend/src/domain/entities/csv-booking.entity.ts b/apps/backend/src/domain/entities/csv-booking.entity.ts index d37b6f4..d85e6dc 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.ts @@ -63,10 +63,12 @@ export class CsvBooking { public readonly id: string, public readonly userId: string, public readonly organizationId: string, - public readonly carrierName: string, - public readonly carrierEmail: string, - public readonly origin: PortCode, - public readonly destination: PortCode, + // Carrier + route + transit + container — editable before payment when the + // user re-runs the search and picks another rate (see editFromRate). + public carrierName: string, + public carrierEmail: string, + public origin: PortCode, + public destination: PortCode, // Cargo characteristics — editable before payment (see editDetails). public volumeCBM: number, public weightKG: number, @@ -75,8 +77,8 @@ export class CsvBooking { public priceUSD: number, public priceEUR: number, public primaryCurrency: string, - public readonly transitDays: number, - public readonly containerType: string, + public transitDays: number, + public containerType: string, public status: CsvBookingStatus, public readonly documents: CsvBookingDocument[], 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) * diff --git a/apps/frontend/app/[locale]/dashboard/booking/[id]/edit/page.tsx b/apps/frontend/app/[locale]/dashboard/booking/[id]/edit/page.tsx deleted file mode 100644 index af6baea..0000000 --- a/apps/frontend/app/[locale]/dashboard/booking/[id]/edit/page.tsx +++ /dev/null @@ -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(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" - /> -
-
-
- -