From 7234343e20ae23d4c94276830f60e909f6685fa0 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 1 Jul 2026 13:30:05 +0200 Subject: [PATCH] feat(bookings): Phase 2 edition avant paiement + filtre + ergonomie - Edition d'une reservation avant paiement: page /booking/:id/edit (volume, poids, palettes, notes + gestion documents), endpoint PATCH /csv-bookings/:id/details (owner + PENDING_PAYMENT), methode domaine editDetails. Acces "Modifier" depuis la liste, la modale detail et la page de paiement. - Filtre "A finaliser" (PENDING_PAYMENT + PENDING_BANK_TRANSFER) + libelles et couleurs de tous les statuts. - Tableau des reservations: 6 colonnes + colonne Actions collante a droite (plus de scroll horizontal pour atteindre Payer/Voir). Co-Authored-By: Claude Opus 4.8 --- .../controllers/csv-bookings.controller.ts | 32 ++ .../src/application/dto/csv-booking.dto.ts | 32 ++ .../services/csv-booking.service.ts | 42 ++ .../src/domain/entities/csv-booking.entity.ts | 54 ++- .../dashboard/booking/[id]/edit/page.tsx | 416 ++++++++++++++++++ .../dashboard/booking/[id]/pay/page.tsx | 9 +- .../app/[locale]/dashboard/bookings/page.tsx | 270 +++++++----- apps/frontend/messages/en.json | 13 +- apps/frontend/messages/fr.json | 13 +- apps/frontend/src/lib/api/bookings.ts | 18 + 10 files changed, 772 insertions(+), 127 deletions(-) create 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 07a19ca..3601f6d 100644 --- a/apps/backend/src/application/controllers/csv-bookings.controller.ts +++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts @@ -47,6 +47,7 @@ import { CsvBookingResponseDto, CsvBookingListResponseDto, CsvBookingStatsDto, + UpdateCsvBookingDetailsDto, } from '../dto/csv-booking.dto'; /** @@ -560,6 +561,37 @@ export class CsvBookingsController { return await this.csvBookingService.cancelBooking(id, userId); } + /** + * Update booking cargo details before payment + * + * PATCH /api/v1/csv-bookings/:id/details + */ + @Patch(':id/details') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update booking details before payment', + description: + 'Edit cargo characteristics (volume, weight, pallets, notes) of a booking awaiting payment. Only the owner can edit, and only while the booking is PENDING_PAYMENT.', + }) + @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) + @ApiResponse({ + status: 200, + description: 'Booking details 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 updateBookingDetails( + @Param('id') id: string, + @Body() dto: UpdateCsvBookingDetailsDto, + @Request() req: any + ): Promise { + const userId = req.user.id; + return await this.csvBookingService.updateBookingDetails(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 2203dbc..a5ac3b6 100644 --- a/apps/backend/src/application/dto/csv-booking.dto.ts +++ b/apps/backend/src/application/dto/csv-booking.dto.ts @@ -173,6 +173,38 @@ export class CreateCsvBookingDto { // Not included in DTO validation but processed separately } +/** + * Update CSV Booking Details DTO + * + * Editable cargo characteristics before payment. Carrier, route and price are + * derived from the selected rate and cannot be changed here. + */ +export class UpdateCsvBookingDetailsDto { + @ApiPropertyOptional({ description: 'Total volume in cubic meters (CBM)', example: 12.5 }) + @IsOptional() + @IsNumber() + @Min(0.01) + volumeCBM?: number; + + @ApiPropertyOptional({ description: 'Total weight in kilograms', example: 1500 }) + @IsOptional() + @IsNumber() + @Min(0.01) + weightKG?: number; + + @ApiPropertyOptional({ description: 'Number of pallets', example: 5 }) + @IsOptional() + @IsNumber() + @Min(0) + palletCount?: number; + + @ApiPropertyOptional({ description: 'Additional notes', example: 'Please 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 d62e544..28132ff 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -31,6 +31,7 @@ import { CsvBookingDocumentDto, CsvBookingListResponseDto, CsvBookingStatsDto, + UpdateCsvBookingDetailsDto, } from '../dto/csv-booking.dto'; import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto'; import { SubscriptionService } from './subscription.service'; @@ -1011,6 +1012,47 @@ export class CsvBookingService { return this.toResponseDto(updatedBooking); } + /** + * Update the cargo details of a booking before payment (user action). + * + * Only the owner can edit, and only while the booking is PENDING_PAYMENT. + */ + async updateBookingDetails( + id: string, + userId: string, + dto: UpdateCsvBookingDetailsDto + ): Promise { + this.logger.log(`Updating booking details ${id} by user ${userId}`); + + const booking = await this.csvBookingRepository.findById(id); + + if (!booking) { + throw new NotFoundException('Booking not found'); + } + + // Verify user owns this booking + if (booking.userId !== userId) { + throw new NotFoundException('Booking not found'); + } + + // Apply the changes (domain logic validates status + values) + try { + booking.editDetails({ + volumeCBM: dto.volumeCBM, + weightKG: dto.weightKG, + palletCount: dto.palletCount, + notes: dto.notes, + }); + } catch (err) { + throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking details'); + } + + const updatedBooking = await this.csvBookingRepository.update(booking); + this.logger.log(`Booking ${id} details 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 f75f6a2..02597d6 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.ts @@ -67,9 +67,10 @@ export class CsvBooking { public readonly carrierEmail: string, public readonly origin: PortCode, public readonly destination: PortCode, - public readonly volumeCBM: number, - public readonly weightKG: number, - public readonly palletCount: number, + // Cargo characteristics — editable before payment (see editDetails). + public volumeCBM: number, + public weightKG: number, + public palletCount: number, public readonly priceUSD: number, public readonly priceEUR: number, public readonly primaryCurrency: string, @@ -291,6 +292,53 @@ export class CsvBooking { this.respondedAt = new Date(); } + /** + * Edit the cargo details of a booking before it is paid. + * + * Only allowed while the booking is awaiting payment (PENDING_PAYMENT), i.e. + * before it is sent to the carrier. Carrier, route and price derive from the + * selected rate and are not editable here. + * + * @throws Error if the booking is not in PENDING_PAYMENT status or values are invalid + */ + editDetails(details: { + volumeCBM?: number; + weightKG?: number; + palletCount?: number; + 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 (details.volumeCBM !== undefined) { + if (details.volumeCBM <= 0) { + throw new Error('Volume must be positive'); + } + this.volumeCBM = details.volumeCBM; + } + + if (details.weightKG !== undefined) { + if (details.weightKG <= 0) { + throw new Error('Weight must be positive'); + } + this.weightKG = details.weightKG; + } + + if (details.palletCount !== undefined) { + if (details.palletCount < 0) { + throw new Error('Pallet count cannot be negative'); + } + this.palletCount = details.palletCount; + } + + if (details.notes !== undefined) { + this.notes = details.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 new file mode 100644 index 0000000..8769770 --- /dev/null +++ b/apps/frontend/app/[locale]/dashboard/booking/[id]/edit/page.tsx @@ -0,0 +1,416 @@ +/** + * 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), then + * continue to the payment page. Carrier, route and price derive from the selected + * rate and are shown read-only. + */ + +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import { + ArrowLeft, + Loader2, + AlertTriangle, + Save, + CreditCard, + FileText, + Trash2, + Upload, + Package, +} from 'lucide-react'; +import { getCsvBooking, updateCsvBookingDetails } from '@/lib/api/bookings'; +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; + status: string; + notes?: string; + documents: BookingDoc[]; +} + +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 [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 ?? ''); + }; + + 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]); + + 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, + })) 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'}

+ +
+
+ ); + } + + return ( +
+
+ + +

Modifier la réservation

+

+ Corrigez les informations de votre réservation avant de procéder au paiement. +

+ + {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, le trajet et le prix 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" + /> +
+
+
+ +