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:
parent
8877265526
commit
bce77edd76
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 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> => {
|
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 ? (
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user