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:
parent
25fe78ccfc
commit
6cdbf29bf7
@ -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<CsvBookingResponseDto> {
|
||||
const userId = req.user.id;
|
||||
return await this.csvBookingService.updateBookingRate(id, userId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add documents to an existing booking
|
||||
*
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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<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)
|
||||
*/
|
||||
|
||||
@ -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)
|
||||
*
|
||||
|
||||
@ -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 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<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>
|
||||
);
|
||||
}
|
||||
@ -227,7 +227,18 @@ export default function PayCommissionPage() {
|
||||
Finalisez votre booking en réglant le forfait par booking
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@ -30,6 +30,21 @@ export default function BookingsListPage() {
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null);
|
||||
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
|
||||
// clipped by the scrollable table container.
|
||||
const toggleMenu = (e: React.MouseEvent, bookingId: string) => {
|
||||
@ -677,7 +692,7 @@ export default function BookingsListPage() {
|
||||
{b.status === 'PENDING_PAYMENT' && (
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
<Pencil className="h-4 w-4 text-gray-500" />
|
||||
@ -806,7 +821,7 @@ export default function BookingsListPage() {
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Link
|
||||
href={`/dashboard/booking/${selectedBooking.id}/edit`}
|
||||
href={buildEditUrl(selectedBooking)}
|
||||
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"
|
||||
>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Search, Loader2 } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
@ -53,6 +54,8 @@ interface SearchForm {
|
||||
export default function AdvancedSearchPage() {
|
||||
const t = useTranslations('dashboard.rateSearch');
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [editBookingId, setEditBookingId] = useState<string | null>(null);
|
||||
const [searchForm, setSearchForm] = useState<SearchForm>({
|
||||
origin: '',
|
||||
destination: '',
|
||||
@ -137,6 +140,76 @@ export default function AdvancedSearchPage() {
|
||||
}
|
||||
}, [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 = () => {
|
||||
let totalVolumeCBM = 0;
|
||||
let totalWeightKG = 0;
|
||||
@ -172,6 +245,10 @@ export default function AdvancedSearchPage() {
|
||||
requiresAppointment: searchForm.appointment.toString(),
|
||||
});
|
||||
|
||||
if (editBookingId) {
|
||||
params.set('editBookingId', editBookingId);
|
||||
}
|
||||
|
||||
router.push(`/dashboard/search-advanced/results?${params.toString()}`);
|
||||
};
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import { useSearchParams } from 'next/navigation';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
|
||||
import { updateCsvBookingRate } from '@/lib/api/bookings';
|
||||
import type { CsvRateSearchResult } from '@/types/rates';
|
||||
import {
|
||||
Search,
|
||||
@ -38,6 +39,59 @@ export default function SearchResultsPage() {
|
||||
const volumeCBM = parseFloat(searchParams.get('volumeCBM') || '0');
|
||||
const weightKG = parseFloat(searchParams.get('weightKG') || '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 () => {
|
||||
setIsLoading(true);
|
||||
@ -260,6 +314,17 @@ export default function SearchResultsPage() {
|
||||
</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 */}
|
||||
{bestOptions && (
|
||||
<div className="mb-12">
|
||||
@ -323,15 +388,15 @@ export default function SearchResultsPage() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const rateData = encodeURIComponent(JSON.stringify(card.option));
|
||||
router.push(
|
||||
`/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`}
|
||||
onClick={() => handleSelect(card.option!, card.type)}
|
||||
disabled={submittingId === card.type}
|
||||
className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors disabled:opacity-60`}
|
||||
>
|
||||
{t('select')}
|
||||
{submittingId === card.type
|
||||
? '...'
|
||||
: editBookingId
|
||||
? t('selectAndPay')
|
||||
: t('select')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -416,15 +481,15 @@ export default function SearchResultsPage() {
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const rateData = encodeURIComponent(JSON.stringify(result));
|
||||
router.push(
|
||||
`/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"
|
||||
onClick={() => handleSelect(result, `all-${index}`)}
|
||||
disabled={submittingId === `all-${index}`}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{t('selectShort')}
|
||||
{submittingId === `all-${index}`
|
||||
? '...'
|
||||
: editBookingId
|
||||
? t('selectAndPay')
|
||||
: t('selectShort')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -619,7 +619,9 @@
|
||||
},
|
||||
"validUntil": "✓ Valid until {date}",
|
||||
"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": {
|
||||
|
||||
@ -619,7 +619,9 @@
|
||||
},
|
||||
"validUntil": "✓ Valide jusqu'au {date}",
|
||||
"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": {
|
||||
|
||||
@ -289,6 +289,37 @@ export async function updateCsvBookingDetails(
|
||||
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)
|
||||
* POST /api/v1/csv-bookings/:token/accept
|
||||
|
||||
Loading…
Reference in New Issue
Block a user