Le statut PENDING_PAYMENT ne designe plus un paiement en attente mais un devis : une reservation construite dont les frais de booking ne sont pas encore regles. L'enum backend devient QUOTE, converti en place par migration. Les deux onglets se partagent desormais la meme liste : - « Reservations » n'affiche que PENDING, ACCEPTED et REJECTED ; - « Historique des devis » regroupe QUOTE, PENDING_BANK_TRANSFER et CANCELLED, avec les actions propres a un devis (modifier, payer, supprimer). La table, l'export et les filtres sont extraits dans BookingListView pour eviter de dupliquer la page entre les deux vues. Tableau de bord : - l'alerte « Paiement en attente » disparait des points a traiter, les devis non regles ne bloquant aucun envoi en cours ; - « Sans reponse du transporteur » devient « En attente du transporteur ». Migration verifiee en transaction annulee sur la base de dev : les lignes PENDING_PAYMENT deviennent QUOTE, l'enum et le defaut de colonne suivent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZJ5aowZJVAcyPZw5TiNEm
738 lines
31 KiB
TypeScript
738 lines
31 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useTranslations, useLocale } from 'next-intl';
|
|
import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin';
|
|
import { useConfirm } from '@/components/ui/use-confirm';
|
|
|
|
interface Booking {
|
|
id: string;
|
|
bookingNumber?: string | null;
|
|
type?: string;
|
|
status: string;
|
|
origin?: string;
|
|
destination?: string;
|
|
carrierName?: string;
|
|
containerType: string;
|
|
volumeCBM?: number;
|
|
weightKG?: number;
|
|
palletCount?: number;
|
|
priceEUR?: number;
|
|
priceUSD?: number;
|
|
primaryCurrency?: string;
|
|
freightTotal?: number;
|
|
freightCurrency?: string;
|
|
fobTotal?: number;
|
|
fobCurrency?: string;
|
|
commissionAmountEur?: number;
|
|
createdAt?: string;
|
|
requestedAt?: string;
|
|
updatedAt?: string;
|
|
organizationId?: string;
|
|
userId?: string;
|
|
}
|
|
|
|
export default function AdminBookingsPage() {
|
|
const confirm = useConfirm();
|
|
const t = useTranslations('dashboard.admin.bookings');
|
|
const locale = useLocale();
|
|
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
|
|
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [filterStatus, setFilterStatus] = useState('all');
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [validatingId, setValidatingId] = useState<string | null>(null);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
|
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
|
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
|
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchBookings();
|
|
}, []);
|
|
|
|
const handleDeleteBooking = async (bookingId: string) => {
|
|
if (!(await confirm({ title: t('confirmDelete'), destructive: true }))) return;
|
|
setDeletingId(bookingId);
|
|
try {
|
|
await deleteAdminBooking(bookingId);
|
|
setBookings(prev => prev.filter(b => b.id !== bookingId));
|
|
} catch (err: any) {
|
|
setError(err.message || t('deleteError'));
|
|
} finally {
|
|
setDeletingId(null);
|
|
}
|
|
};
|
|
|
|
const handleValidateTransfer = async (bookingId: string) => {
|
|
if (!(await confirm({ title: t('confirmValidate') }))) return;
|
|
setValidatingId(bookingId);
|
|
try {
|
|
await validateBankTransfer(bookingId);
|
|
await fetchBookings();
|
|
} catch (err: any) {
|
|
setError(err.message || t('validateError'));
|
|
} finally {
|
|
setValidatingId(null);
|
|
}
|
|
};
|
|
|
|
const fetchBookings = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await getAllBookings();
|
|
setBookings(response.bookings || []);
|
|
setError(null);
|
|
} catch (err: any) {
|
|
setError(err.message || t('loadError'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
const colors: Record<string, string> = {
|
|
quote: 'bg-neutral-100 text-neutral-800',
|
|
pending_bank_transfer: 'bg-amber-100 text-amber-900',
|
|
pending: 'bg-yellow-100 text-yellow-800',
|
|
accepted: 'bg-green-100 text-green-800',
|
|
rejected: 'bg-red-100 text-red-800',
|
|
cancelled: 'bg-red-100 text-red-800',
|
|
};
|
|
return colors[status.toLowerCase()] || 'bg-neutral-100 text-neutral-800';
|
|
};
|
|
|
|
const getStatusLabel = (status: string) => {
|
|
const key = status.toUpperCase();
|
|
const allowed = [
|
|
'QUOTE',
|
|
'PENDING_BANK_TRANSFER',
|
|
'PENDING',
|
|
'ACCEPTED',
|
|
'REJECTED',
|
|
'CANCELLED',
|
|
];
|
|
if (allowed.includes(key)) {
|
|
return t(`status.${key}` as any);
|
|
}
|
|
return status;
|
|
};
|
|
|
|
const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`;
|
|
|
|
const formatMoney = (amount: number, currency: string) =>
|
|
new Intl.NumberFormat(dateLocale, { style: 'currency', currency }).format(amount);
|
|
|
|
const filteredBookings = bookings
|
|
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
|
|
.filter(booking => {
|
|
if (searchTerm === '') return true;
|
|
const s = searchTerm.toLowerCase();
|
|
return (
|
|
booking.bookingNumber?.toLowerCase().includes(s) ||
|
|
booking.id.toLowerCase().includes(s) ||
|
|
booking.carrierName?.toLowerCase().includes(s) ||
|
|
booking.origin?.toLowerCase().includes(s) ||
|
|
booking.destination?.toLowerCase().includes(s) ||
|
|
String(booking.palletCount || '').includes(s) ||
|
|
String(booking.weightKG || '').includes(s) ||
|
|
String(booking.volumeCBM || '').includes(s) ||
|
|
booking.containerType?.toLowerCase().includes(s)
|
|
);
|
|
});
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-96">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto"></div>
|
|
<p className="mt-4 text-neutral-600">{t('loading')}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-brand-navy">{t('title')}</h1>
|
|
<p className="mt-1 text-sm text-neutral-500">{t('subtitle')}</p>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
|
<div className="bg-white rounded-lg shadow-sm border border-border p-4">
|
|
<div className="text-xs text-neutral-500 uppercase tracking-wide">{t('stats.total')}</div>
|
|
<div className="text-2xl font-bold text-brand-navy mt-1">{bookings.length}</div>
|
|
</div>
|
|
<div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4">
|
|
<div className="text-xs text-amber-700 uppercase tracking-wide">
|
|
{t('stats.pendingBankTransfer')}
|
|
</div>
|
|
<div className="text-2xl font-bold text-amber-700 mt-1">
|
|
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow-sm border border-border p-4">
|
|
<div className="text-xs text-neutral-500 uppercase tracking-wide">
|
|
{t('stats.pendingCarrier')}
|
|
</div>
|
|
<div className="text-2xl font-bold text-yellow-600 mt-1">
|
|
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow-sm border border-border p-4">
|
|
<div className="text-xs text-neutral-500 uppercase tracking-wide">{t('stats.accepted')}</div>
|
|
<div className="text-2xl font-bold text-green-600 mt-1">
|
|
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow-sm border border-border p-4">
|
|
<div className="text-xs text-neutral-500 uppercase tracking-wide">{t('stats.rejected')}</div>
|
|
<div className="text-2xl font-bold text-red-600 mt-1">
|
|
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white rounded-lg shadow-sm border border-border p-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
|
{t('search.label')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder={t('search.placeholder')}
|
|
value={searchTerm}
|
|
onChange={e => setSearchTerm(e.target.value)}
|
|
className="block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
|
{t('filter.label')}
|
|
</label>
|
|
<select
|
|
value={filterStatus}
|
|
onChange={e => setFilterStatus(e.target.value)}
|
|
className="block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none text-sm"
|
|
>
|
|
<option value="all">{t('filter.all')}</option>
|
|
<option value="pending_bank_transfer">{t('status.PENDING_BANK_TRANSFER')}</option>
|
|
<option value="quote">{t('status.QUOTE')}</option>
|
|
<option value="pending">{t('status.PENDING')}</option>
|
|
<option value="accepted">{t('status.ACCEPTED')}</option>
|
|
<option value="rejected">{t('status.REJECTED')}</option>
|
|
<option value="cancelled">{t('status.CANCELLED')}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Message */}
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Bookings Table */}
|
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-border">
|
|
<thead className="bg-neutral-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
|
{t('table.bookingNumber')}
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
|
{t('table.route')}
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
|
{t('table.cargo')}
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
|
{t('table.carrier')}
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
|
{t('table.status')}
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
|
{t('table.date')}
|
|
</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
|
{t('table.actions')}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-border">
|
|
{filteredBookings.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-8 text-center text-sm text-neutral-500">
|
|
{t('table.empty')}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredBookings.map(booking => (
|
|
<tr key={booking.id} className="hover:bg-neutral-50">
|
|
{/* N° Booking */}
|
|
<td className="px-4 py-4 whitespace-nowrap">
|
|
{booking.bookingNumber && (
|
|
<div className="text-sm font-semibold text-brand-navy">
|
|
{booking.bookingNumber}
|
|
</div>
|
|
)}
|
|
<div className="text-xs text-neutral-400 font-mono">{getShortId(booking)}</div>
|
|
</td>
|
|
|
|
{/* Route */}
|
|
<td className="px-4 py-4 whitespace-nowrap">
|
|
<div className="text-sm font-medium text-brand-navy">
|
|
{booking.origin} → {booking.destination}
|
|
</div>
|
|
</td>
|
|
|
|
{/* Cargo */}
|
|
<td className="px-4 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-brand-navy">
|
|
{booking.containerType}
|
|
{booking.palletCount != null && (
|
|
<span className="ml-1 text-neutral-500">
|
|
· {booking.palletCount} {t('table.pallets')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-neutral-500 space-x-2">
|
|
{booking.weightKG != null && (
|
|
<span>{booking.weightKG.toLocaleString(dateLocale)} kg</span>
|
|
)}
|
|
{booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>}
|
|
</div>
|
|
</td>
|
|
|
|
{/* Transporteur */}
|
|
<td className="px-4 py-4 whitespace-nowrap text-sm text-brand-navy">
|
|
{booking.carrierName || '—'}
|
|
</td>
|
|
|
|
{/* Statut */}
|
|
<td className="px-4 py-4 whitespace-nowrap">
|
|
<span
|
|
className={`px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}
|
|
>
|
|
{getStatusLabel(booking.status)}
|
|
</span>
|
|
</td>
|
|
|
|
{/* Date */}
|
|
<td className="px-4 py-4 whitespace-nowrap text-sm text-neutral-500">
|
|
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString(
|
|
dateLocale
|
|
)}
|
|
</td>
|
|
|
|
{/* Actions */}
|
|
<td className="px-4 py-4 whitespace-nowrap text-right text-sm">
|
|
<button
|
|
onClick={e => {
|
|
if (openMenuId === booking.id) {
|
|
setOpenMenuId(null);
|
|
setMenuPosition(null);
|
|
} else {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
setMenuPosition({ top: rect.bottom + 5, left: rect.left - 180 });
|
|
setOpenMenuId(booking.id);
|
|
}
|
|
}}
|
|
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
|
>
|
|
<svg
|
|
className="w-5 h-5 text-neutral-600"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
{/* Actions Dropdown Menu */}
|
|
{openMenuId && menuPosition && (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-[998]"
|
|
onClick={() => {
|
|
setOpenMenuId(null);
|
|
setMenuPosition(null);
|
|
}}
|
|
/>
|
|
<div
|
|
className="fixed w-56 bg-white border-2 border-neutral-300 rounded-lg shadow-2xl z-[999]"
|
|
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
|
|
>
|
|
<div className="py-2">
|
|
<button
|
|
onClick={() => {
|
|
const booking = bookings.find(b => b.id === openMenuId);
|
|
if (booking) {
|
|
setSelectedBooking(booking);
|
|
setShowDetailsModal(true);
|
|
}
|
|
setOpenMenuId(null);
|
|
setMenuPosition(null);
|
|
}}
|
|
className="w-full px-4 py-3 text-left hover:bg-neutral-50 flex items-center space-x-3 border-b border-border"
|
|
>
|
|
<svg
|
|
className="w-5 h-5 text-brand-navy"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
/>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
|
/>
|
|
</svg>
|
|
<span className="text-sm font-medium text-neutral-700">{t('menu.viewDetails')}</span>
|
|
</button>
|
|
{(() => {
|
|
const booking = bookings.find(b => b.id === openMenuId);
|
|
return booking?.status.toUpperCase() === 'PENDING_BANK_TRANSFER' ? (
|
|
<button
|
|
onClick={() => {
|
|
const id = openMenuId;
|
|
setOpenMenuId(null);
|
|
setMenuPosition(null);
|
|
if (id) handleValidateTransfer(id);
|
|
}}
|
|
disabled={validatingId === openMenuId}
|
|
className="w-full px-4 py-3 text-left hover:bg-green-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-border"
|
|
>
|
|
<svg
|
|
className="w-5 h-5 text-green-600"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<span className="text-sm font-medium text-green-700">
|
|
{t('menu.validateTransfer')}
|
|
</span>
|
|
</button>
|
|
) : null;
|
|
})()}
|
|
<button
|
|
onClick={() => {
|
|
const id = openMenuId;
|
|
setOpenMenuId(null);
|
|
setMenuPosition(null);
|
|
if (id) handleDeleteBooking(id);
|
|
}}
|
|
disabled={deletingId === openMenuId}
|
|
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
|
|
>
|
|
<svg
|
|
className="w-5 h-5 text-red-600"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
/>
|
|
</svg>
|
|
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Details Modal */}
|
|
{showDetailsModal && selectedBooking && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto p-4">
|
|
<div className="bg-white rounded-lg p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h2 className="text-xl font-bold text-brand-navy">{t('modal.title')}</h2>
|
|
<button
|
|
onClick={() => {
|
|
setShowDetailsModal(false);
|
|
setSelectedBooking(null);
|
|
}}
|
|
className="text-neutral-400 hover:text-neutral-600"
|
|
>
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-500">
|
|
{t('modal.bookingNumber')}
|
|
</label>
|
|
<div className="mt-1 text-lg font-semibold text-brand-navy">
|
|
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-500">
|
|
{t('modal.status')}
|
|
</label>
|
|
<span
|
|
className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}
|
|
>
|
|
{getStatusLabel(selectedBooking.status)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-4">
|
|
<div className="border-t pt-4">
|
|
<h3 className="text-sm font-medium text-brand-navy mb-3">
|
|
{t('modal.routeSection')}
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-500">
|
|
{t('modal.origin')}
|
|
</label>
|
|
<div className="mt-1 font-semibold text-brand-navy">
|
|
{selectedBooking.origin || t('modal.none')}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-500">
|
|
{t('modal.destination')}
|
|
</label>
|
|
<div className="mt-1 font-semibold text-brand-navy">
|
|
{selectedBooking.destination || t('modal.none')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<h3 className="text-sm font-medium text-brand-navy mb-3">
|
|
{t('modal.cargoSection')}
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-500">
|
|
{t('modal.carrier')}
|
|
</label>
|
|
<div className="mt-1 font-semibold text-brand-navy">
|
|
{selectedBooking.carrierName || t('modal.none')}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-500">
|
|
{t('modal.containerType')}
|
|
</label>
|
|
<div className="mt-1 font-semibold text-brand-navy">
|
|
{selectedBooking.containerType}
|
|
</div>
|
|
</div>
|
|
{selectedBooking.palletCount != null && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-500">
|
|
{t('modal.pallets')}
|
|
</label>
|
|
<div className="mt-1 font-semibold text-brand-navy">
|
|
{selectedBooking.palletCount}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{selectedBooking.weightKG != null && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-500">
|
|
{t('modal.weight')}
|
|
</label>
|
|
<div className="mt-1 font-semibold text-brand-navy">
|
|
{selectedBooking.weightKG.toLocaleString(dateLocale)} kg
|
|
</div>
|
|
</div>
|
|
)}
|
|
{selectedBooking.volumeCBM != null && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-500">
|
|
{t('modal.volume')}
|
|
</label>
|
|
<div className="mt-1 font-semibold text-brand-navy">
|
|
{selectedBooking.volumeCBM} CBM
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{(selectedBooking.priceEUR != null ||
|
|
selectedBooking.priceUSD != null ||
|
|
selectedBooking.freightTotal != null ||
|
|
selectedBooking.fobTotal != null) && (
|
|
<div className="border-t pt-4">
|
|
<h3 className="text-sm font-medium text-brand-navy mb-3">
|
|
{t('modal.priceSection')}
|
|
</h3>
|
|
|
|
{/* Transport breakdown — Fret + FOB (paid to carrier) */}
|
|
{(selectedBooking.freightTotal != null || selectedBooking.fobTotal != null) && (
|
|
<div className="space-y-2 mb-4">
|
|
{selectedBooking.freightTotal != null && (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-neutral-500">{t('modal.freight')}</span>
|
|
<span className="font-semibold text-brand-navy">
|
|
{formatMoney(
|
|
selectedBooking.freightTotal,
|
|
selectedBooking.freightCurrency ||
|
|
selectedBooking.primaryCurrency ||
|
|
'USD'
|
|
)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{selectedBooking.fobTotal != null && selectedBooking.fobTotal > 0 && (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-neutral-500">{t('modal.fob')}</span>
|
|
<span className="font-semibold text-brand-navy">
|
|
{formatMoney(
|
|
selectedBooking.fobTotal,
|
|
selectedBooking.fobCurrency || 'EUR'
|
|
)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{selectedBooking.commissionAmountEur != null &&
|
|
selectedBooking.commissionAmountEur > 0 && (
|
|
<div className="flex justify-between text-sm border-t pt-2">
|
|
<span className="text-neutral-500">{t('modal.bookingFee')}</span>
|
|
<span className="font-semibold text-brand-navy">
|
|
{formatMoney(selectedBooking.commissionAmountEur, 'EUR')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<p className="text-xs text-neutral-400 pt-1">{t('modal.transportNote')}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Per-currency totals (also covers legacy bookings without a breakdown) */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{selectedBooking.priceEUR != null && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-500">EUR</label>
|
|
<div className="mt-1 text-xl font-bold text-brand-navy">
|
|
{selectedBooking.priceEUR.toLocaleString(dateLocale)} €
|
|
</div>
|
|
</div>
|
|
)}
|
|
{selectedBooking.priceUSD != null && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-500">USD</label>
|
|
<div className="mt-1 text-xl font-bold text-brand-navy">
|
|
{selectedBooking.priceUSD.toLocaleString(dateLocale)} $
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="border-t pt-4">
|
|
<h3 className="text-sm font-medium text-brand-navy mb-3">
|
|
{t('modal.datesSection')}
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<label className="block text-neutral-500">{t('modal.createdAt')}</label>
|
|
<div className="mt-1 text-brand-navy">
|
|
{new Date(
|
|
selectedBooking.requestedAt || selectedBooking.createdAt || ''
|
|
).toLocaleString(dateLocale)}
|
|
</div>
|
|
</div>
|
|
{selectedBooking.updatedAt && (
|
|
<div>
|
|
<label className="block text-neutral-500">{t('modal.updatedAt')}</label>
|
|
<div className="mt-1 text-brand-navy">
|
|
{new Date(selectedBooking.updatedAt).toLocaleString(dateLocale)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedBooking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
|
|
<div className="border-t pt-4">
|
|
<button
|
|
onClick={() => {
|
|
setShowDetailsModal(false);
|
|
setSelectedBooking(null);
|
|
handleValidateTransfer(selectedBooking.id);
|
|
}}
|
|
disabled={validatingId === selectedBooking.id}
|
|
className="w-full px-4 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{validatingId === selectedBooking.id
|
|
? t('modal.validating')
|
|
: t('modal.validateButton')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end mt-6 pt-4 border-t">
|
|
<button
|
|
onClick={() => {
|
|
setShowDetailsModal(false);
|
|
setSelectedBooking(null);
|
|
}}
|
|
className="px-4 py-2 border border-neutral-300 rounded-md text-neutral-700 hover:bg-neutral-50"
|
|
>
|
|
{t('modal.close')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|