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'}
@@ -491,34 +500,15 @@ export default function BookingsListPage() {
})
: 'N/A'}
-
-
- {booking.status === 'PENDING_PAYMENT' && (
- <>
-
-
- {t('actions.edit')}
-
-
-
- {t('actions.pay')}
-
- >
- )}
- setSelectedBooking(booking)}
- className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
- >
-
- {t('actions.view')}
-
-
+ |
+ toggleMenu(e, booking.id)}
+ className="p-2 rounded-full text-gray-500 hover:bg-gray-100 transition-colors"
+ title={t('columns.actions')}
+ aria-label={t('columns.actions')}
+ >
+
+
|
);
@@ -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')}
+
+ >
+ )}
+
{
+ setOpenMenuId(null);
+ setSelectedBooking(b);
+ }}
+ className="flex items-center gap-2 w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
+ >
+
+ {t('actions.view')}
+
+
+ );
+ })()}
+
{/* 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 */}
-
+
@@ -600,7 +615,7 @@ export default function UserDocumentsPage() {
{
e.stopPropagation();
- toggleDropdown(`${doc.bookingId}-${doc.id}`);
+ toggleDropdown(`${doc.bookingId}-${doc.id}`, e);
}}
className="inline-flex items-center justify-center w-8 h-8 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title={t('table.actions')}
@@ -612,9 +627,10 @@ 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(