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 <noreply@anthropic.com>
This commit is contained in:
David 2026-07-01 18:56:25 +02:00
parent 8877265526
commit bce77edd76
9 changed files with 415 additions and 110 deletions

View File

@ -203,6 +203,50 @@ export class UpdateCsvBookingDetailsDto {
@IsString() @IsString()
@MaxLength(2000) @MaxLength(2000)
notes?: string; 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;
} }
/** /**

View File

@ -1035,6 +1035,16 @@ export class CsvBookingService {
throw new NotFoundException('Booking not found'); 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) // Apply the changes (domain logic validates status + values)
try { try {
booking.editDetails({ booking.editDetails({
@ -1042,6 +1052,17 @@ export class CsvBookingService {
weightKG: dto.weightKG, weightKG: dto.weightKG,
palletCount: dto.palletCount, palletCount: dto.palletCount,
notes: dto.notes, 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) { } catch (err) {
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking details'); 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`); throw new NotFoundException(`Booking with ID ${bookingId} not found`);
} }
// Verify booking is still pending or awaiting payment // Find the document (no status restriction — aligned with replaceDocument;
if ( // deleting down to zero documents is allowed)
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
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId); const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
if (documentIndex === -1) { if (documentIndex === -1) {
throw new NotFoundException(`Document with ID ${documentId} not found`); 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 // Remove document from array
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId); const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);

View File

@ -191,7 +191,9 @@ describe('CsvBooking Entity', () => {
}).toThrow('Invalid carrier email format'); }).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(() => { expect(() => {
new CsvBooking( new CsvBooking(
'booking-123', 'booking-123',
@ -214,7 +216,7 @@ describe('CsvBooking Entity', () => {
'token-abc123', 'token-abc123',
new Date() new Date()
); );
}).toThrow('At least one document is required for booking'); }).not.toThrow();
}); });
}); });

View File

@ -71,9 +71,10 @@ export class CsvBooking {
public volumeCBM: number, public volumeCBM: number,
public weightKG: number, public weightKG: number,
public palletCount: number, public palletCount: number,
public readonly priceUSD: number, // Pricing — recomputed when cargo details change before payment (see editDetails).
public readonly priceEUR: number, public priceUSD: number,
public readonly primaryCurrency: string, public priceEUR: number,
public primaryCurrency: string,
public readonly transitDays: number, public readonly transitDays: number,
public readonly containerType: string, public readonly containerType: string,
public status: CsvBookingStatus, public status: CsvBookingStatus,
@ -89,10 +90,10 @@ export class CsvBooking {
public stripePaymentIntentId?: string, public stripePaymentIntentId?: string,
// Detailed transport cost breakdown (informational — paid to the carrier, // Detailed transport cost breakdown (informational — paid to the carrier,
// not collected by Xpeditis). Freight and FOB may be in different currencies. // not collected by Xpeditis). Freight and FOB may be in different currencies.
public readonly freightTotal?: number, public freightTotal?: number,
public readonly freightCurrency?: string, public freightCurrency?: string,
public readonly fobTotal?: number, public fobTotal?: number,
public readonly fobCurrency?: string public fobCurrency?: string
) { ) {
this.validate(); this.validate();
} }
@ -151,9 +152,9 @@ export class CsvBooking {
throw new Error('Confirmation token is required'); throw new Error('Confirmation token is required');
} }
if (!this.documents || this.documents.length === 0) { // Note: at least one document is required *at creation* (enforced in the
throw new Error('At least one document is required for booking'); // 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; weightKG?: number;
palletCount?: number; palletCount?: number;
notes?: string; notes?: string;
pricing?: {
priceUSD?: number;
priceEUR?: number;
primaryCurrency?: string;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
};
}): void { }): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) { if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error( throw new Error(
@ -337,6 +347,24 @@ export class CsvBooking {
if (details.notes !== undefined) { if (details.notes !== undefined) {
this.notes = details.notes; 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;
}
} }
/** /**

View File

@ -567,7 +567,7 @@ export default function AdminBlogPage() {
{loading ? ( {loading ? (
<div className="text-center py-12 text-gray-500">Chargement des articles...</div> <div className="text-center py-12 text-gray-500">Chargement des articles...</div>
) : ( ) : (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-4"> <div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-x-auto mt-4">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>

View File

@ -2,9 +2,9 @@
* Edit Booking Page (before payment) * Edit Booking Page (before payment)
* *
* Lets the owner adjust cargo details (volume, weight, pallets, notes) and manage * Lets the owner adjust cargo details (volume, weight, pallets, notes) and manage
* documents of a booking that is still awaiting payment (PENDING_PAYMENT), then * documents of a booking that is still awaiting payment (PENDING_PAYMENT). When the
* continue to the payment page. Carrier, route and price derive from the selected * cargo changes, the price is recomputed by re-quoting the same rate (carrier +
* rate and are shown read-only. * container + transit) for the new volume/weight. Then they can continue to payment.
*/ */
'use client'; 'use client';
@ -21,8 +21,10 @@ import {
Trash2, Trash2,
Upload, Upload,
Package, Package,
RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { getCsvBooking, updateCsvBookingDetails } from '@/lib/api/bookings'; import { getCsvBooking, updateCsvBookingDetails } from '@/lib/api/bookings';
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
import { upload, del } from '@/lib/api/client'; import { upload, del } from '@/lib/api/client';
interface BookingDoc { interface BookingDoc {
@ -42,11 +44,33 @@ interface BookingData {
weightKG: number; weightKG: number;
palletCount: number; palletCount: number;
containerType: string; containerType: string;
transitDays: number;
status: string; status: string;
notes?: string; notes?: string;
primaryCurrency?: string;
priceUSD?: number;
priceEUR?: number;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
documents: BookingDoc[]; 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() { export default function EditBookingPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
@ -63,6 +87,10 @@ export default function EditBookingPage() {
const [palletCount, setPalletCount] = useState(''); const [palletCount, setPalletCount] = useState('');
const [notes, setNotes] = 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 [uploadingDocs, setUploadingDocs] = useState(false);
const [deletingDocId, setDeletingDocId] = useState<string | null>(null); const [deletingDocId, setDeletingDocId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -73,6 +101,15 @@ export default function EditBookingPage() {
setWeightKG(String(data.weightKG ?? '')); setWeightKG(String(data.weightKG ?? ''));
setPalletCount(String(data.palletCount ?? '')); setPalletCount(String(data.palletCount ?? ''));
setNotes(data.notes ?? ''); 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 () => { const loadBooking = async () => {
@ -96,6 +133,80 @@ export default function EditBookingPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookingId]); }, [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 || [];
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 dorigine.'
);
}
} 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> => { const persistDetails = async (): Promise<boolean> => {
setError(null); setError(null);
const vol = parseFloat(volumeCBM); const vol = parseFloat(volumeCBM);
@ -120,6 +231,17 @@ export default function EditBookingPage() {
weightKG: wgt, weightKG: wgt,
palletCount: plt, palletCount: plt,
notes: notes.trim() || undefined, 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; })) as any as BookingData;
applyBooking(updated); applyBooking(updated);
return true; return true;
@ -196,8 +318,8 @@ export default function EditBookingPage() {
if (!booking) { if (!booking) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50"> <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"> <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" /> <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> <p className="text-center text-gray-700">{error || 'Réservation introuvable'}</p>
<button <button
@ -211,8 +333,10 @@ export default function EditBookingPage() {
); );
} }
const transportTotalUSD = (pricing?.priceUSD ?? 0) + (pricing?.priceEUR ?? 0);
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4"> <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"> <div className="max-w-4xl mx-auto">
<button <button
onClick={() => router.back()} onClick={() => router.back()}
@ -224,7 +348,8 @@ export default function EditBookingPage() {
<h1 className="text-2xl font-bold text-gray-900 mb-1">Modifier la réservation</h1> <h1 className="text-2xl font-bold text-gray-900 mb-1">Modifier la réservation</h1>
<p className="text-gray-500 mb-8"> <p className="text-gray-500 mb-8">
Corrigez les informations de votre réservation avant de procéder au paiement. Corrigez les informations de votre réservation avant de procéder au paiement. Le prix est
recalculé automatiquement.
</p> </p>
{error && ( {error && (
@ -247,7 +372,7 @@ export default function EditBookingPage() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div> <div>
<p className="text-gray-500">Transporteur</p> <p className="text-gray-500">Transporteur</p>
<p className="font-semibold text-gray-900">{booking.carrierName}</p> <p className="font-semibold text-gray-900 break-words">{booking.carrierName}</p>
</div> </div>
<div> <div>
<p className="text-gray-500">Trajet</p> <p className="text-gray-500">Trajet</p>
@ -267,8 +392,8 @@ export default function EditBookingPage() {
)} )}
</div> </div>
<p className="text-xs text-gray-400 mt-3"> <p className="text-xs text-gray-400 mt-3">
Le transporteur, le trajet et le prix proviennent du tarif sélectionné et ne sont pas Le transporteur et le trajet proviennent du tarif sélectionné et ne sont pas modifiables
modifiables ici. ici.
</p> </p>
</div> </div>
@ -278,7 +403,7 @@ export default function EditBookingPage() {
<Package className="h-4 w-4" /> <Package className="h-4 w-4" />
Caractéristiques de la marchandise Caractéristiques de la marchandise
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Volume (CBM)</label> <label className="block text-sm font-medium text-gray-700 mb-1">Volume (CBM)</label>
<input <input
@ -325,9 +450,53 @@ export default function EditBookingPage() {
</div> </div>
</div> </div>
{/* Documents */} {/* Price summary (recomputed) */}
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6"> <div className="bg-white rounded-xl border border-gray-200 p-5 mb-6">
<div className="flex items-center justify-between mb-3"> <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"> <h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide flex items-center gap-2">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
Documents Documents
@ -360,7 +529,7 @@ export default function EditBookingPage() {
{booking.documents.map((doc, idx) => ( {booking.documents.map((doc, idx) => (
<li <li
key={doc.id || `${doc.fileName}-${idx}`} key={doc.id || `${doc.fileName}-${idx}`}
className="flex items-center justify-between py-2.5" className="flex items-center justify-between py-2.5 gap-3"
> >
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<FileText className="h-4 w-4 text-gray-400 flex-shrink-0" /> <FileText className="h-4 w-4 text-gray-400 flex-shrink-0" />
@ -371,7 +540,7 @@ export default function EditBookingPage() {
type="button" type="button"
onClick={() => handleDeleteDocument(doc.id)} onClick={() => handleDeleteDocument(doc.id)}
disabled={deletingDocId === doc.id} disabled={deletingDocId === doc.id}
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors disabled:opacity-50" className="p-1.5 text-gray-400 hover:text-red-600 transition-colors disabled:opacity-50 flex-shrink-0"
title="Supprimer" title="Supprimer"
> >
{deletingDocId === doc.id ? ( {deletingDocId === doc.id ? (

View File

@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { listCsvBookings } from '@/lib/api'; import { listCsvBookings } from '@/lib/api';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { Plus, Clock, Eye, X, CreditCard, Pencil } from 'lucide-react'; import { Plus, Clock, Eye, X, CreditCard, Pencil, MoreVertical } from 'lucide-react';
import ExportButton from '@/components/ExportButton'; import ExportButton from '@/components/ExportButton';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
@ -26,8 +26,36 @@ export default function BookingsListPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [showTransferBanner, setShowTransferBanner] = useState(false); const [showTransferBanner, setShowTransferBanner] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<any | null>(null); const [selectedBooking, setSelectedBooking] = useState<any | null>(null);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null);
const ITEMS_PER_PAGE = 20; const ITEMS_PER_PAGE = 20;
// Kebab (⋮) actions menu — anchored with fixed positioning so it is never
// clipped by the scrollable table container.
const toggleMenu = (e: React.MouseEvent, bookingId: string) => {
e.stopPropagation();
if (openMenuId === bookingId) {
setOpenMenuId(null);
return;
}
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setMenuPos({ top: rect.bottom + 6, right: window.innerWidth - rect.right });
setOpenMenuId(bookingId);
};
useEffect(() => {
if (!openMenuId) return;
const close = () => setOpenMenuId(null);
window.addEventListener('click', close);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
};
}, [openMenuId]);
useEffect(() => { useEffect(() => {
if (searchParams.get('transfer') === 'declared') { if (searchParams.get('transfer') === 'declared') {
setShowTransferBanner(true); setShowTransferBanner(true);
@ -369,35 +397,16 @@ export default function BookingsListPage() {
}) })
: t('mobile.booking', { number: booking.bookingNumber || '-' })} : t('mobile.booking', { number: booking.bookingNumber || '-' })}
</div> </div>
<div className="flex items-center gap-2">
{booking.status === 'PENDING_PAYMENT' && (
<>
<Link
href={`/dashboard/booking/${booking.id}/edit`}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
{t('actions.edit')}
</Link>
<Link
href={`/dashboard/booking/${booking.id}/pay`}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
>
<CreditCard className="h-3.5 w-3.5" />
{t('actions.pay')}
</Link>
</>
)}
<button <button
onClick={() => setSelectedBooking(booking)} onClick={e => toggleMenu(e, booking.id)}
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" className="p-2 -mr-1 rounded-full text-gray-500 hover:bg-gray-100 transition-colors"
title={t('columns.actions')}
aria-label={t('columns.actions')}
> >
<Eye className="h-3.5 w-3.5" /> <MoreVertical className="h-5 w-5" />
{t('actions.view')}
</button> </button>
</div> </div>
</div> </div>
</div>
))} ))}
</div> </div>
@ -420,7 +429,7 @@ export default function BookingsListPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.date')} {t('columns.date')}
</th> </th>
<th className="sticky right-0 z-10 bg-gray-50 px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.actions')} {t('columns.actions')}
</th> </th>
</tr> </tr>
@ -491,34 +500,15 @@ export default function BookingsListPage() {
}) })
: 'N/A'} : 'N/A'}
</td> </td>
<td className="sticky right-0 z-10 bg-white group-hover:bg-gray-50 px-6 py-4 whitespace-nowrap text-right shadow-[-8px_0_8px_-8px_rgba(0,0,0,0.08)]"> <td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
{booking.status === 'PENDING_PAYMENT' && (
<>
<Link
href={`/dashboard/booking/${booking.id}/edit`}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
{t('actions.edit')}
</Link>
<Link
href={`/dashboard/booking/${booking.id}/pay`}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
>
<CreditCard className="h-3.5 w-3.5" />
{t('actions.pay')}
</Link>
</>
)}
<button <button
onClick={() => setSelectedBooking(booking)} onClick={e => toggleMenu(e, booking.id)}
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" className="p-2 rounded-full text-gray-500 hover:bg-gray-100 transition-colors"
title={t('columns.actions')}
aria-label={t('columns.actions')}
> >
<Eye className="h-3.5 w-3.5" /> <MoreVertical className="h-5 w-5" />
{t('actions.view')}
</button> </button>
</div>
</td> </td>
</tr> </tr>
); );
@ -672,6 +662,50 @@ export default function BookingsListPage() {
)} )}
</div> </div>
{/* Actions menu (⋮) — fixed-positioned to avoid clipping */}
{openMenuId &&
menuPos &&
(() => {
const b = paginatedBookings.find((x: any) => x.id === openMenuId);
if (!b) return null;
return (
<div
className="fixed z-50 w-44 bg-white rounded-lg shadow-lg border border-gray-200 py-1"
style={{ top: menuPos.top, right: menuPos.right }}
onClick={e => e.stopPropagation()}
>
{b.status === 'PENDING_PAYMENT' && (
<>
<Link
href={`/dashboard/booking/${b.id}/edit`}
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" />
{t('actions.edit')}
</Link>
<Link
href={`/dashboard/booking/${b.id}/pay`}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<CreditCard className="h-4 w-4 text-orange-500" />
{t('actions.pay')}
</Link>
</>
)}
<button
onClick={() => {
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"
>
<Eye className="h-4 w-4 text-blue-600" />
{t('actions.view')}
</button>
</div>
);
})()}
{/* Booking Detail Modal */} {/* Booking Detail Modal */}
{selectedBooking && ( {selectedBooking && (
<div className="fixed inset-0 z-50 overflow-y-auto"> <div className="fixed inset-0 z-50 overflow-y-auto">

View File

@ -68,6 +68,7 @@ export default function UserDocumentsPage() {
const [deletingFile, setDeletingFile] = useState(false); const [deletingFile, setDeletingFile] = useState(false);
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null); const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number } | null>(null);
const getQuoteNumber = (booking: CsvBookingResponse): string => { const getQuoteNumber = (booking: CsvBookingResponse): string => {
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`; return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
@ -309,15 +310,29 @@ export default function UserDocumentsPage() {
} }
}; };
const toggleDropdown = (docId: string) => { const toggleDropdown = (docId: string, e: React.MouseEvent) => {
setOpenDropdownId(openDropdownId === docId ? null : docId); 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(() => { useEffect(() => {
const handleClickOutside = () => setOpenDropdownId(null); const handleClickOutside = () => setOpenDropdownId(null);
if (openDropdownId) { if (openDropdownId) {
document.addEventListener('click', handleClickOutside); 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]); }, [openDropdownId]);
@ -533,7 +548,7 @@ export default function UserDocumentsPage() {
)} )}
{/* Documents Table */} {/* Documents Table */}
<div className="bg-white rounded-lg shadow overflow-hidden"> <div className="bg-white rounded-lg shadow overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
@ -600,7 +615,7 @@ export default function UserDocumentsPage() {
<button <button
onClick={e => { onClick={e => {
e.stopPropagation(); 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" 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')} title={t('table.actions')}
@ -612,9 +627,10 @@ export default function UserDocumentsPage() {
</svg> </svg>
</button> </button>
{openDropdownId === `${doc.bookingId}-${doc.id}` && ( {openDropdownId === `${doc.bookingId}-${doc.id}` && dropdownPos && (
<div <div
className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50" className="fixed w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"
style={{ top: dropdownPos.top, right: dropdownPos.right }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<div className="py-1"> <div className="py-1">

View File

@ -272,6 +272,14 @@ export interface UpdateCsvBookingDetailsRequest {
weightKG?: number; weightKG?: number;
palletCount?: number; palletCount?: number;
notes?: string; 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( export async function updateCsvBookingDetails(