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()
@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;
}
/**

View File

@ -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);

View File

@ -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();
});
});

View File

@ -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;
}
}
/**

View File

@ -567,7 +567,7 @@ export default function AdminBlogPage() {
{loading ? (
<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">
<thead className="bg-gray-50">
<tr>

View File

@ -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<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);
@ -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<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> => {
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 (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
<div className="bg-white rounded-xl shadow-md p-8 max-w-md">
<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
@ -211,8 +333,10 @@ export default function EditBookingPage() {
);
}
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-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">
<button
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>
<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>
{error && (
@ -247,7 +372,7 @@ export default function EditBookingPage() {
<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">{booking.carrierName}</p>
<p className="font-semibold text-gray-900 break-words">{booking.carrierName}</p>
</div>
<div>
<p className="text-gray-500">Trajet</p>
@ -267,8 +392,8 @@ export default function EditBookingPage() {
)}
</div>
<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
modifiables ici.
Le transporteur et le trajet proviennent du tarif sélectionné et ne sont pas modifiables
ici.
</p>
</div>
@ -278,7 +403,7 @@ export default function EditBookingPage() {
<Package className="h-4 w-4" />
Caractéristiques de la marchandise
</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>
<label className="block text-sm font-medium text-gray-700 mb-1">Volume (CBM)</label>
<input
@ -325,9 +450,53 @@ export default function EditBookingPage() {
</div>
</div>
{/* Documents */}
{/* 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
@ -360,7 +529,7 @@ export default function EditBookingPage() {
{booking.documents.map((doc, idx) => (
<li
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">
<FileText className="h-4 w-4 text-gray-400 flex-shrink-0" />
@ -371,7 +540,7 @@ export default function EditBookingPage() {
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"
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors disabled:opacity-50 flex-shrink-0"
title="Supprimer"
>
{deletingDocId === doc.id ? (

View File

@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listCsvBookings } from '@/lib/api';
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 { useSearchParams } from 'next/navigation';
import { PageHeader } from '@/components/ui/PageHeader';
@ -26,8 +26,36 @@ export default function BookingsListPage() {
const [page, setPage] = useState(1);
const [showTransferBanner, setShowTransferBanner] = useState(false);
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;
// 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(() => {
if (searchParams.get('transfer') === 'declared') {
setShowTransferBanner(true);
@ -369,35 +397,16 @@ export default function BookingsListPage() {
})
: t('mobile.booking', { number: booking.bookingNumber || '-' })}
</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
onClick={() => 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"
onClick={e => toggleMenu(e, booking.id)}
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" />
{t('actions.view')}
<MoreVertical className="h-5 w-5" />
</button>
</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">
{t('columns.date')}
</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')}
</th>
</tr>
@ -491,34 +500,15 @@ export default function BookingsListPage() {
})
: 'N/A'}
</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)]">
<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>
</>
)}
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={() => 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"
onClick={e => 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')}
>
<Eye className="h-3.5 w-3.5" />
{t('actions.view')}
<MoreVertical className="h-5 w-5" />
</button>
</div>
</td>
</tr>
);
@ -672,6 +662,50 @@ export default function BookingsListPage() {
)}
</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 */}
{selectedBooking && (
<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 [openDropdownId, setOpenDropdownId] = useState<string | null>(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 */}
<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">
<thead className="bg-gray-50">
<tr>
@ -600,7 +615,7 @@ export default function UserDocumentsPage() {
<button
onClick={e => {
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() {
</svg>
</button>
{openDropdownId === `${doc.bookingId}-${doc.id}` && (
{openDropdownId === `${doc.bookingId}-${doc.id}` && dropdownPos && (
<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()}
>
<div className="py-1">

View File

@ -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(