From bce77edd76f982801071f410f981c2b0dea9c406 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 1 Jul 2026 18:56:25 +0200 Subject: [PATCH] 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 --- .../src/application/dto/csv-booking.dto.ts | 44 ++++ .../services/csv-booking.service.ts | 42 ++-- .../entities/csv-booking.entity.spec.ts | 6 +- .../src/domain/entities/csv-booking.entity.ts | 48 ++++- .../frontend/app/[locale]/admin/blog/page.tsx | 2 +- .../dashboard/booking/[id]/edit/page.tsx | 197 ++++++++++++++++-- .../app/[locale]/dashboard/bookings/page.tsx | 148 ++++++++----- .../app/[locale]/dashboard/documents/page.tsx | 30 ++- apps/frontend/src/lib/api/bookings.ts | 8 + 9 files changed, 415 insertions(+), 110 deletions(-) diff --git a/apps/backend/src/application/dto/csv-booking.dto.ts b/apps/backend/src/application/dto/csv-booking.dto.ts index a5ac3b6..5d4137d 100644 --- a/apps/backend/src/application/dto/csv-booking.dto.ts +++ b/apps/backend/src/application/dto/csv-booking.dto.ts @@ -203,6 +203,50 @@ export class UpdateCsvBookingDetailsDto { @IsString() @MaxLength(2000) notes?: string; + + // Recomputed pricing (sent when cargo details change). Optional; when omitted + // the price is left unchanged. + @ApiPropertyOptional({ description: 'Recomputed total price in USD' }) + @IsOptional() + @IsNumber() + @Min(0) + priceUSD?: number; + + @ApiPropertyOptional({ description: 'Recomputed total price in EUR' }) + @IsOptional() + @IsNumber() + @Min(0) + priceEUR?: number; + + @ApiPropertyOptional({ description: 'Primary currency (USD/EUR)' }) + @IsOptional() + @IsString() + @MaxLength(3) + primaryCurrency?: string; + + @ApiPropertyOptional({ description: 'Recomputed freight total' }) + @IsOptional() + @IsNumber() + @Min(0) + freightTotal?: number; + + @ApiPropertyOptional({ description: 'Freight currency' }) + @IsOptional() + @IsString() + @MaxLength(3) + freightCurrency?: string; + + @ApiPropertyOptional({ description: 'Recomputed FOB total' }) + @IsOptional() + @IsNumber() + @Min(0) + fobTotal?: number; + + @ApiPropertyOptional({ description: 'FOB currency' }) + @IsOptional() + @IsString() + @MaxLength(3) + fobCurrency?: string; } /** diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index 28132ff..84e4805 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -1035,6 +1035,16 @@ export class CsvBookingService { throw new NotFoundException('Booking not found'); } + // Detect whether any pricing field was provided + const hasPricing = + dto.priceUSD !== undefined || + dto.priceEUR !== undefined || + dto.primaryCurrency !== undefined || + dto.freightTotal !== undefined || + dto.freightCurrency !== undefined || + dto.fobTotal !== undefined || + dto.fobCurrency !== undefined; + // Apply the changes (domain logic validates status + values) try { booking.editDetails({ @@ -1042,6 +1052,17 @@ export class CsvBookingService { weightKG: dto.weightKG, palletCount: dto.palletCount, notes: dto.notes, + pricing: hasPricing + ? { + priceUSD: dto.priceUSD, + priceEUR: dto.priceEUR, + primaryCurrency: dto.primaryCurrency, + freightTotal: dto.freightTotal, + freightCurrency: dto.freightCurrency, + fobTotal: dto.fobTotal, + fobCurrency: dto.fobCurrency, + } + : undefined, }); } catch (err) { throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking details'); @@ -1309,31 +1330,14 @@ export class CsvBookingService { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } - // Verify booking is still pending or awaiting payment - if ( - booking.status !== CsvBookingStatus.PENDING_PAYMENT && - booking.status !== CsvBookingStatus.PENDING - ) { - throw new BadRequestException('Cannot delete documents from a booking that is not pending'); - } - - // Find the document + // Find the document (no status restriction — aligned with replaceDocument; + // deleting down to zero documents is allowed) const documentIndex = booking.documents.findIndex(doc => doc.id === documentId); if (documentIndex === -1) { throw new NotFoundException(`Document with ID ${documentId} not found`); } - // Ensure at least one document remains - if (booking.documents.length <= 1) { - throw new BadRequestException( - 'Cannot delete the last document. At least one document is required.' - ); - } - - // Get the document to delete (for potential S3 cleanup - currently kept for audit) - const _documentToDelete = booking.documents[documentIndex]; - // Remove document from array const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId); diff --git a/apps/backend/src/domain/entities/csv-booking.entity.spec.ts b/apps/backend/src/domain/entities/csv-booking.entity.spec.ts index 26e6a93..01edc3a 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.spec.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.spec.ts @@ -191,7 +191,9 @@ describe('CsvBooking Entity', () => { }).toThrow('Invalid carrier email format'); }); - it('should throw error if no documents provided', () => { + it('should allow reconstituting a booking with no documents (all deleted before payment)', () => { + // A booking may end up with zero documents after the owner deletes them. + // Reconstitution must not fail (creation still requires >=1 doc at the service level). expect(() => { new CsvBooking( 'booking-123', @@ -214,7 +216,7 @@ describe('CsvBooking Entity', () => { 'token-abc123', new Date() ); - }).toThrow('At least one document is required for booking'); + }).not.toThrow(); }); }); diff --git a/apps/backend/src/domain/entities/csv-booking.entity.ts b/apps/backend/src/domain/entities/csv-booking.entity.ts index 02597d6..d37b6f4 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.ts @@ -71,9 +71,10 @@ export class CsvBooking { public volumeCBM: number, public weightKG: number, public palletCount: number, - public readonly priceUSD: number, - public readonly priceEUR: number, - public readonly primaryCurrency: string, + // Pricing — recomputed when cargo details change before payment (see editDetails). + public priceUSD: number, + public priceEUR: number, + public primaryCurrency: string, public readonly transitDays: number, public readonly containerType: string, public status: CsvBookingStatus, @@ -89,10 +90,10 @@ export class CsvBooking { public stripePaymentIntentId?: string, // Detailed transport cost breakdown (informational — paid to the carrier, // not collected by Xpeditis). Freight and FOB may be in different currencies. - public readonly freightTotal?: number, - public readonly freightCurrency?: string, - public readonly fobTotal?: number, - public readonly fobCurrency?: string + public freightTotal?: number, + public freightCurrency?: string, + public fobTotal?: number, + public fobCurrency?: string ) { this.validate(); } @@ -151,9 +152,9 @@ export class CsvBooking { throw new Error('Confirmation token is required'); } - if (!this.documents || this.documents.length === 0) { - throw new Error('At least one document is required for booking'); - } + // Note: at least one document is required *at creation* (enforced in the + // create flow). A booking may end up with zero documents after the owner + // deletes them, so reconstitution must not fail here. } /** @@ -306,6 +307,15 @@ export class CsvBooking { weightKG?: number; palletCount?: number; notes?: string; + pricing?: { + priceUSD?: number; + priceEUR?: number; + primaryCurrency?: string; + freightTotal?: number; + freightCurrency?: string; + fobTotal?: number; + fobCurrency?: string; + }; }): void { if (this.status !== CsvBookingStatus.PENDING_PAYMENT) { throw new Error( @@ -337,6 +347,24 @@ export class CsvBooking { if (details.notes !== undefined) { this.notes = details.notes; } + + // Pricing is recomputed from the current rate when cargo details change. + const p = details.pricing; + if (p) { + if (p.priceUSD !== undefined) { + if (p.priceUSD < 0) throw new Error('Price cannot be negative'); + this.priceUSD = p.priceUSD; + } + if (p.priceEUR !== undefined) { + if (p.priceEUR < 0) throw new Error('Price cannot be negative'); + this.priceEUR = p.priceEUR; + } + if (p.primaryCurrency !== undefined) this.primaryCurrency = p.primaryCurrency; + if (p.freightTotal !== undefined) this.freightTotal = p.freightTotal; + if (p.freightCurrency !== undefined) this.freightCurrency = p.freightCurrency; + if (p.fobTotal !== undefined) this.fobTotal = p.fobTotal; + if (p.fobCurrency !== undefined) this.fobCurrency = p.fobCurrency; + } } /** diff --git a/apps/frontend/app/[locale]/admin/blog/page.tsx b/apps/frontend/app/[locale]/admin/blog/page.tsx index fab44c0..7973ffc 100644 --- a/apps/frontend/app/[locale]/admin/blog/page.tsx +++ b/apps/frontend/app/[locale]/admin/blog/page.tsx @@ -567,7 +567,7 @@ export default function AdminBlogPage() { {loading ? (
Chargement des articles...
) : ( -
+
diff --git a/apps/frontend/app/[locale]/dashboard/booking/[id]/edit/page.tsx b/apps/frontend/app/[locale]/dashboard/booking/[id]/edit/page.tsx index 8769770..8a7ec76 100644 --- a/apps/frontend/app/[locale]/dashboard/booking/[id]/edit/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/booking/[id]/edit/page.tsx @@ -2,9 +2,9 @@ * 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. + * 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'; @@ -21,8 +21,10 @@ import { 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 { @@ -42,11 +44,33 @@ interface BookingData { 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(); @@ -63,6 +87,10 @@ export default function EditBookingPage() { 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); @@ -73,6 +101,15 @@ export default function EditBookingPage() { 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 () => { @@ -96,6 +133,80 @@ export default function EditBookingPage() { // 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 || []; + 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 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); @@ -120,6 +231,17 @@ export default function EditBookingPage() { 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; @@ -196,8 +318,8 @@ export default function EditBookingPage() { if (!booking) { return ( -
-
+
+

{error || 'Réservation introuvable'}

-
+
))} @@ -420,7 +429,7 @@ export default function BookingsListPage() {
- @@ -491,34 +500,15 @@ export default function BookingsListPage() { }) : 'N/A'} - ); @@ -672,6 +662,50 @@ export default function BookingsListPage() { )} + {/* Actions menu (⋮) — fixed-positioned to avoid clipping */} + {openMenuId && + menuPos && + (() => { + const b = paginatedBookings.find((x: any) => x.id === openMenuId); + if (!b) return null; + return ( +
e.stopPropagation()} + > + {b.status === 'PENDING_PAYMENT' && ( + <> + + + {t('actions.edit')} + + + + {t('actions.pay')} + + + )} + +
+ ); + })()} + {/* Booking Detail Modal */} {selectedBooking && (
diff --git a/apps/frontend/app/[locale]/dashboard/documents/page.tsx b/apps/frontend/app/[locale]/dashboard/documents/page.tsx index 2b1a13c..d6888e3 100644 --- a/apps/frontend/app/[locale]/dashboard/documents/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/documents/page.tsx @@ -68,6 +68,7 @@ export default function UserDocumentsPage() { const [deletingFile, setDeletingFile] = useState(false); const [openDropdownId, setOpenDropdownId] = useState(null); + const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number } | null>(null); const getQuoteNumber = (booking: CsvBookingResponse): string => { return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`; @@ -309,15 +310,29 @@ export default function UserDocumentsPage() { } }; - const toggleDropdown = (docId: string) => { - setOpenDropdownId(openDropdownId === docId ? null : docId); + const toggleDropdown = (docId: string, e: React.MouseEvent) => { + if (openDropdownId === docId) { + setOpenDropdownId(null); + return; + } + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + // Anchor the menu with fixed positioning so it is never clipped by the + // horizontally scrollable table container. + setDropdownPos({ top: rect.bottom + 6, right: window.innerWidth - rect.right }); + setOpenDropdownId(docId); }; useEffect(() => { const handleClickOutside = () => setOpenDropdownId(null); if (openDropdownId) { document.addEventListener('click', handleClickOutside); - return () => document.removeEventListener('click', handleClickOutside); + window.addEventListener('scroll', handleClickOutside, true); + window.addEventListener('resize', handleClickOutside); + return () => { + document.removeEventListener('click', handleClickOutside); + window.removeEventListener('scroll', handleClickOutside, true); + window.removeEventListener('resize', handleClickOutside); + }; } }, [openDropdownId]); @@ -533,7 +548,7 @@ export default function UserDocumentsPage() { )} {/* Documents Table */} -
+
{t('columns.date')} + {t('columns.actions')}
-
- {booking.status === 'PENDING_PAYMENT' && ( - <> - - - {t('actions.edit')} - - - - {t('actions.pay')} - - - )} - -
+
+
@@ -600,7 +615,7 @@ export default function UserDocumentsPage() { - {openDropdownId === `${doc.bookingId}-${doc.id}` && ( + {openDropdownId === `${doc.bookingId}-${doc.id}` && dropdownPos && (
e.stopPropagation()} >
diff --git a/apps/frontend/src/lib/api/bookings.ts b/apps/frontend/src/lib/api/bookings.ts index d594d96..212c8c1 100644 --- a/apps/frontend/src/lib/api/bookings.ts +++ b/apps/frontend/src/lib/api/bookings.ts @@ -272,6 +272,14 @@ export interface UpdateCsvBookingDetailsRequest { weightKG?: number; palletCount?: number; notes?: string; + // Recomputed pricing (optional; sent when cargo details change) + priceUSD?: number; + priceEUR?: number; + primaryCurrency?: string; + freightTotal?: number; + freightCurrency?: string; + fobTotal?: number; + fobCurrency?: string; } export async function updateCsvBookingDetails(