This commit is contained in:
David 2026-02-03 16:08:00 +01:00
parent 3e654af8a3
commit cf19c36586
31 changed files with 1320 additions and 558 deletions

View File

@ -2,6 +2,8 @@
import { useState, useEffect, useCallback } from 'react';
import { getAllBookings, getAllUsers } from '@/lib/api/admin';
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
import type { ReactNode } from 'react';
interface Document {
id: string;
@ -226,30 +228,31 @@ export default function AdminDocumentsPage() {
setCurrentPage(1);
}, [searchTerm, filterUserId, filterQuoteNumber]);
const getDocumentIcon = (type: string) => {
const getDocumentIcon = (type: string): ReactNode => {
const typeLower = type.toLowerCase();
const icons: Record<string, string> = {
'application/pdf': '📄',
'image/jpeg': '🖼️',
'image/png': '🖼️',
'image/jpg': '🖼️',
pdf: '📄',
jpeg: '🖼️',
jpg: '🖼️',
png: '🖼️',
gif: '🖼️',
image: '🖼️',
word: '📝',
doc: '📝',
docx: '📝',
excel: '📊',
xls: '📊',
xlsx: '📊',
csv: '📊',
text: '📄',
txt: '📄',
const cls = "h-6 w-6";
const iconMap: Record<string, ReactNode> = {
'application/pdf': <FileText className={`${cls} text-red-500`} />,
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
'image/png': <ImageIcon className={`${cls} text-green-500`} />,
'image/jpg': <ImageIcon className={`${cls} text-green-500`} />,
pdf: <FileText className={`${cls} text-red-500`} />,
jpeg: <ImageIcon className={`${cls} text-green-500`} />,
jpg: <ImageIcon className={`${cls} text-green-500`} />,
png: <ImageIcon className={`${cls} text-green-500`} />,
gif: <ImageIcon className={`${cls} text-green-500`} />,
image: <ImageIcon className={`${cls} text-green-500`} />,
word: <FileEdit className={`${cls} text-blue-500`} />,
doc: <FileEdit className={`${cls} text-blue-500`} />,
docx: <FileEdit className={`${cls} text-blue-500`} />,
excel: <FileSpreadsheet className={`${cls} text-green-600`} />,
xls: <FileSpreadsheet className={`${cls} text-green-600`} />,
xlsx: <FileSpreadsheet className={`${cls} text-green-600`} />,
csv: <FileSpreadsheet className={`${cls} text-green-600`} />,
text: <FileText className={`${cls} text-gray-500`} />,
txt: <FileText className={`${cls} text-gray-500`} />,
};
return icons[typeLower] || '📎';
return iconMap[typeLower] || <Paperclip className={`${cls} text-gray-400`} />;
};
const getStatusColor = (status: string) => {
@ -445,7 +448,7 @@ export default function AdminDocumentsPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="text-2xl mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
<span className="mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
</div>
</td>

View File

@ -8,6 +8,7 @@
import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ClipboardList, Mail, AlertTriangle } from 'lucide-react';
import type { CsvRateSearchResult } from '@/types/rates';
import { createCsvBooking } from '@/lib/api/bookings';
@ -259,7 +260,7 @@ function NewBookingPageContent() {
{error && (
<div className="mb-6 bg-red-50 border-2 border-red-200 rounded-lg p-4">
<div className="flex items-start">
<span className="text-2xl mr-3"></span>
<AlertTriangle className="h-6 w-6 mr-3 text-red-500 flex-shrink-0" />
<div>
<h4 className="font-semibold text-red-900 mb-1">Erreur</h4>
<p className="text-red-700 whitespace-pre-line">{error}</p>
@ -384,7 +385,7 @@ function NewBookingPageContent() {
<div className="mb-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg p-4">
<div className="flex items-start">
<span className="text-2xl mr-3">📋</span>
<ClipboardList className="h-6 w-6 mr-3 text-yellow-700 flex-shrink-0" />
<div>
<h4 className="font-semibold text-yellow-900 mb-1">Information importante</h4>
<p className="text-sm text-yellow-800">
@ -572,7 +573,7 @@ function NewBookingPageContent() {
{/* What happens next */}
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">
📧 Que se passe-t-il ensuite ?
<Mail className="h-5 w-5 mr-2 inline-block" /> Que se passe-t-il ensuite ?
</h3>
<ul className="space-y-2 text-sm text-gray-700">
<li className="flex items-start">

View File

@ -10,6 +10,8 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listBookings, listCsvBookings } from '@/lib/api';
import Link from 'next/link';
import { Plus } from 'lucide-react';
import ExportButton from '@/components/ExportButton';
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
@ -146,14 +148,38 @@ export default function BookingsListPage() {
<h1 className="text-2xl font-bold text-gray-900">Réservations</h1>
<p className="text-sm text-gray-500 mt-1">Gérez et suivez vos envois</p>
</div>
<div className="flex items-center space-x-3">
<ExportButton
data={filteredBookings}
filename="reservations"
columns={[
{ key: 'id', label: 'ID' },
{ key: 'palletCount', label: 'Palettes', format: (v) => `${v || 0}` },
{ key: 'weightKG', label: 'Poids (kg)', format: (v) => `${v || 0}` },
{ key: 'volumeCBM', label: 'Volume (CBM)', format: (v) => `${v || 0}` },
{ key: 'origin', label: 'Origine' },
{ key: 'destination', label: 'Destination' },
{ key: 'carrierName', label: 'Transporteur' },
{ key: 'status', label: 'Statut', format: (v) => {
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
};
return labels[v] || v;
}},
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
]}
/>
<Link
href="/dashboard/search-advanced"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<span className="mr-2"></span>
<Plus className="mr-2 h-4 w-4" />
Nouvelle Réservation
</Link>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow p-4">
@ -450,7 +476,7 @@ export default function BookingsListPage() {
href="/dashboard/search-advanced"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<span className="mr-2"></span>
<Plus className="mr-2 h-4 w-4" />
Nouvelle Réservation
</Link>
</div>

View File

@ -2,6 +2,9 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
import type { ReactNode } from 'react';
import ExportButton from '@/components/ExportButton';
interface Document {
id: string;
@ -164,30 +167,31 @@ export default function UserDocumentsPage() {
setCurrentPage(1);
}, [searchTerm, filterStatus, filterQuoteNumber]);
const getDocumentIcon = (type: string) => {
const getDocumentIcon = (type: string): ReactNode => {
const typeLower = type.toLowerCase();
const icons: Record<string, string> = {
'application/pdf': '📄',
'image/jpeg': '🖼️',
'image/png': '🖼️',
'image/jpg': '🖼️',
pdf: '📄',
jpeg: '🖼️',
jpg: '🖼️',
png: '🖼️',
gif: '🖼️',
image: '🖼️',
word: '📝',
doc: '📝',
docx: '📝',
excel: '📊',
xls: '📊',
xlsx: '📊',
csv: '📊',
text: '📄',
txt: '📄',
const cls = "h-6 w-6";
const iconMap: Record<string, ReactNode> = {
'application/pdf': <FileText className={`${cls} text-red-500`} />,
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
'image/png': <ImageIcon className={`${cls} text-green-500`} />,
'image/jpg': <ImageIcon className={`${cls} text-green-500`} />,
pdf: <FileText className={`${cls} text-red-500`} />,
jpeg: <ImageIcon className={`${cls} text-green-500`} />,
jpg: <ImageIcon className={`${cls} text-green-500`} />,
png: <ImageIcon className={`${cls} text-green-500`} />,
gif: <ImageIcon className={`${cls} text-green-500`} />,
image: <ImageIcon className={`${cls} text-green-500`} />,
word: <FileEdit className={`${cls} text-blue-500`} />,
doc: <FileEdit className={`${cls} text-blue-500`} />,
docx: <FileEdit className={`${cls} text-blue-500`} />,
excel: <FileSpreadsheet className={`${cls} text-green-600`} />,
xls: <FileSpreadsheet className={`${cls} text-green-600`} />,
xlsx: <FileSpreadsheet className={`${cls} text-green-600`} />,
csv: <FileSpreadsheet className={`${cls} text-green-600`} />,
text: <FileText className={`${cls} text-gray-500`} />,
txt: <FileText className={`${cls} text-gray-500`} />,
};
return icons[typeLower] || '📎';
return iconMap[typeLower] || <Paperclip className={`${cls} text-gray-400`} />;
};
const getStatusColor = (status: string) => {
@ -407,6 +411,28 @@ export default function UserDocumentsPage() {
Gérez tous les documents de vos réservations
</p>
</div>
<div className="flex items-center space-x-3">
<ExportButton
data={filteredDocuments}
filename="documents"
columns={[
{ key: 'fileName', label: 'Nom du fichier' },
{ key: 'fileType', label: 'Type' },
{ key: 'quoteNumber', label: 'N° de Devis' },
{ key: 'route', label: 'Route' },
{ key: 'carrierName', label: 'Transporteur' },
{ key: 'status', label: 'Statut', format: (v) => {
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
CANCELLED: 'Annulé',
};
return labels[v] || v;
}},
{ key: 'uploadedAt', label: 'Date d\'ajout', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
]}
/>
<button
onClick={handleAddDocumentClick}
disabled={bookingsWithPendingStatus.length === 0}
@ -423,6 +449,7 @@ export default function UserDocumentsPage() {
Ajouter un document
</button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@ -533,7 +560,7 @@ export default function UserDocumentsPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="text-2xl mr-2">
<span className="mr-2">
{getDocumentIcon(doc.fileType || doc.type)}
</span>
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>

View File

@ -13,25 +13,34 @@ import { useState } from 'react';
import NotificationDropdown from '@/components/NotificationDropdown';
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
import Image from 'next/image';
import {
BarChart3,
Package,
FileText,
Search,
BookOpen,
User,
Building2,
Users,
LogOut,
} from 'lucide-react';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth();
const pathname = usePathname();
const [sidebarOpen, setSidebarOpen] = useState(false);
// { name: 'Search Rates', href: '/dashboard/search-advanced', icon: '🔎' },
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: '📊' },
{ name: 'Bookings', href: '/dashboard/bookings', icon: '📦' },
{ name: 'Documents', href: '/dashboard/documents', icon: '📄' },
{ name: 'Track & Trace', href: '/dashboard/track-trace', icon: '🔍' },
{ name: 'Wiki', href: '/dashboard/wiki', icon: '📚' },
{ name: 'My Profile', href: '/dashboard/profile', icon: '👤' },
{ name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' },
{ name: 'Tableau de bord', href: '/dashboard', icon: BarChart3 },
{ name: 'Réservations', href: '/dashboard/bookings', icon: Package },
{ name: 'Documents', href: '/dashboard/documents', icon: FileText },
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search },
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen },
{ name: 'Mon Profil', href: '/dashboard/profile', icon: User },
{ name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 },
// ADMIN and MANAGER only navigation items
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
{ name: 'Users', href: '/dashboard/settings/users', icon: '👥' },
{ name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users },
] : []),
];
@ -98,7 +107,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<span className="mr-3 text-xl">{item.icon}</span>
<item.icon className="mr-3 h-5 w-5" />
{item.name}
</Link>
))}
@ -129,15 +138,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
onClick={logout}
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Logout
<LogOut className="w-4 h-4 mr-2" />
Déconnexion
</button>
</div>
</div>
@ -162,7 +164,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</button>
<div className="flex-1 lg:flex-none">
<h1 className="text-xl font-semibold text-gray-900">
{navigation.find(item => isActive(item.href))?.name || 'Dashboard'}
{navigation.find(item => isActive(item.href))?.name || 'Tableau de bord'}
</h1>
</div>
<div className="flex items-center space-x-4">
@ -170,9 +172,18 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<NotificationDropdown />
{/* User Role Badge */}
{user?.role === 'ADMIN' ? (
<Link
href="/dashboard/admin/users"
className="px-3 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full hover:bg-blue-200 transition-colors cursor-pointer"
>
{user.role}
</Link>
) : (
<span className="px-3 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full">
{user?.role}
</span>
)}
</div>
</div>

View File

@ -16,7 +16,8 @@ import {
deleteNotification,
} from '@/lib/api';
import type { NotificationResponse } from '@/types/api';
import { Trash2, CheckCheck, Filter, Bell, ChevronLeft, ChevronRight } from 'lucide-react';
import { Trash2, CheckCheck, Filter, Bell, ChevronLeft, ChevronRight, Package, RefreshCw, XCircle, CheckCircle, Mail, Clock, FileText, Megaphone, User, Building2 } from 'lucide-react';
import type { ReactNode } from 'react';
export default function NotificationsPage() {
const [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
@ -77,7 +78,7 @@ export default function NotificationsPage() {
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
e.stopPropagation();
if (confirm('Are you sure you want to delete this notification?')) {
if (confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')) {
deleteNotificationMutation.mutate(notificationId);
}
};
@ -92,22 +93,23 @@ export default function NotificationsPage() {
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-100';
};
const getNotificationIcon = (type: string) => {
const icons: Record<string, string> = {
booking_created: '📦',
booking_updated: '🔄',
booking_cancelled: '❌',
booking_confirmed: '✅',
csv_booking_accepted: '✅',
csv_booking_rejected: '❌',
csv_booking_request_sent: '📧',
rate_quote_expiring: '⏰',
document_uploaded: '📄',
system_announcement: '📢',
user_invited: '👤',
organization_update: '🏢',
const getNotificationIcon = (type: string): ReactNode => {
const iconClass = "h-8 w-8";
const icons: Record<string, ReactNode> = {
booking_created: <Package className={`${iconClass} text-blue-600`} />,
booking_updated: <RefreshCw className={`${iconClass} text-orange-500`} />,
booking_cancelled: <XCircle className={`${iconClass} text-red-500`} />,
booking_confirmed: <CheckCircle className={`${iconClass} text-green-500`} />,
csv_booking_accepted: <CheckCircle className={`${iconClass} text-green-500`} />,
csv_booking_rejected: <XCircle className={`${iconClass} text-red-500`} />,
csv_booking_request_sent: <Mail className={`${iconClass} text-blue-500`} />,
rate_quote_expiring: <Clock className={`${iconClass} text-yellow-500`} />,
document_uploaded: <FileText className={`${iconClass} text-gray-600`} />,
system_announcement: <Megaphone className={`${iconClass} text-purple-500`} />,
user_invited: <User className={`${iconClass} text-teal-500`} />,
organization_update: <Building2 className={`${iconClass} text-indigo-500`} />,
};
return icons[type.toLowerCase()] || '🔔';
return icons[type.toLowerCase()] || <Bell className={`${iconClass} text-gray-500`} />;
};
const formatTime = (dateString: string) => {
@ -118,11 +120,11 @@ export default function NotificationsPage() {
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', {
if (diffMins < 1) return 'A l\'instant';
if (diffMins < 60) return `Il y a ${diffMins}min`;
if (diffHours < 24) return `Il y a ${diffHours}h`;
if (diffDays < 7) return `Il y a ${diffDays}j`;
return date.toLocaleDateString('fr-FR', {
month: 'long',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
@ -144,8 +146,8 @@ export default function NotificationsPage() {
<div>
<h1 className="text-3xl font-bold text-gray-900">Notifications</h1>
<p className="text-sm text-gray-600 mt-1">
{total} notification{total !== 1 ? 's' : ''} total
{unreadCount > 0 && `${unreadCount} unread`}
{total} notification{total !== 1 ? 's' : ''} au total
{unreadCount > 0 && `${unreadCount} non lue${unreadCount > 1 ? 's' : ''}`}
</p>
</div>
</div>
@ -156,7 +158,7 @@ export default function NotificationsPage() {
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<CheckCheck className="w-5 h-5" />
<span>Mark all as read</span>
<span>Tout marquer comme lu</span>
</button>
)}
</div>
@ -169,7 +171,7 @@ export default function NotificationsPage() {
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex items-center space-x-3">
<Filter className="w-5 h-5 text-gray-500" />
<span className="text-sm font-medium text-gray-700">Filter:</span>
<span className="text-sm font-medium text-gray-700">Filtrer :</span>
<div className="flex space-x-2">
{(['all', 'unread', 'read'] as const).map((filter) => (
<button
@ -184,7 +186,7 @@ export default function NotificationsPage() {
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{filter.charAt(0).toUpperCase() + filter.slice(1)}
{filter === 'all' ? 'Toutes' : filter === 'unread' ? 'Non lues' : 'Lues'}
{filter === 'unread' && unreadCount > 0 && (
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-full text-xs">
{unreadCount}
@ -202,18 +204,18 @@ export default function NotificationsPage() {
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4" />
<p className="text-gray-500">Loading notifications...</p>
<p className="text-gray-500">Chargement des notifications...</p>
</div>
</div>
) : notifications.length === 0 ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-7xl mb-4">🔔</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">No notifications</h3>
<div className="mb-4 flex justify-center"><Bell className="h-16 w-16 text-gray-300" /></div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Aucune notification</h3>
<p className="text-gray-500">
{selectedFilter === 'unread'
? "You're all caught up! Great job!"
: 'No notifications to display'}
? 'Vous êtes à jour !'
: 'Aucune notification à afficher'}
</p>
</div>
</div>
@ -229,7 +231,7 @@ export default function NotificationsPage() {
>
<div className="flex items-start space-x-4">
{/* Icon */}
<div className="flex-shrink-0 text-4xl">
<div className="flex-shrink-0 flex items-center justify-center w-12 h-12">
{getNotificationIcon(notification.type)}
</div>
@ -243,14 +245,14 @@ export default function NotificationsPage() {
{!notification.read && (
<span className="flex items-center space-x-1 px-2 py-1 bg-blue-600 text-white text-xs font-medium rounded-full">
<span className="w-2 h-2 bg-white rounded-full animate-pulse" />
<span>NEW</span>
<span>NOUVEAU</span>
</span>
)}
</div>
<button
onClick={(e) => handleDelete(e, notification.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg"
title="Delete notification"
title="Supprimer la notification"
>
<Trash2 className="w-5 h-5 text-red-600" />
</button>
@ -300,7 +302,7 @@ export default function NotificationsPage() {
</div>
{notification.actionUrl && (
<span className="text-sm text-blue-600 font-medium group-hover:underline flex items-center space-x-1">
<span>View details</span>
<span>Voir les tails</span>
<svg
className="w-4 h-4"
fill="none"
@ -330,11 +332,10 @@ export default function NotificationsPage() {
<div className="mt-6 bg-white rounded-lg shadow-sm border p-4">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
Showing page <span className="font-semibold">{currentPage}</span> of{' '}
Page <span className="font-semibold">{currentPage}</span> sur{' '}
<span className="font-semibold">{totalPages}</span>
{' • '}
<span className="font-semibold">{total}</span> total notification
{total !== 1 ? 's' : ''}
<span className="font-semibold">{total}</span> notification{total !== 1 ? 's' : ''} au total
</div>
<div className="flex items-center space-x-2">
<button
@ -343,7 +344,7 @@ export default function NotificationsPage() {
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
<span>Previous</span>
<span>Précédent</span>
</button>
<div className="flex items-center space-x-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
@ -378,7 +379,7 @@ export default function NotificationsPage() {
disabled={currentPage === totalPages}
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span>Next</span>
<span>Suivant</span>
<ChevronRight className="w-4 h-4" />
</button>
</div>

View File

@ -21,6 +21,7 @@ import {
Plus,
ArrowRight,
} from 'lucide-react';
import ExportButton from '@/components/ExportButton';
import {
PieChart,
Pie,
@ -74,17 +75,33 @@ export default function DashboardPage() {
<div>
<h1 className="text-3xl font-semibold text-gray-900">Tableau de Bord</h1>
<p className="text-gray-600 mt-1 text-sm">
Vue d'ensemble de vos bookings et performances
Vue d'ensemble de vos réservations et performances
</p>
</div>
<Link href="/dashboard/bookings">
<Button className="bg-blue-600 hover:bg-blue-700 text-white gap-2 shadow-sm">
<Plus className="h-4 w-4" />
Nouveau Booking
<div className="flex items-center space-x-3">
<ExportButton
data={topCarriers || []}
filename="tableau-de-bord-transporteurs"
columns={[
{ key: 'carrierName', label: 'Transporteur' },
{ key: 'totalBookings', label: 'Total Réservations' },
{ key: 'acceptedBookings', label: 'Acceptées' },
{ key: 'rejectedBookings', label: 'Refusées' },
{ key: 'totalWeightKG', label: 'Poids Total (KG)', format: (v) => v?.toLocaleString('fr-FR') || '0' },
{ key: 'totalVolumeCBM', label: 'Volume Total (CBM)', format: (v) => v?.toFixed(2) || '0' },
{ key: 'acceptanceRate', label: 'Taux d\'acceptation (%)', format: (v) => v?.toFixed(1) || '0' },
{ key: 'avgPriceUSD', label: 'Prix moyen ($)', format: (v) => v?.toFixed(2) || '0' },
]}
/>
<Link href="/dashboard/search-advanced">
<Button className="bg-blue-600 hover:bg-blue-700 text-white gap-2 shadow-lg text-base px-6 py-5 font-semibold">
<Plus className="h-5 w-5" />
Nouvelle Réservation
</Button>
</Link>
</div>
</div>
{/* KPI Cards - Compact with Color */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@ -191,7 +208,7 @@ export default function DashboardPage() {
<Card className="border border-gray-200 shadow-sm bg-white">
<CardHeader className="pb-4 border-b border-gray-100">
<CardTitle className="text-base font-semibold text-gray-900">
Distribution des Bookings
Distribution des Réservations
</CardTitle>
<CardDescription className="text-xs text-gray-600">
Répartition par statut
@ -233,7 +250,7 @@ export default function DashboardPage() {
Poids par Transporteur
</CardTitle>
<CardDescription className="text-xs text-gray-600">
Top 5 carriers par poids (KG)
Top 5 transporteurs par poids (KG)
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
@ -282,7 +299,7 @@ export default function DashboardPage() {
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
<Package className="h-5 w-5 text-blue-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">Total Bookings</p>
<p className="text-xs font-medium text-gray-600 mb-1">Total Réservations</p>
<p className="text-2xl font-bold text-gray-900">
{csvKpisLoading
? '--'
@ -360,7 +377,7 @@ export default function DashboardPage() {
{carrier.carrierName}
</h3>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
<span>{carrier.totalBookings} bookings</span>
<span>{carrier.totalBookings} réservations</span>
<span></span>
<span>{carrier.totalWeightKG.toLocaleString()} KG</span>
</div>
@ -400,15 +417,15 @@ export default function DashboardPage() {
<Package className="h-6 w-6 text-gray-400" />
</div>
<h3 className="text-sm font-semibold text-gray-900 mb-1">
Aucun booking
Aucune réservation
</h3>
<p className="text-xs text-gray-500 mb-4 max-w-sm mx-auto">
Créez votre premier booking pour voir vos statistiques
Créez votre première réservation pour voir vos statistiques
</p>
<Link href="/dashboard/bookings">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
<Plus className="mr-1.5 h-3 w-3" />
Créer un booking
Créer une réservation
</Button>
</Link>
</div>

View File

@ -17,18 +17,18 @@ import { updateUser, changePassword } from '@/lib/api';
// Password update schema
const passwordSchema = z
.object({
currentPassword: z.string().min(1, 'Current password is required'),
currentPassword: z.string().min(1, 'Le mot de passe actuel est requis'),
newPassword: z
.string()
.min(12, 'Password must be at least 12 characters')
.min(12, 'Le mot de passe doit contenir au moins 12 caractères')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
'Password must contain uppercase, lowercase, number, and special character'
'Le mot de passe doit contenir une majuscule, une minuscule, un chiffre et un caractère spécial'
),
confirmPassword: z.string().min(1, 'Please confirm your password'),
confirmPassword: z.string().min(1, 'Veuillez confirmer votre mot de passe'),
})
.refine(data => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
message: 'Les mots de passe ne correspondent pas',
path: ['confirmPassword'],
});
@ -36,9 +36,9 @@ type PasswordFormData = z.infer<typeof passwordSchema>;
// Profile update schema
const profileSchema = z.object({
firstName: z.string().min(2, 'First name must be at least 2 characters'),
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
firstName: z.string().min(2, 'Le prénom doit contenir au moins 2 caractères'),
lastName: z.string().min(2, 'Le nom doit contenir au moins 2 caractères'),
email: z.string().email('Adresse email invalide'),
});
type ProfileFormData = z.infer<typeof profileSchema>;
@ -101,14 +101,14 @@ export default function ProfilePage() {
return updateUser(user.id, data);
},
onSuccess: () => {
setSuccessMessage('Profile updated successfully!');
setSuccessMessage('Profil mis à jour avec succès !');
setErrorMessage('');
refreshUser();
queryClient.invalidateQueries({ queryKey: ['user'] });
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setErrorMessage(error.message || 'Failed to update profile');
setErrorMessage(error.message || 'Échec de la mise à jour du profil');
setSuccessMessage('');
},
});
@ -122,7 +122,7 @@ export default function ProfilePage() {
});
},
onSuccess: () => {
setSuccessMessage('Password updated successfully!');
setSuccessMessage('Mot de passe mis à jour avec succès !');
setErrorMessage('');
passwordForm.reset({
currentPassword: '',
@ -132,7 +132,7 @@ export default function ProfilePage() {
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setErrorMessage(error.message || 'Failed to update password');
setErrorMessage(error.message || 'Échec de la mise à jour du mot de passe');
setSuccessMessage('');
},
});
@ -151,7 +151,7 @@ export default function ProfilePage() {
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Loading profile...</p>
<p className="text-gray-600">Chargement du profil...</p>
</div>
</div>
);
@ -162,12 +162,12 @@ export default function ProfilePage() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<p className="text-red-600 mb-4">Unable to load user profile</p>
<p className="text-red-600 mb-4">Impossible de charger le profil</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retry
Réessayer
</button>
</div>
</div>
@ -178,8 +178,8 @@ export default function ProfilePage() {
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg shadow-lg p-6 text-white">
<h1 className="text-3xl font-bold mb-2">My Profile</h1>
<p className="text-blue-100">Manage your account settings and preferences</p>
<h1 className="text-3xl font-bold mb-2">Mon Profil</h1>
<p className="text-blue-100">Gérez vos paramètres de compte et préférences</p>
</div>
{/* Success/Error Messages */}
@ -230,7 +230,7 @@ export default function ProfilePage() {
{user?.role}
</span>
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
Active
Actif
</span>
</div>
</div>
@ -249,7 +249,7 @@ export default function ProfilePage() {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Profile Information
Informations personnelles
</button>
<button
onClick={() => setActiveTab('password')}
@ -259,7 +259,7 @@ export default function ProfilePage() {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Change Password
Modifier le mot de passe
</button>
</nav>
</div>
@ -274,7 +274,7 @@ export default function ProfilePage() {
htmlFor="firstName"
className="block text-sm font-medium text-gray-700 mb-2"
>
First Name
Prénom
</label>
<input
{...profileForm.register('firstName')}
@ -295,7 +295,7 @@ export default function ProfilePage() {
htmlFor="lastName"
className="block text-sm font-medium text-gray-700 mb-2"
>
Last Name
Nom
</label>
<input
{...profileForm.register('lastName')}
@ -314,7 +314,7 @@ export default function ProfilePage() {
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email Address
Adresse email
</label>
<input
{...profileForm.register('email')}
@ -323,7 +323,7 @@ export default function ProfilePage() {
disabled
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
/>
<p className="mt-1 text-xs text-gray-500">Email cannot be changed</p>
<p className="mt-1 text-xs text-gray-500">L&apos;adresse email ne peut pas être modifiée</p>
</div>
{/* Submit Button */}
@ -333,7 +333,7 @@ export default function ProfilePage() {
disabled={updateProfileMutation.isPending}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updateProfileMutation.isPending ? 'Saving...' : 'Save Changes'}
{updateProfileMutation.isPending ? 'Enregistrement...' : 'Enregistrer'}
</button>
</div>
</form>
@ -345,7 +345,7 @@ export default function ProfilePage() {
htmlFor="currentPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Current Password
Mot de passe actuel
</label>
<input
{...passwordForm.register('currentPassword')}
@ -367,7 +367,7 @@ export default function ProfilePage() {
htmlFor="newPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
New Password
Nouveau mot de passe
</label>
<input
{...passwordForm.register('newPassword')}
@ -382,8 +382,7 @@ export default function ProfilePage() {
</p>
)}
<p className="mt-1 text-xs text-gray-500">
Must be at least 12 characters with uppercase, lowercase, number, and special
character
Au moins 12 caractères avec majuscule, minuscule, chiffre et caractère spécial
</p>
</div>
@ -393,7 +392,7 @@ export default function ProfilePage() {
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Confirm New Password
Confirmer le nouveau mot de passe
</label>
<input
{...passwordForm.register('confirmPassword')}
@ -416,7 +415,7 @@ export default function ProfilePage() {
disabled={updatePasswordMutation.isPending}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updatePasswordMutation.isPending ? 'Updating...' : 'Update Password'}
{updatePasswordMutation.isPending ? 'Mise à jour...' : 'Mettre à jour'}
</button>
</div>
</form>

View File

@ -8,6 +8,7 @@
import { useState, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { searchPorts, Port } from '@/lib/api/ports';
import dynamic from 'next/dynamic';
@ -641,7 +642,7 @@ export default function AdvancedSearchPage() {
disabled={!searchForm.origin || !searchForm.destination}
className="px-6 py-3 text-base font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
🔍 Rechercher les tarifs
<Search className="h-5 w-5 mr-2" /> Rechercher les tarifs
</button>
)}
</div>

View File

@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
import type { CsvRateSearchResult } from '@/types/rates';
import { Search, Lightbulb, DollarSign, Scale, Zap, Trophy, XCircle, AlertTriangle } from 'lucide-react';
interface BestOptions {
eco: CsvRateSearchResult;
@ -121,7 +122,7 @@ export default function SearchResultsPage() {
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
<div className="max-w-7xl mx-auto">
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
<div className="text-6xl mb-4"></div>
<div className="mb-4 flex justify-center"><XCircle className="h-16 w-16 text-red-500" /></div>
<h3 className="text-xl font-bold text-red-900 mb-2">Erreur</h3>
<p className="text-red-700 mb-4">{error}</p>
<button
@ -148,13 +149,13 @@ export default function SearchResultsPage() {
</button>
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
<div className="text-6xl mb-4">🔍</div>
<div className="mb-4 flex justify-center"><Search className="h-16 w-16 text-yellow-500" /></div>
<h3 className="text-xl font-bold text-gray-900 mb-2">Aucun résultat trouvé</h3>
<p className="text-gray-600 mb-4">
Aucun tarif ne correspond à votre recherche pour le trajet {origin} {destination}
</p>
<div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6">
<h4 className="font-semibold text-gray-900 mb-2">💡 Suggestions :</h4>
<h4 className="font-semibold text-gray-900 mb-2 flex items-center"><Lightbulb className="h-5 w-5 mr-2 text-yellow-500" /> Suggestions :</h4>
<ul className="text-sm text-gray-700 space-y-2">
<li>
<strong>Ports disponibles :</strong> NLRTM, DEHAM, FRLEH, BEGNE (origine) USNYC, USLAX,
@ -190,7 +191,7 @@ export default function SearchResultsPage() {
text: 'text-green-800',
button: 'bg-green-600 hover:bg-green-700',
},
icon: '💰',
icon: <DollarSign className="h-10 w-10 text-green-600" />,
badge: 'Le moins cher',
},
{
@ -202,7 +203,7 @@ export default function SearchResultsPage() {
text: 'text-blue-800',
button: 'bg-blue-600 hover:bg-blue-700',
},
icon: '⚖️',
icon: <Scale className="h-10 w-10 text-blue-600" />,
badge: 'Équilibré',
},
{
@ -214,7 +215,7 @@ export default function SearchResultsPage() {
text: 'text-purple-800',
button: 'bg-purple-600 hover:bg-purple-700',
},
icon: '⚡',
icon: <Zap className="h-10 w-10 text-purple-600" />,
badge: 'Le plus rapide',
},
];
@ -253,7 +254,7 @@ export default function SearchResultsPage() {
{bestOptions && (
<div className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
<span className="text-3xl mr-3">🏆</span>
<Trophy className="h-8 w-8 mr-3 text-yellow-500" />
Meilleurs choix pour votre recherche
</h2>
@ -269,7 +270,7 @@ export default function SearchResultsPage() {
<div className={`p-6 ${card.colors.bg}`}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<span className="text-4xl">{card.icon}</span>
<span>{card.icon}</span>
<div>
<h3 className={`text-xl font-bold ${card.colors.text}`}>{card.type}</h3>
<span className="text-xs bg-white px-2 py-1 rounded-full text-gray-600 font-medium">
@ -366,7 +367,7 @@ export default function SearchResultsPage() {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-sm text-gray-600">
<span> Valide jusqu'au {new Date(result.validUntil).toLocaleDateString('fr-FR')}</span>
{result.hasSurcharges && <span className="text-orange-600"> Surcharges applicables</span>}
{result.hasSurcharges && <span className="text-orange-600 flex items-center"><AlertTriangle className="h-4 w-4 mr-1" /> Surcharges applicables</span>}
</div>
<button
onClick={() => {

View File

@ -11,6 +11,7 @@ import { useQuery } from '@tanstack/react-query';
import Image from 'next/image';
import { searchRates } from '@/lib/api';
import { searchPorts, Port } from '@/lib/api/ports';
import { Search, Leaf, Package } from 'lucide-react';
type ContainerType = '20GP' | '40GP' | '40HC' | '45HC' | '20RF' | '40RF';
type Mode = 'SEA' | 'AIR' | 'ROAD' | 'RAIL';
@ -122,9 +123,9 @@ export default function RateSearchPage() {
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Search Shipping Rates</h1>
<h1 className="text-2xl font-bold text-gray-900">Recherche de Tarifs Maritime</h1>
<p className="text-sm text-gray-500 mt-1">
Compare rates from multiple carriers in real-time
Comparez les tarifs de plusieurs transporteurs en temps réel
</p>
</div>
@ -135,7 +136,7 @@ export default function RateSearchPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Origin Port */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Origin Port *</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Port d'origine *</label>
<input
type="text"
required
@ -146,7 +147,7 @@ export default function RateSearchPage() {
setSearchForm({ ...searchForm, originPort: '' });
}
}}
placeholder="e.g., Rotterdam, Shanghai"
placeholder="ex : Rotterdam, Shanghai"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
{originPorts && originPorts.length > 0 && (
@ -174,7 +175,7 @@ export default function RateSearchPage() {
{/* Destination Port */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Destination Port *
Port de destination *
</label>
<input
type="text"
@ -186,7 +187,7 @@ export default function RateSearchPage() {
setSearchForm({ ...searchForm, destinationPort: '' });
}
}}
placeholder="e.g., Los Angeles, Hamburg"
placeholder="ex : Los Angeles, Hambourg"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
{destinationPorts && destinationPorts.length > 0 && (
@ -216,7 +217,7 @@ export default function RateSearchPage() {
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Container Type *
Type de conteneur *
</label>
<select
value={searchForm.containerType}
@ -235,7 +236,7 @@ export default function RateSearchPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Quantity *</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Quantité *</label>
<input
type="number"
min="1"
@ -250,7 +251,7 @@ export default function RateSearchPage() {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Departure Date *
Date de départ *
</label>
<input
type="date"
@ -264,6 +265,7 @@ export default function RateSearchPage() {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Mode *</label>
<select
value={searchForm.mode}
onChange={e => setSearchForm({ ...searchForm, mode: e.target.value as Mode })}
@ -285,7 +287,7 @@ export default function RateSearchPage() {
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="hazmat" className="ml-2 block text-sm text-gray-900">
Hazardous Materials (requires special handling)
Marchandises dangereuses (manutention spéciale requise)
</label>
</div>
@ -299,12 +301,12 @@ export default function RateSearchPage() {
{isSearching ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
Searching...
Recherche en cours...
</>
) : (
<>
<span className="mr-2">🔍</span>
Search Rates
<Search className="h-5 w-5 mr-2" />
Rechercher des tarifs
</>
)}
</button>
@ -315,7 +317,7 @@ export default function RateSearchPage() {
{/* Error */}
{searchError && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-800">Failed to search rates. Please try again.</div>
<div className="text-sm text-red-800">La recherche de tarifs a échoué. Veuillez réessayer.</div>
</div>
)}
@ -326,20 +328,20 @@ export default function RateSearchPage() {
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow p-4 space-y-6 sticky top-4">
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Sort By</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Trier par</h3>
<select
value={sortBy}
onChange={e => setSortBy(e.target.value as any)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
>
<option value="price">Price (Low to High)</option>
<option value="transitTime">Transit Time</option>
<option value="co2">CO2 Emissions</option>
<option value="price">Prix (croissant)</option>
<option value="transitTime">Temps de transit</option>
<option value="co2">Émissions CO2</option>
</select>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Price Range (USD)</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Fourchette de prix (USD)</h3>
<div className="space-y-2">
<input
type="range"
@ -351,14 +353,14 @@ export default function RateSearchPage() {
className="w-full"
/>
<div className="text-sm text-gray-600">
Up to ${priceRange[1].toLocaleString()}
Jusqu'à {priceRange[1].toLocaleString()} $
</div>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">
Max Transit Time (days)
Temps de transit max (jours)
</h3>
<div className="space-y-2">
<input
@ -369,13 +371,13 @@ export default function RateSearchPage() {
onChange={e => setTransitTimeMax(parseInt(e.target.value))}
className="w-full"
/>
<div className="text-sm text-gray-600">{transitTimeMax} days</div>
<div className="text-sm text-gray-600">{transitTimeMax} jours</div>
</div>
</div>
{availableCarriers.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Carriers</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Transporteurs</h3>
<div className="space-y-2">
{availableCarriers.map(carrier => (
<label key={carrier} className="flex items-center">
@ -398,8 +400,7 @@ export default function RateSearchPage() {
<div className="lg:col-span-3 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
{filteredAndSortedQuotes.length} Rate
{filteredAndSortedQuotes.length !== 1 ? 's' : ''} Found
{filteredAndSortedQuotes.length} tarif{filteredAndSortedQuotes.length !== 1 ? 's' : ''} trouvé{filteredAndSortedQuotes.length !== 1 ? 's' : ''}
</h2>
</div>
@ -418,9 +419,9 @@ export default function RateSearchPage() {
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No rates found</h3>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun tarif trouvé</h3>
<p className="mt-1 text-sm text-gray-500">
Try adjusting your filters or search criteria
Essayez d'ajuster vos filtres ou vos critères de recherche
</p>
</div>
) : (
@ -467,19 +468,19 @@ export default function RateSearchPage() {
{/* Route Info */}
<div className="mt-4 grid grid-cols-3 gap-4">
<div>
<div className="text-xs text-gray-500 uppercase">Departure</div>
<div className="text-xs text-gray-500 uppercase">Départ</div>
<div className="text-sm font-medium text-gray-900 mt-1">
{new Date(quote.route.etd).toLocaleDateString()}
</div>
</div>
<div>
<div className="text-xs text-gray-500 uppercase">Transit Time</div>
<div className="text-xs text-gray-500 uppercase">Temps de transit</div>
<div className="text-sm font-medium text-gray-900 mt-1">
{quote.route.transitDays} days
{quote.route.transitDays} jours
</div>
</div>
<div>
<div className="text-xs text-gray-500 uppercase">Arrival</div>
<div className="text-xs text-gray-500 uppercase">Arrivée</div>
<div className="text-sm font-medium text-gray-900 mt-1">
{new Date(quote.route.eta).toLocaleDateString()}
</div>
@ -530,14 +531,14 @@ export default function RateSearchPage() {
<div className="mt-4 flex items-center space-x-4 text-sm text-gray-500">
{quote.co2Emissions && (
<div className="flex items-center">
<span className="mr-1">🌱</span>
<Leaf className="h-4 w-4 mr-1 text-green-500" />
{quote.co2Emissions.value} kg CO2
</div>
)}
{quote.availability && (
<div className="flex items-center">
<span className="mr-1">📦</span>
{quote.availability} containers available
<Package className="h-4 w-4 mr-1 text-blue-500" />
{quote.availability} conteneurs disponibles
</div>
)}
</div>
@ -545,7 +546,7 @@ export default function RateSearchPage() {
{/* Surcharges */}
{quote.pricing.surcharges && quote.pricing.surcharges.length > 0 && (
<div className="mt-4 text-sm">
<div className="text-gray-500 mb-2">Includes surcharges:</div>
<div className="text-gray-500 mb-2">Surcharges incluses :</div>
<div className="flex flex-wrap gap-2">
{quote.pricing.surcharges.map((surcharge: any, idx: number) => (
<span
@ -565,7 +566,7 @@ export default function RateSearchPage() {
href={`/dashboard/bookings/new?quoteId=${quote.id}`}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Book Now
Réserver
</a>
</div>
</div>
@ -591,10 +592,9 @@ export default function RateSearchPage() {
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<h3 className="mt-4 text-lg font-medium text-gray-900">Search for Shipping Rates</h3>
<h3 className="mt-4 text-lg font-medium text-gray-900">Rechercher des tarifs maritimes</h3>
<p className="mt-2 text-sm text-gray-500">
Enter your origin, destination, and container details to compare rates from multiple
carriers
Saisissez votre origine, destination et détails du conteneur pour comparer les tarifs de plusieurs transporteurs
</p>
</div>
)}

View File

@ -13,6 +13,7 @@ import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
import { createInvitation } from '@/lib/api/invitations';
import { useAuth } from '@/lib/context/auth-context';
import Link from 'next/link';
import ExportButton from '@/components/ExportButton';
export default function UsersManagementPage() {
const router = useRouter();
@ -53,7 +54,7 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('Invitation sent successfully! The user will receive an email with a registration link.');
setSuccess('Invitation envoyée avec succès ! L\'utilisateur recevra un email avec un lien d\'inscription.');
setShowInviteModal(false);
setInviteForm({
email: '',
@ -64,7 +65,7 @@ export default function UsersManagementPage() {
setTimeout(() => setSuccess(''), 5000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to send invitation');
setError(err.response?.data?.message || 'Échec de l\'envoi de l\'invitation');
setTimeout(() => setError(''), 5000);
},
});
@ -75,11 +76,11 @@ export default function UsersManagementPage() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setSuccess('Role updated successfully');
setSuccess('Rôle mis à jour avec succès');
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to update role');
setError(err.response?.data?.message || 'Échec de la mise à jour du rôle');
setTimeout(() => setError(''), 5000);
},
});
@ -91,11 +92,11 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('User status updated successfully');
setSuccess('Statut de l\'utilisateur mis à jour avec succès');
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to update user status');
setError(err.response?.data?.message || 'Échec de la mise à jour du statut');
setTimeout(() => setError(''), 5000);
},
});
@ -105,11 +106,11 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('User deleted successfully');
setSuccess('Utilisateur supprimé avec succès');
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to delete user');
setError(err.response?.data?.message || 'Échec de la suppression de l\'utilisateur');
setTimeout(() => setError(''), 5000);
},
});
@ -143,7 +144,7 @@ export default function UsersManagementPage() {
const handleToggleActive = (userId: string, isActive: boolean) => {
if (
window.confirm(`Are you sure you want to ${isActive ? 'deactivate' : 'activate'} this user?`)
window.confirm(`Êtes-vous sûr de vouloir ${isActive ? 'désactiver' : 'activer'} cet utilisateur ?`)
) {
toggleActiveMutation.mutate({ id: userId, isActive });
}
@ -151,7 +152,7 @@ export default function UsersManagementPage() {
const handleDelete = (userId: string) => {
if (
window.confirm('Are you sure you want to delete this user? This action cannot be undone.')
window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.')
) {
deleteMutation.mutate(userId);
}
@ -179,17 +180,17 @@ export default function UsersManagementPage() {
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-amber-800">License limit reached</h3>
<h3 className="text-sm font-medium text-amber-800">Limite de licences atteinte</h3>
<p className="mt-1 text-sm text-amber-700">
Your organization has used all available licenses ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
Upgrade your subscription to invite more users.
Votre organisation a utilisé toutes les licences disponibles ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
Mettez à niveau votre abonnement pour inviter plus d'utilisateurs.
</p>
<div className="mt-3">
<Link
href="/dashboard/settings/subscription"
className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"
>
Upgrade Subscription
Mettre à niveau l'abonnement
</Link>
</div>
</div>
@ -206,14 +207,14 @@ export default function UsersManagementPage() {
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<span className="text-sm text-blue-800">
{licenseStatus.availableLicenses} license{licenseStatus.availableLicenses !== 1 ? 's' : ''} remaining ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} used)
{licenseStatus.availableLicenses} licence{licenseStatus.availableLicenses !== 1 ? 's' : ''} restante{licenseStatus.availableLicenses !== 1 ? 's' : ''} ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} utilisées)
</span>
</div>
<Link
href="/dashboard/settings/subscription"
className="text-sm font-medium text-blue-600 hover:text-blue-800"
>
Manage Subscription
Gérer l'abonnement
</Link>
</div>
</div>
@ -222,16 +223,37 @@ export default function UsersManagementPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<p className="text-sm text-gray-500 mt-1">Manage team members and their permissions</p>
<h1 className="text-2xl font-bold text-gray-900">Gestion des Utilisateurs</h1>
<p className="text-sm text-gray-500 mt-1">Gérez les membres de l'équipe et leurs permissions</p>
</div>
<div className="flex items-center space-x-3">
<ExportButton
data={users?.users || []}
filename="utilisateurs"
columns={[
{ key: 'firstName', label: 'Prénom' },
{ key: 'lastName', label: 'Nom' },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Rôle', format: (v) => {
const labels: Record<string, string> = {
ADMIN: 'Administrateur',
MANAGER: 'Manager',
USER: 'Utilisateur',
VIEWER: 'Lecteur',
};
return labels[v] || v;
}},
{ key: 'isActive', label: 'Statut', format: (v) => v ? 'Actif' : 'Inactif' },
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
]}
/>
{licenseStatus?.canInvite ? (
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<span className="mr-2">+</span>
Invite User
Inviter un utilisateur
</button>
) : (
<Link
@ -239,10 +261,11 @@ export default function UsersManagementPage() {
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
>
<span className="mr-2">+</span>
Upgrade to Invite
Mettre à niveau
</Link>
)}
</div>
</div>
{success && (
<div className="rounded-md bg-green-50 p-4">
@ -261,7 +284,7 @@ export default function UsersManagementPage() {
{isLoading ? (
<div className="px-6 py-12 text-center text-gray-500">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
Loading users...
Chargement des utilisateurs...
</div>
) : users?.users && users.users.length > 0 ? (
<div className="overflow-x-auto overflow-y-visible">
@ -269,19 +292,19 @@ export default function UsersManagementPage() {
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
Utilisateur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
Rôle
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Login
Date de création
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
@ -338,7 +361,7 @@ export default function UsersManagementPage() {
: 'bg-red-100 text-red-800'
}`}
>
{user.isActive ? 'Active' : 'Inactive'}
{user.isActive ? 'Actif' : 'Inactif'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@ -386,8 +409,8 @@ export default function UsersManagementPage() {
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No users</h3>
<p className="mt-1 text-sm text-gray-500">Get started by inviting a team member</p>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun utilisateur</h3>
<p className="mt-1 text-sm text-gray-500">Commencez par inviter un membre de l'équipe</p>
<div className="mt-6">
{licenseStatus?.canInvite ? (
<button
@ -490,7 +513,7 @@ export default function UsersManagementPage() {
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Invite User</h3>
<h3 className="text-lg font-medium text-gray-900">Inviter un utilisateur</h3>
<button
onClick={() => setShowInviteModal(false)}
className="text-gray-400 hover:text-gray-500"
@ -510,7 +533,7 @@ export default function UsersManagementPage() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
First Name *
Prénom *
</label>
<input
type="text"
@ -521,7 +544,7 @@ export default function UsersManagementPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Last Name *</label>
<label className="block text-sm font-medium text-gray-700">Nom *</label>
<input
type="text"
required
@ -533,7 +556,7 @@ export default function UsersManagementPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email *</label>
<label className="block text-sm font-medium text-gray-700">Adresse email *</label>
<input
type="email"
required
@ -544,20 +567,20 @@ export default function UsersManagementPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Role *</label>
<label className="block text-sm font-medium text-gray-700">Rôle *</label>
<select
value={inviteForm.role}
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
>
<option value="USER">User</option>
<option value="USER">Utilisateur</option>
<option value="MANAGER">Manager</option>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
<option value="VIEWER">Viewer</option>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Administrateur</option>}
<option value="VIEWER">Lecteur</option>
</select>
{currentUser?.role !== 'ADMIN' && (
<p className="mt-1 text-xs text-gray-500">
Only platform administrators can assign the ADMIN role
Seuls les administrateurs peuvent attribuer le rôle ADMIN
</p>
)}
</div>
@ -568,14 +591,14 @@ export default function UsersManagementPage() {
disabled={inviteMutation.isPending}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
>
{inviteMutation.isPending ? 'Inviting...' : 'Send Invitation'}
{inviteMutation.isPending ? 'Envoi en cours...' : 'Envoyer l\'invitation'}
</button>
<button
type="button"
onClick={() => setShowInviteModal(false)}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Cancel
Annuler
</button>
</div>
</form>

View File

@ -2,107 +2,181 @@
* Track & Trace Page
*
* Allows users to track their shipments by entering tracking numbers
* and selecting the carrier. Redirects to carrier's tracking page.
* and selecting the carrier. Includes search history and vessel position map.
*/
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Search,
Package,
FileText,
ClipboardList,
Lightbulb,
History,
MapPin,
X,
Clock,
Ship,
ExternalLink,
Maximize2,
Minimize2,
Globe,
Anchor,
} from 'lucide-react';
// Carrier tracking URLs - the tracking number will be appended
// Search history item type
interface SearchHistoryItem {
id: string;
trackingNumber: string;
carrierId: string;
carrierName: string;
timestamp: Date;
}
// Carrier tracking URLs with official brand colors
const carriers = [
{
id: 'maersk',
name: 'Maersk',
logo: '🚢',
color: '#00243D', // Maersk dark blue
textColor: 'text-white',
trackingUrl: 'https://www.maersk.com/tracking/',
placeholder: 'Ex: MSKU1234567',
description: 'Container or B/L number',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/maersk.svg',
},
{
id: 'msc',
name: 'MSC',
logo: '🛳️',
color: '#002B5C', // MSC blue
textColor: 'text-white',
trackingUrl: 'https://www.msc.com/track-a-shipment?query=',
placeholder: 'Ex: MSCU1234567',
description: 'Container, B/L or Booking number',
description: 'N° conteneur, B/L ou réservation',
logo: '/assets/logos/carriers/msc.svg',
},
{
id: 'cma-cgm',
name: 'CMA CGM',
logo: '⚓',
color: '#E30613', // CMA CGM red
textColor: 'text-white',
trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=',
placeholder: 'Ex: CMAU1234567',
description: 'Container or B/L number',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/cmacgm.svg',
},
{
id: 'hapag-lloyd',
name: 'Hapag-Lloyd',
logo: '🔷',
color: '#FF6600', // Hapag orange
textColor: 'text-white',
trackingUrl: 'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=',
placeholder: 'Ex: HLCU1234567',
description: 'Container number',
description: 'N° conteneur',
logo: '/assets/logos/carriers/hapag.svg',
},
{
id: 'cosco',
name: 'COSCO',
logo: '🌊',
color: '#003A70', // COSCO blue
textColor: 'text-white',
trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=',
placeholder: 'Ex: COSU1234567',
description: 'Container or B/L number',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/cosco.svg',
},
{
id: 'one',
name: 'ONE (Ocean Network Express)',
logo: '🟣',
name: 'ONE',
color: '#FF00FF', // ONE magenta
textColor: 'text-white',
trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=',
placeholder: 'Ex: ONEU1234567',
description: 'Container or B/L number',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/one.svg',
},
{
id: 'evergreen',
name: 'Evergreen',
logo: '🌲',
color: '#006633', // Evergreen green
textColor: 'text-white',
trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=',
placeholder: 'Ex: EGHU1234567',
description: 'Container or B/L number',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/evergreen.svg',
},
{
id: 'yangming',
name: 'Yang Ming',
logo: '🟡',
color: '#FFD700', // Yang Ming yellow
textColor: 'text-gray-900',
trackingUrl: 'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=',
placeholder: 'Ex: YMLU1234567',
description: 'Container number',
description: 'N° conteneur',
logo: '/assets/logos/carriers/yangming.svg',
},
{
id: 'zim',
name: 'ZIM',
logo: '🔵',
color: '#1E3A8A', // ZIM blue
textColor: 'text-white',
trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=',
placeholder: 'Ex: ZIMU1234567',
description: 'Container or B/L number',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/zim.svg',
},
{
id: 'hmm',
name: 'HMM (Hyundai)',
logo: '🟠',
name: 'HMM',
color: '#E65100', // HMM orange
textColor: 'text-white',
trackingUrl: 'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=',
placeholder: 'Ex: HDMU1234567',
description: 'Container or B/L number',
description: 'N° conteneur ou B/L',
logo: '/assets/logos/carriers/hmm.svg',
},
];
// Local storage keys
const HISTORY_KEY = 'xpeditis_track_history';
export default function TrackTracePage() {
const [trackingNumber, setTrackingNumber] = useState('');
const [selectedCarrier, setSelectedCarrier] = useState('');
const [error, setError] = useState('');
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]);
const [showMap, setShowMap] = useState(false);
const [isMapFullscreen, setIsMapFullscreen] = useState(false);
const [isMapLoading, setIsMapLoading] = useState(true);
// Load history from localStorage on mount
useEffect(() => {
const savedHistory = localStorage.getItem(HISTORY_KEY);
if (savedHistory) {
try {
const parsed = JSON.parse(savedHistory);
setSearchHistory(parsed.map((item: any) => ({
...item,
timestamp: new Date(item.timestamp)
})));
} catch (e) {
console.error('Failed to parse search history:', e);
}
}
}, []);
// Save to localStorage
const saveHistory = (history: SearchHistoryItem[]) => {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
setSearchHistory(history);
};
const handleTrack = () => {
// Validation
if (!trackingNumber.trim()) {
setError('Veuillez entrer un numéro de tracking');
return;
@ -114,15 +188,43 @@ export default function TrackTracePage() {
setError('');
// Find the carrier and build the tracking URL
const carrier = carriers.find(c => c.id === selectedCarrier);
if (carrier) {
// Add to history
const newHistoryItem: SearchHistoryItem = {
id: Date.now().toString(),
trackingNumber: trackingNumber.trim(),
carrierId: carrier.id,
carrierName: carrier.name,
timestamp: new Date(),
};
// Keep only last 10 unique searches
const updatedHistory = [newHistoryItem, ...searchHistory.filter(
h => !(h.trackingNumber === newHistoryItem.trackingNumber && h.carrierId === newHistoryItem.carrierId)
)].slice(0, 10);
saveHistory(updatedHistory);
const trackingUrl = carrier.trackingUrl + encodeURIComponent(trackingNumber.trim());
// Open in new tab
window.open(trackingUrl, '_blank', 'noopener,noreferrer');
}
};
const handleHistoryClick = (item: SearchHistoryItem) => {
setTrackingNumber(item.trackingNumber);
setSelectedCarrier(item.carrierId);
};
const handleDeleteHistory = (id: string) => {
const updatedHistory = searchHistory.filter(h => h.id !== id);
saveHistory(updatedHistory);
};
const handleClearHistory = () => {
saveHistory([]);
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleTrack();
@ -131,11 +233,25 @@ export default function TrackTracePage() {
const selectedCarrierData = carriers.find(c => c.id === selectedCarrier);
const formatTimeAgo = (date: Date) => {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'À l\'instant';
if (diffMins < 60) return `Il y a ${diffMins}min`;
if (diffHours < 24) return `Il y a ${diffHours}h`;
if (diffDays < 7) return `Il y a ${diffDays}j`;
return date.toLocaleDateString('fr-FR');
};
return (
<div className="space-y-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Track & Trace</h1>
<h1 className="text-3xl font-bold text-gray-900">Suivi des expéditions</h1>
<p className="mt-2 text-gray-600">
Suivez vos expéditions en temps réel. Entrez votre numéro de tracking et sélectionnez le transporteur.
</p>
@ -145,15 +261,15 @@ export default function TrackTracePage() {
<Card className="bg-white shadow-lg border-blue-100">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<span className="text-2xl">🔍</span>
<Search className="h-5 w-5 text-blue-600" />
Rechercher une expédition
</CardTitle>
<CardDescription>
Entrez votre numéro de conteneur, connaissement (B/L) ou référence de booking
Entrez votre numéro de conteneur, connaissement (B/L) ou référence de réservation
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Carrier Selection */}
{/* Carrier Selection - US 5.1: Professional carrier cards with brand colors */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Sélectionnez le transporteur
@ -167,14 +283,20 @@ export default function TrackTracePage() {
setSelectedCarrier(carrier.id);
setError('');
}}
className={`flex flex-col items-center justify-center p-3 rounded-lg border-2 transition-all ${
className={`flex flex-col items-center justify-center p-4 rounded-xl border-2 transition-all hover:scale-105 ${
selectedCarrier === carrier.id
? 'border-blue-500 bg-blue-50 shadow-md'
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
? 'border-blue-500 shadow-lg ring-2 ring-blue-200'
: 'border-gray-200 hover:border-gray-300 hover:shadow-md'
}`}
>
<span className="text-2xl mb-1">{carrier.logo}</span>
<span className="text-xs font-medium text-gray-700 text-center">{carrier.name}</span>
{/* Carrier logo/badge with brand color */}
<div
className={`w-12 h-12 rounded-lg flex items-center justify-center text-sm font-bold mb-2 shadow-sm ${carrier.textColor}`}
style={{ backgroundColor: carrier.color }}
>
{carrier.name.length <= 3 ? carrier.name : carrier.name.substring(0, 2).toUpperCase()}
</div>
<span className="text-xs font-semibold text-gray-800 text-center leading-tight">{carrier.name}</span>
</button>
))}
</div>
@ -197,22 +319,42 @@ export default function TrackTracePage() {
}}
onKeyPress={handleKeyPress}
placeholder={selectedCarrierData?.placeholder || 'Ex: MSKU1234567'}
className="text-lg font-mono border-gray-300 focus:border-blue-500"
className="text-lg font-mono border-gray-300 focus:border-blue-500 h-12"
/>
{selectedCarrierData && (
<p className="mt-1 text-xs text-gray-500">{selectedCarrierData.description}</p>
)}
</div>
{/* US 5.2: Harmonized button color */}
<Button
onClick={handleTrack}
className="bg-blue-600 hover:bg-blue-700 text-white px-6"
size="lg"
className="bg-blue-600 hover:bg-blue-700 text-white px-8 h-12 font-semibold shadow-md"
>
<span className="mr-2">🔍</span>
<Search className="mr-2 h-5 w-5" />
Rechercher
</Button>
</div>
</div>
{/* Action Button - Map */}
<div className="flex flex-wrap gap-3 pt-2">
<Button
variant={showMap ? "default" : "outline"}
onClick={() => {
setShowMap(!showMap);
if (!showMap) setIsMapLoading(true);
}}
className={showMap
? "bg-blue-600 hover:bg-blue-700 text-white"
: "text-gray-700 border-gray-300 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700"
}
>
<Globe className="mr-2 h-4 w-4" />
{showMap ? 'Masquer la carte maritime' : 'Afficher la carte maritime'}
</Button>
</div>
{/* Error Message */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
@ -222,12 +364,221 @@ export default function TrackTracePage() {
</CardContent>
</Card>
{/* Vessel Position Map - Large immersive display */}
{showMap && (
<div className={`${isMapFullscreen ? 'fixed inset-0 z-50 bg-gray-900' : ''}`}>
<Card className={`bg-white shadow-xl overflow-hidden ${isMapFullscreen ? 'h-full rounded-none' : ''}`}>
{/* Map Header */}
<div className={`flex items-center justify-between px-6 py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white ${isMapFullscreen ? '' : 'rounded-t-lg'}`}>
<div className="flex items-center gap-3">
<div className="p-2 bg-white/20 rounded-lg">
<Globe className="h-6 w-6" />
</div>
<div>
<h3 className="text-lg font-semibold">Carte Maritime Mondiale</h3>
<p className="text-blue-100 text-sm">Position des navires en temps réel</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Fullscreen Toggle */}
<Button
variant="ghost"
size="sm"
onClick={() => setIsMapFullscreen(!isMapFullscreen)}
className="text-white hover:bg-white/20"
>
{isMapFullscreen ? (
<>
<Minimize2 className="h-4 w-4 mr-2" />
Réduire
</>
) : (
<>
<Maximize2 className="h-4 w-4 mr-2" />
Plein écran
</>
)}
</Button>
{/* Close Button */}
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowMap(false);
setIsMapFullscreen(false);
}}
className="text-white hover:bg-white/20"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Map Container */}
<div className={`relative w-full ${isMapFullscreen ? 'h-[calc(100vh-80px)]' : 'h-[70vh] min-h-[500px] max-h-[800px]'}`}>
{/* Loading State */}
{isMapLoading && (
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center z-10">
<div className="text-center">
<div className="relative">
<Ship className="h-16 w-16 text-blue-600 animate-pulse" />
<div className="absolute -bottom-1 left-1/2 transform -translate-x-1/2">
<div className="flex gap-1">
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
<p className="mt-4 text-blue-700 font-medium">Chargement de la carte...</p>
<p className="text-blue-500 text-sm">Connexion à MarineTraffic</p>
</div>
</div>
)}
{/* MarineTraffic Map */}
<iframe
src="https://www.marinetraffic.com/en/ais/embed/zoom:3/centery:25/centerx:0/maptype:4/shownames:true/mmsi:0/shipid:0/fleet:/fleet_id:/vtypes:/showmenu:true/remember:false"
className="w-full h-full border-0"
title="Carte maritime en temps réel"
loading="lazy"
onLoad={() => setIsMapLoading(false)}
/>
{/* Map Legend Overlay */}
<div className={`absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? 'max-w-xs' : 'max-w-[280px]'}`}>
<h4 className="font-semibold text-gray-800 text-sm mb-3 flex items-center gap-2">
<Anchor className="h-4 w-4 text-blue-600" />
Légende
</h4>
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-gray-600">Cargos</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-gray-600">Tankers</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-gray-600">Passagers</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<span className="text-gray-600">High Speed</span>
</div>
</div>
</div>
{/* Quick Stats Overlay */}
<div className={`absolute top-4 right-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? '' : 'hidden lg:block'}`}>
<div className="flex items-center gap-4 text-sm">
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">90K+</p>
<p className="text-gray-500 text-xs">Navires actifs</p>
</div>
<div className="w-px h-10 bg-gray-200" />
<div className="text-center">
<p className="text-2xl font-bold text-green-600">3,500+</p>
<p className="text-gray-500 text-xs">Ports mondiaux</p>
</div>
</div>
</div>
</div>
{/* Map Footer */}
<div className="px-6 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
<p className="text-xs text-gray-500 flex items-center gap-1">
<ExternalLink className="h-3 w-3" />
Données fournies par MarineTraffic - Mise à jour en temps réel
</p>
<a
href="https://www.marinetraffic.com"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1"
>
Ouvrir sur MarineTraffic
<ExternalLink className="h-3 w-3" />
</a>
</div>
</Card>
</div>
)}
{/* Search History */}
<Card className="bg-white shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<History className="h-5 w-5 text-gray-600" />
Historique des recherches
</CardTitle>
{searchHistory.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearHistory}
className="text-gray-500 hover:text-red-600 text-xs"
>
Effacer tout
</Button>
)}
</div>
</CardHeader>
<CardContent>
{searchHistory.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Clock className="h-10 w-10 mx-auto mb-3 text-gray-300" />
<p className="text-sm">Aucune recherche récente</p>
<p className="text-xs text-gray-400 mt-1">Vos recherches apparaîtront ici</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{searchHistory.map(item => {
const carrier = carriers.find(c => c.id === item.carrierId);
return (
<div
key={item.id}
className="flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:bg-gray-50 group cursor-pointer transition-colors"
onClick={() => handleHistoryClick(item)}
>
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded flex items-center justify-center text-xs font-bold ${carrier?.textColor || 'text-white'}`}
style={{ backgroundColor: carrier?.color || '#666' }}
>
{item.carrierName.substring(0, 2).toUpperCase()}
</div>
<div>
<p className="font-mono text-sm font-medium text-gray-900">{item.trackingNumber}</p>
<p className="text-xs text-gray-500">{item.carrierName} {formatTimeAgo(item.timestamp)}</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteHistory(item.id);
}}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-red-50 rounded transition-opacity"
>
<X className="h-4 w-4 text-gray-400 hover:text-red-500" />
</button>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Help Section */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="bg-white">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<span>📦</span>
<Package className="h-5 w-5 text-blue-600" />
Numéro de conteneur
</CardTitle>
</CardHeader>
@ -242,14 +593,14 @@ export default function TrackTracePage() {
<Card className="bg-white">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<span>📋</span>
<FileText className="h-5 w-5 text-blue-600" />
Connaissement (B/L)
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600">
Le numéro de Bill of Lading est fourni par le transporteur lors de la confirmation de booking.
Format variable selon le carrier.
Le numéro de connaissement (Bill of Lading) est fourni par le transporteur lors de la confirmation de réservation.
Le format varie selon le transporteur.
</p>
</CardContent>
</Card>
@ -257,8 +608,8 @@ export default function TrackTracePage() {
<Card className="bg-white">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<span>📝</span>
Référence de booking
<ClipboardList className="h-5 w-5 text-blue-600" />
Référence de réservation
</CardTitle>
</CardHeader>
<CardContent>
@ -272,7 +623,7 @@ export default function TrackTracePage() {
{/* Info Box */}
<div className="p-4 bg-blue-50 rounded-lg border border-blue-100">
<div className="flex items-start gap-3">
<span className="text-xl">💡</span>
<Lightbulb className="h-5 w-5 text-blue-600 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-blue-800">Comment fonctionne le suivi ?</p>
<p className="text-sm text-blue-700 mt-1">

View File

@ -6,6 +6,7 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Shield, ClipboardList, DollarSign, Pencil, Lightbulb, Plus } from 'lucide-react';
const clausesICC = [
{
@ -64,7 +65,7 @@ export default function AssurancePage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">🛡</span>
<Shield className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Assurance Maritime (Cargo Insurance)</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -88,7 +89,7 @@ export default function AssurancePage() {
{/* ICC Clauses */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Clauses ICC (Institute Cargo Clauses)</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Clauses ICC (Institute Cargo Clauses)</h2>
<div className="space-y-4">
{clausesICC.map((clause) => (
<Card key={clause.code} className={`bg-white ${clause.recommended ? 'border-green-300 border-2' : ''}`}>
@ -138,7 +139,7 @@ export default function AssurancePage() {
{/* Valeur assurée */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">💰 Calcul de la Valeur Assurée</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Calcul de la Valeur Assurée</h3>
<div className="bg-white p-4 rounded-lg border">
<div className="text-center mb-4">
<p className="text-lg font-mono bg-blue-100 text-blue-800 p-3 rounded inline-block">
@ -165,7 +166,7 @@ export default function AssurancePage() {
{/* Extensions */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4"> Extensions de Garantie</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Plus className="w-5 h-5" /> Extensions de Garantie</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{extensionsGaranties.map((ext) => (
<Card key={ext.name} className="bg-white">
@ -181,7 +182,7 @@ export default function AssurancePage() {
{/* Process */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">📝 En Cas de Sinistre</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Pencil className="w-5 h-5" /> En Cas de Sinistre</h3>
<ol className="list-decimal list-inside space-y-3 text-gray-700">
<li><strong>Constater</strong> : Émettre des réserves précises sur le bon de livraison</li>
<li><strong>Préserver</strong> : Ne pas modifier l&apos;état des marchandises (photos, témoins)</li>
@ -195,7 +196,7 @@ export default function AssurancePage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Toujours opter pour ICC A (All Risks) sauf marchandises très résistantes</li>
<li>Vérifier les exclusions et souscrire les extensions nécessaires</li>

View File

@ -6,6 +6,7 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Calculator, Ruler, ClipboardList, Banknote, BarChart3, Lightbulb, Scale } from 'lucide-react';
const surcharges = [
{
@ -86,7 +87,7 @@ export default function CalculFretPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">🧮</span>
<Calculator className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Calcul du Fret Maritime</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -98,7 +99,7 @@ export default function CalculFretPage() {
{/* Base Calculation */}
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3">📐 Principes de Base</h3>
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><Ruler className="w-5 h-5" /> Principes de Base</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-blue-800">FCL (Conteneur Complet)</h4>
@ -121,7 +122,7 @@ export default function CalculFretPage() {
{/* Weight Calculation */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3"> Poids Taxable (LCL)</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Scale className="w-5 h-5" /> Poids Taxable (LCL)</h3>
<div className="bg-white p-4 rounded-lg border mb-4">
<div className="text-center">
<p className="text-lg font-mono bg-blue-100 text-blue-800 p-3 rounded inline-block">
@ -157,7 +158,7 @@ export default function CalculFretPage() {
{/* Surcharges */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Surcharges Courantes</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Surcharges Courantes</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{surcharges.map((sur) => (
<Card key={sur.code} className="bg-white">
@ -180,7 +181,7 @@ export default function CalculFretPage() {
{/* Additional fees */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">💵 Frais Additionnels</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Banknote className="w-5 h-5" /> Frais Additionnels</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="overflow-x-auto">
@ -210,7 +211,7 @@ export default function CalculFretPage() {
{/* Example calculation */}
<Card className="mt-8 bg-green-50 border-green-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-green-900 mb-3">📊 Exemple de Devis FCL</h3>
<h3 className="font-semibold text-green-900 mb-3 flex items-center gap-2"><BarChart3 className="w-5 h-5" /> Exemple de Devis FCL</h3>
<div className="bg-white p-4 rounded-lg border">
<p className="text-sm text-gray-600 mb-3">Conteneur 40&apos; Shanghai Le Havre</p>
<div className="space-y-2 font-mono text-sm">
@ -254,7 +255,7 @@ export default function CalculFretPage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils pour Optimiser</h3>
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils pour Optimiser</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Demandez des devis &quot;All-in&quot; pour éviter les surprises de surcharges</li>
<li>Comparez les transitaires sur le total, pas seulement le fret de base</li>

View File

@ -6,6 +6,7 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Package, PackageOpen, Truck, Cylinder, Snowflake, type LucideIcon } from 'lucide-react';
const containers = [
{
@ -22,7 +23,7 @@ const containers = [
tare: '2,300 kg',
},
usage: 'Marchandises générales sèches',
icon: '📦',
icon: Package,
},
{
type: '40\' Standard (40\' DRY)',
@ -38,7 +39,7 @@ const containers = [
tare: '3,800 kg',
},
usage: 'Marchandises générales, cargo volumineux',
icon: '📦',
icon: Package,
},
{
type: '40\' High Cube (40\' HC)',
@ -54,7 +55,7 @@ const containers = [
tare: '4,020 kg',
},
usage: 'Cargo léger mais volumineux',
icon: '📦',
icon: Package,
},
{
type: 'Reefer (Réfrigéré)',
@ -70,7 +71,7 @@ const containers = [
temperature: '-30°C à +30°C',
},
usage: 'Produits périssables, pharmaceutiques',
icon: '❄️',
icon: Snowflake,
},
{
type: 'Open Top',
@ -86,7 +87,7 @@ const containers = [
tare: '2,400 kg / 4,100 kg',
},
usage: 'Cargo hors gabarit en hauteur, machinerie',
icon: '📭',
icon: PackageOpen,
},
{
type: 'Flat Rack',
@ -102,7 +103,7 @@ const containers = [
tare: '2,700 kg / 4,700 kg',
},
usage: 'Cargo très lourd ou surdimensionné',
icon: '🚛',
icon: Truck,
},
{
type: 'Tank Container',
@ -118,7 +119,7 @@ const containers = [
tare: '3,500 kg',
},
usage: 'Liquides, gaz, produits chimiques',
icon: '🛢️',
icon: Cylinder,
},
];
@ -148,7 +149,7 @@ export default function ConteneursPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">📦</span>
<Package className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Conteneurs et Types de Cargo</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -189,7 +190,7 @@ export default function ConteneursPage() {
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{container.icon}</span>
<container.icon className="w-6 h-6 text-blue-600" />
<div>
<span className="text-lg">{container.type}</span>
<span className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 text-xs font-mono rounded">
@ -260,35 +261,35 @@ export default function ConteneursPage() {
<CardContent>
<div className="space-y-3">
<div className="flex items-start gap-3">
<span className="text-xl">📦</span>
<Package className="w-5 h-5 text-green-700 mt-0.5" />
<div>
<p className="font-medium text-green-900">Marchandises générales</p>
<p className="text-sm text-green-800"> 20&apos; ou 40&apos; Standard (DRY)</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-xl"></span>
<Snowflake className="w-5 h-5 text-green-700 mt-0.5" />
<div>
<p className="font-medium text-green-900">Produits réfrigérés/congelés</p>
<p className="text-sm text-green-800"> Reefer 20&apos; ou 40&apos;</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-xl">📭</span>
<PackageOpen className="w-5 h-5 text-green-700 mt-0.5" />
<div>
<p className="font-medium text-green-900">Cargo hors gabarit (hauteur)</p>
<p className="text-sm text-green-800"> Open Top</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-xl">🚛</span>
<Truck className="w-5 h-5 text-green-700 mt-0.5" />
<div>
<p className="font-medium text-green-900">Machinerie lourde/surdimensionnée</p>
<p className="text-sm text-green-800"> Flat Rack</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-xl">🛢</span>
<Cylinder className="w-5 h-5 text-green-700 mt-0.5" />
<div>
<p className="font-medium text-green-900">Liquides en vrac</p>
<p className="text-sm text-green-800"> Tank Container ou Flexitank</p>

View File

@ -6,6 +6,7 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { FileText, ClipboardList, FileStack, Package, Receipt, Factory, type LucideIcon } from 'lucide-react';
const documents = [
{
@ -18,7 +19,7 @@ const documents = [
{ name: 'B/L au porteur', desc: 'Propriété à celui qui le détient' },
],
importance: 'Critique',
icon: '📄',
icon: FileText,
},
{
name: 'Sea Waybill',
@ -29,7 +30,7 @@ const documents = [
{ name: 'Express', desc: 'Libération rapide sans documents originaux' },
],
importance: 'Important',
icon: '📋',
icon: ClipboardList,
},
{
name: 'Manifest',
@ -40,7 +41,7 @@ const documents = [
{ name: 'Freight Manifest', desc: 'Inclut les informations de fret' },
],
importance: 'Obligatoire',
icon: '📑',
icon: FileStack,
},
{
name: 'Packing List',
@ -51,7 +52,7 @@ const documents = [
{ name: 'Détaillée', desc: 'Avec poids, dimensions, marquage' },
],
importance: 'Important',
icon: '📦',
icon: Package,
},
{
name: 'Commercial Invoice',
@ -62,7 +63,7 @@ const documents = [
{ name: 'Définitive', desc: 'Document final de facturation' },
],
importance: 'Critique',
icon: '🧾',
icon: Receipt,
},
{
name: 'Certificate of Origin',
@ -74,7 +75,7 @@ const documents = [
{ name: 'Non préférentiel', desc: 'Attestation simple d\'origine' },
],
importance: 'Selon destination',
icon: '🏭',
icon: Factory,
},
];
@ -120,7 +121,7 @@ export default function DocumentsTransportPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">📋</span>
<ClipboardList className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Documents de Transport Maritime</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -149,7 +150,7 @@ export default function DocumentsTransportPage() {
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{doc.icon}</span>
<doc.icon className="w-6 h-6 text-blue-600" />
<div>
<span className="text-lg">{doc.name}</span>
<span className="text-gray-500 text-sm ml-2">({doc.french})</span>

View File

@ -6,6 +6,7 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { ShieldCheck, ClipboardList, FileText, DollarSign, AlertTriangle } from 'lucide-react';
const regimesDouaniers = [
{
@ -97,7 +98,7 @@ export default function DouanesPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">🛃</span>
<ShieldCheck className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Procédures Douanières</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -134,7 +135,7 @@ export default function DouanesPage() {
{/* Régimes douaniers */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Régimes Douaniers</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Régimes Douaniers</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{regimesDouaniers.map((regime) => (
<Card key={regime.code} className="bg-white">
@ -156,7 +157,7 @@ export default function DouanesPage() {
{/* Documents requis */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Requis</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><FileText className="w-5 h-5" /> Documents Requis</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-4">
@ -179,7 +180,7 @@ export default function DouanesPage() {
{/* Droits et taxes */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">💰 Droits et Taxes</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Droits et Taxes</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">Droits de douane</h4>
@ -203,7 +204,7 @@ export default function DouanesPage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3"> Points d&apos;Attention</h3>
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Points d&apos;Attention</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Toujours vérifier le classement tarifaire avant l&apos;importation</li>
<li>Conserver tous les documents 3 ans minimum (contrôle a posteriori)</li>

View File

@ -6,6 +6,7 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { ClipboardList, Hash, Package, FileText, Tag, Shuffle, Lightbulb, AlertTriangle } from 'lucide-react';
const classesIMDG = [
{
@ -110,7 +111,7 @@ export default function IMDGPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl"></span>
<AlertTriangle className="w-10 h-10 text-orange-500" />
<h1 className="text-3xl font-bold text-gray-900">Marchandises Dangereuses (Code IMDG)</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -123,7 +124,7 @@ export default function IMDGPage() {
{/* Key Info */}
<Card className="bg-red-50 border-red-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-red-900 mb-3"> Responsabilités de l&apos;Expéditeur</h3>
<h3 className="font-semibold text-red-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Responsabilités de l&apos;Expéditeur</h3>
<ul className="list-disc list-inside space-y-2 text-red-800">
<li>Classer correctement la marchandise selon le Code IMDG</li>
<li>Utiliser des emballages homologués UN</li>
@ -136,7 +137,7 @@ export default function IMDGPage() {
{/* Classes */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Les 9 Classes de Danger</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Les 9 Classes de Danger</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{classesIMDG.map((cls) => (
<Card key={cls.class} className="bg-white overflow-hidden">
@ -171,7 +172,7 @@ export default function IMDGPage() {
{/* UN Number */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">🔢 Numéro ONU (UN Number)</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Hash className="w-5 h-5" /> Numéro ONU (UN Number)</h3>
<p className="text-gray-600 mb-4">
Chaque matière dangereuse est identifiée par un numéro ONU à 4 chiffres.
Ce numéro permet de retrouver toutes les informations dans le Code IMDG.
@ -195,7 +196,7 @@ export default function IMDGPage() {
{/* Packaging Groups */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📦 Groupes d&apos;Emballage</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Package className="w-5 h-5" /> Groupes d&apos;Emballage</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-4">
@ -219,7 +220,7 @@ export default function IMDGPage() {
{/* Documents */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Requis</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><FileText className="w-5 h-5" /> Documents Requis</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-3">
@ -240,7 +241,7 @@ export default function IMDGPage() {
{/* Labeling */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">🏷 Marquage et Étiquetage</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Tag className="w-5 h-5" /> Marquage et Étiquetage</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">Colis</h4>
@ -267,7 +268,7 @@ export default function IMDGPage() {
{/* Segregation */}
<Card className="mt-8 bg-orange-50 border-orange-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-orange-900 mb-3">🔀 Ségrégation</h3>
<h3 className="font-semibold text-orange-900 mb-3 flex items-center gap-2"><Shuffle className="w-5 h-5" /> Ségrégation</h3>
<p className="text-orange-800 mb-3">
Certaines classes de marchandises dangereuses ne peuvent pas être chargées ensemble.
Le Code IMDG définit des règles strictes de ségrégation :
@ -296,7 +297,7 @@ export default function IMDGPage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Vérifier l&apos;acceptation par la compagnie maritime (certaines refusent certaines classes)</li>
<li>Anticiper les surcharges DG (dangerous goods) qui peuvent être significatives</li>

View File

@ -6,6 +6,7 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { ScrollText, Ship, ArrowUpFromLine, ArrowDownToLine } from 'lucide-react';
const incoterms = [
{
@ -119,7 +120,7 @@ export default function IncotermsPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">📜</span>
<ScrollText className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Incoterms 2020</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -146,8 +147,11 @@ export default function IncotermsPage() {
{categories.map((category) => (
<div key={category} className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">
{category === 'Maritime' ? '🚢 Incoterms Maritimes' :
category === 'Départ' ? '📤 Incoterms de Départ' : '📥 Incoterms d\'Arrivée'}
<span className="flex items-center gap-2">
{category === 'Maritime' ? <><Ship className="w-5 h-5" /> Incoterms Maritimes</> :
category === 'Départ' ? <><ArrowUpFromLine className="w-5 h-5" /> Incoterms de Départ</> :
<><ArrowDownToLine className="w-5 h-5" /> Incoterms d&apos;Arrivée</>}
</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{incoterms

View File

@ -6,6 +6,7 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Package, Truck, Scale } from 'lucide-react';
export default function LclVsFclPage() {
return (
@ -26,7 +27,7 @@ export default function LclVsFclPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl"></span>
<Scale className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">LCL vs FCL</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -40,7 +41,7 @@ export default function LclVsFclPage() {
<Card className="bg-blue-50 border-blue-200">
<CardHeader>
<CardTitle className="text-blue-900 flex items-center gap-2">
<span className="text-2xl">📦</span>
<Package className="w-6 h-6" />
LCL - Groupage
</CardTitle>
</CardHeader>
@ -64,7 +65,7 @@ export default function LclVsFclPage() {
<Card className="bg-green-50 border-green-200">
<CardHeader>
<CardTitle className="text-green-900 flex items-center gap-2">
<span className="text-2xl">🚛</span>
<Truck className="w-6 h-6" />
FCL - Complet
</CardTitle>
</CardHeader>

View File

@ -6,6 +6,7 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { CreditCard, RefreshCw, Users, ClipboardList, FileText, Calendar, DollarSign, ScrollText, Lightbulb, AlertTriangle } from 'lucide-react';
const typesLC = [
{
@ -93,7 +94,7 @@ export default function LettreCreditPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">💳</span>
<CreditCard className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Lettre de Crédit (L/C)</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -106,7 +107,7 @@ export default function LettreCreditPage() {
{/* How it works */}
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3">🔄 Fonctionnement Simplifié</h3>
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><RefreshCw className="w-5 h-5" /> Fonctionnement Simplifié</h3>
<div className="grid grid-cols-1 md:grid-cols-5 gap-2">
{[
{ step: '1', title: 'Demande', desc: 'Acheteur demande l\'ouverture à sa banque' },
@ -129,7 +130,7 @@ export default function LettreCreditPage() {
{/* Parties */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">👥 Parties Impliquées</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Users className="w-5 h-5" /> Parties Impliquées</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-4">
@ -149,7 +150,7 @@ export default function LettreCreditPage() {
{/* Types */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Types de Lettres de Crédit</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Types de Lettres de Crédit</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{typesLC.map((lc) => (
<Card key={lc.type} className={`bg-white ${lc.recommended ? 'border-green-300 border-2' : ''}`}>
@ -172,7 +173,7 @@ export default function LettreCreditPage() {
{/* Documents */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Typiquement Requis</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><FileText className="w-5 h-5" /> Documents Typiquement Requis</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@ -192,7 +193,7 @@ export default function LettreCreditPage() {
{/* Key Dates */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">📅 Dates Clés à Surveiller</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Calendar className="w-5 h-5" /> Dates Clés à Surveiller</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">Date d&apos;expédition</h4>
@ -219,7 +220,7 @@ export default function LettreCreditPage() {
{/* Costs */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">💰 Coûts Typiques</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Coûts Typiques</h3>
<div className="bg-white p-4 rounded-lg border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
@ -249,7 +250,7 @@ export default function LettreCreditPage() {
{/* Common Errors */}
<Card className="mt-8 bg-red-50 border-red-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-red-900 mb-3"> Erreurs Fréquentes (Réserves)</h3>
<h3 className="font-semibold text-red-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Erreurs Fréquentes (Réserves)</h3>
<p className="text-red-800 mb-3">
Ces erreurs entraînent des &quot;réserves&quot; de la banque et peuvent bloquer le paiement :
</p>
@ -264,7 +265,7 @@ export default function LettreCreditPage() {
{/* UCP 600 */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">📜 Règles UCP 600</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><ScrollText className="w-5 h-5" /> Règles UCP 600</h3>
<p className="text-gray-600">
Les Règles et Usances Uniformes (UCP 600) de la Chambre de Commerce Internationale (CCI)
régissent les lettres de crédit documentaires depuis 2007. Points clés :
@ -281,7 +282,7 @@ export default function LettreCreditPage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Vérifier minutieusement les termes de la L/C dès réception</li>
<li>Demander des modifications AVANT expédition si nécessaire</li>

View File

@ -6,89 +6,112 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import {
ScrollText,
ClipboardList,
Package,
Scale,
ShieldCheck,
Shield,
Calculator,
Globe,
Anchor,
AlertTriangle,
CreditCard,
Timer,
type LucideIcon,
} from 'lucide-react';
const wikiTopics = [
interface WikiTopic {
title: string;
description: string;
icon: LucideIcon;
href: string;
tags: string[];
}
const wikiTopics: WikiTopic[] = [
{
title: 'Incoterms 2020',
description: 'Les règles internationales pour l\'interprétation des termes commerciaux',
icon: '📜',
icon: ScrollText,
href: '/dashboard/wiki/incoterms',
tags: ['FOB', 'CIF', 'EXW', 'DDP'],
},
{
title: 'Documents de Transport',
description: 'Les documents essentiels pour le transport maritime',
icon: '📋',
icon: ClipboardList,
href: '/dashboard/wiki/documents-transport',
tags: ['B/L', 'Sea Waybill', 'Manifest'],
},
{
title: 'Conteneurs et Types de Cargo',
description: 'Guide complet des types de conteneurs maritimes',
icon: '📦',
icon: Package,
href: '/dashboard/wiki/conteneurs',
tags: ['20\'', '40\'', 'Reefer', 'Open Top'],
},
{
title: 'LCL vs FCL',
description: 'Différences entre groupage et conteneur complet',
icon: '⚖️',
icon: Scale,
href: '/dashboard/wiki/lcl-vs-fcl',
tags: ['Groupage', 'Complet', 'Coûts'],
},
{
title: 'Procédures Douanières',
description: 'Guide des formalités douanières import/export',
icon: '🛃',
icon: ShieldCheck,
href: '/dashboard/wiki/douanes',
tags: ['Déclaration', 'Tarifs', 'Régimes'],
},
{
title: 'Assurance Maritime',
description: 'Protection des marchandises en transit',
icon: '🛡️',
icon: Shield,
href: '/dashboard/wiki/assurance',
tags: ['ICC A', 'ICC B', 'ICC C'],
},
{
title: 'Calcul du Fret Maritime',
description: 'Comment sont calculés les coûts de transport',
icon: '🧮',
icon: Calculator,
href: '/dashboard/wiki/calcul-fret',
tags: ['CBM', 'THC', 'BAF', 'CAF'],
},
{
title: 'Ports et Routes Maritimes',
description: 'Les principales routes commerciales mondiales',
icon: '🌍',
icon: Globe,
href: '/dashboard/wiki/ports-routes',
tags: ['Hub', 'Détroits', 'Canaux'],
},
{
title: 'VGM (Verified Gross Mass)',
description: 'Obligation de pesée des conteneurs (SOLAS)',
icon: '⚓',
icon: Anchor,
href: '/dashboard/wiki/vgm',
tags: ['SOLAS', 'Pesée', 'Certification'],
},
{
title: 'Marchandises Dangereuses (IMDG)',
description: 'Transport de matières dangereuses par mer',
icon: '⚠️',
icon: AlertTriangle,
href: '/dashboard/wiki/imdg',
tags: ['Classes', 'Étiquetage', 'Sécurité'],
},
{
title: 'Lettre de Crédit (L/C)',
description: 'Instrument de paiement international sécurisé',
icon: '💳',
icon: CreditCard,
href: '/dashboard/wiki/lettre-credit',
tags: ['Banque', 'Paiement', 'Sécurité'],
},
{
title: 'Transit Time et Délais',
description: 'Comprendre les délais en transport maritime',
icon: '⏱️',
icon: Timer,
href: '/dashboard/wiki/transit-time',
tags: ['Cut-off', 'Free time', 'Demurrage'],
},
@ -107,12 +130,16 @@ export default function WikiPage() {
{/* Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{wikiTopics.map((topic) => (
{wikiTopics.map((topic) => {
const IconComponent = topic.icon;
return (
<Link key={topic.href} href={topic.href} className="block group">
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-blue-300 bg-white">
<CardHeader>
<div className="flex items-start justify-between">
<span className="text-4xl">{topic.icon}</span>
<div className="h-10 w-10 rounded-lg bg-blue-50 flex items-center justify-center">
<IconComponent className="h-5 w-5 text-blue-600" />
</div>
</div>
<CardTitle className="text-xl mt-3 group-hover:text-blue-600 transition-colors">
{topic.title}
@ -135,7 +162,8 @@ export default function WikiPage() {
</CardContent>
</Card>
</Link>
))}
);
})}
</div>
{/* Footer info */}

View File

@ -6,6 +6,7 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Globe, BarChart3, Ship, Trophy, RefreshCw, Lightbulb, Anchor } from 'lucide-react';
const majorRoutes = [
{
@ -113,7 +114,7 @@ export default function PortsRoutesPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl">🌍</span>
<Globe className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Ports et Routes Maritimes</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -125,7 +126,7 @@ export default function PortsRoutesPage() {
{/* Key Stats */}
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3">📊 Chiffres Clés du Maritime Mondial</h3>
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><BarChart3 className="w-5 h-5" /> Chiffres Clés du Maritime Mondial</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<p className="text-3xl font-bold text-blue-700">80%</p>
@ -149,7 +150,7 @@ export default function PortsRoutesPage() {
{/* Major Routes */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">🛳 Routes Commerciales Majeures</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Ship className="w-5 h-5" /> Routes Commerciales Majeures</h2>
<div className="space-y-4">
{majorRoutes.map((route) => (
<Card key={route.name} className="bg-white">
@ -184,7 +185,7 @@ export default function PortsRoutesPage() {
{/* Strategic Passages */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4"> Passages Stratégiques</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Anchor className="w-5 h-5" /> Passages Stratégiques</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{strategicPassages.map((passage) => (
<Card key={passage.name} className="bg-white">
@ -222,7 +223,7 @@ export default function PortsRoutesPage() {
{/* Top Ports */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">🏆 Top 10 Ports Mondiaux (TEU)</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Trophy className="w-5 h-5" /> Top 10 Ports Mondiaux (TEU)</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="overflow-x-auto">
@ -261,7 +262,7 @@ export default function PortsRoutesPage() {
{/* Hub Ports Info */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">🔄 Ports Hub vs Ports Régionaux</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><RefreshCw className="w-5 h-5" /> Ports Hub vs Ports Régionaux</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">Ports Hub (Transbordement)</h4>
@ -286,7 +287,7 @@ export default function PortsRoutesPage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Privilégiez les routes directes pour réduire les délais et risques</li>
<li>Anticipez les congestions portuaires (Los Angeles, Rotterdam en haute saison)</li>

View File

@ -6,6 +6,7 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { BookOpen, Calendar, Ship, DollarSign, RefreshCw, Lightbulb, Clock, AlertTriangle } from 'lucide-react';
const etapesTimeline = [
{
@ -132,7 +133,7 @@ export default function TransitTimePage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl"></span>
<Clock className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Transit Time et Délais</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -145,7 +146,7 @@ export default function TransitTimePage() {
{/* Key Terms */}
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3">📖 Termes Clés</h3>
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><BookOpen className="w-5 h-5" /> Termes Clés</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<h4 className="font-medium text-blue-800">ETD</h4>
@ -169,7 +170,7 @@ export default function TransitTimePage() {
{/* Timeline */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📅 Timeline d&apos;une Expédition FCL</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Calendar className="w-5 h-5" /> Timeline d&apos;une Expédition FCL</h2>
<div className="space-y-3">
{etapesTimeline.map((item, index) => (
<Card key={item.etape} className={`bg-white ${index === 5 || index === 7 ? 'border-blue-300 border-2' : ''}`}>
@ -195,7 +196,7 @@ export default function TransitTimePage() {
{/* Transit Times */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">🚢 Transit Times Indicatifs</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Ship className="w-5 h-5" /> Transit Times Indicatifs</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="overflow-x-auto">
@ -228,7 +229,7 @@ export default function TransitTimePage() {
{/* Free Time */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3"> Free Time (Jours Gratuits)</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Clock className="w-5 h-5" /> Free Time (Jours Gratuits)</h3>
<p className="text-gray-600 mb-4">
Période pendant laquelle le conteneur peut rester au terminal ou chez l&apos;importateur
sans frais supplémentaires.
@ -257,7 +258,7 @@ export default function TransitTimePage() {
{/* Late Fees */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">💸 Frais de Retard</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Frais de Retard</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{fraisRetard.map((frais) => (
<Card key={frais.nom} className="bg-white border-red-200">
@ -283,7 +284,7 @@ export default function TransitTimePage() {
{/* Factors affecting transit */}
<Card className="mt-8 bg-orange-50 border-orange-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-orange-900 mb-3"> Facteurs Impactant les Délais</h3>
<h3 className="font-semibold text-orange-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Facteurs Impactant les Délais</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="font-medium text-orange-800">Retards potentiels</h4>
@ -312,7 +313,7 @@ export default function TransitTimePage() {
{/* Roll-over */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">🔄 Roll-over (Report)</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><RefreshCw className="w-5 h-5" /> Roll-over (Report)</h3>
<p className="text-gray-600 mb-3">
Situation un conteneur n&apos;est pas chargé sur le navire prévu et est reporté
sur le prochain départ.
@ -336,7 +337,7 @@ export default function TransitTimePage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils pour Optimiser les Délais</h3>
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils pour Optimiser les Délais</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Réserver tôt, surtout en haute saison (2-3 semaines d&apos;avance)</li>
<li>Respecter les cut-off avec une marge de sécurité (24h minimum)</li>

View File

@ -6,6 +6,7 @@
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Shield, Construction, Truck, ClipboardList, Microscope, User, Ruler, Lightbulb, Anchor, Scale, AlertTriangle } from 'lucide-react';
const methodesPesee = [
{
@ -69,7 +70,7 @@ export default function VGMPage() {
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-3">
<span className="text-4xl"></span>
<Anchor className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">VGM (Verified Gross Mass)</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">
@ -85,19 +86,19 @@ export default function VGMPage() {
<h3 className="font-semibold text-blue-900 mb-3">Pourquoi le VGM ?</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800">
<div>
<h4 className="font-medium">🛡 Sécurité</h4>
<h4 className="font-medium flex items-center gap-1"><Shield className="w-4 h-4" /> Sécurité</h4>
<p className="text-sm">Les conteneurs mal déclarés causent des accidents graves (chute de conteneurs, navires instables).</p>
</div>
<div>
<h4 className="font-medium"> Stabilité du navire</h4>
<h4 className="font-medium flex items-center gap-1"><Scale className="w-4 h-4" /> Stabilité du navire</h4>
<p className="text-sm">Le capitaine doit connaître le poids exact pour calculer le plan de chargement.</p>
</div>
<div>
<h4 className="font-medium">🏗 Équipements portuaires</h4>
<h4 className="font-medium flex items-center gap-1"><Construction className="w-4 h-4" /> Équipements portuaires</h4>
<p className="text-sm">Les grues et portiques sont dimensionnés pour des charges maximales.</p>
</div>
<div>
<h4 className="font-medium">🚛 Transport terrestre</h4>
<h4 className="font-medium flex items-center gap-1"><Truck className="w-4 h-4" /> Transport terrestre</h4>
<p className="text-sm">Évite les surcharges sur les camions et wagons de pré/post-acheminement.</p>
</div>
</div>
@ -106,7 +107,7 @@ export default function VGMPage() {
{/* VGM Components */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Composants du VGM</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Composants du VGM</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="bg-gray-50 p-4 rounded-lg border mb-4">
@ -131,7 +132,7 @@ export default function VGMPage() {
{/* Methods */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">🔬 Méthodes de Détermination</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Microscope className="w-5 h-5" /> Méthodes de Détermination</h2>
<div className="space-y-4">
{methodesPesee.map((method) => (
<Card key={method.method} className="bg-white">
@ -186,7 +187,7 @@ export default function VGMPage() {
{/* Responsibility */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">👤 Responsabilités</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><User className="w-5 h-5" /> Responsabilités</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">Expéditeur (Shipper)</h4>
@ -213,7 +214,7 @@ export default function VGMPage() {
{/* Tolerances */}
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">📏 Tolérances</h3>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Ruler className="w-5 h-5" /> Tolérances</h3>
<div className="bg-white p-4 rounded-lg border">
<p className="text-gray-600 mb-3">
Les tolérances varient selon les compagnies maritimes et les ports, mais généralement :
@ -234,7 +235,7 @@ export default function VGMPage() {
{/* Sanctions */}
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4"> Sanctions par Région</h2>
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Sanctions par Région</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-3">
@ -252,7 +253,7 @@ export default function VGMPage() {
{/* Tips */}
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3">💡 Bonnes Pratiques</h3>
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Bonnes Pratiques</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
<li>Transmettre le VGM au moins 24-48h avant le cut-off</li>
<li>Utiliser des balances étalonnées et certifiées</li>

View File

@ -52,7 +52,7 @@ export default function LandingPage() {
const features = [
{
icon: BarChart3,
title: 'Dashboard Analytics',
title: 'Tableau de bord',
description:
'Suivez tous vos KPIs en temps réel : bookings, volumes, revenus et alertes personnalisées.',
color: 'from-blue-500 to-cyan-500',
@ -60,7 +60,7 @@ export default function LandingPage() {
},
{
icon: Package,
title: 'Gestion des Bookings',
title: 'Gestion des Réservations',
description: 'Créez, gérez et suivez vos réservations maritimes LCL/FCL avec un historique complet.',
color: 'from-purple-500 to-pink-500',
link: '/dashboard/bookings',
@ -74,7 +74,7 @@ export default function LandingPage() {
},
{
icon: Search,
title: 'Track & Trace',
title: 'Suivi des expéditions',
description:
'Suivez vos conteneurs en temps réel auprès de 10+ transporteurs majeurs (Maersk, MSC, CMA CGM...).',
color: 'from-green-500 to-emerald-500',
@ -101,13 +101,13 @@ export default function LandingPage() {
const tools = [
{
icon: LayoutDashboard,
title: 'Dashboard',
title: 'Tableau de bord',
description: 'Vue d\'ensemble de votre activité maritime',
link: '/dashboard',
},
{
icon: Package,
title: 'Mes Bookings',
title: 'Mes Réservations',
description: 'Gérez toutes vos réservations en un seul endroit',
link: '/dashboard/bookings',
},
@ -119,7 +119,7 @@ export default function LandingPage() {
},
{
icon: Search,
title: 'Track & Trace',
title: 'Suivi des expéditions',
description: 'Suivez vos conteneurs en temps réel',
link: '/dashboard/track-trace',
},
@ -158,7 +158,7 @@ export default function LandingPage() {
{ text: 'Support par email', included: true },
{ text: 'Gestion des documents', included: false },
{ text: 'Notifications temps réel', included: false },
{ text: 'API access', included: false },
{ text: 'Accès API', included: false },
],
cta: 'Commencer gratuitement',
highlighted: false,
@ -176,7 +176,7 @@ export default function LandingPage() {
{ text: 'Support prioritaire', included: true },
{ text: 'Gestion des documents', included: true },
{ text: 'Notifications temps réel', included: true },
{ text: 'API access', included: false },
{ text: 'Accès API', included: false },
],
cta: 'Essai gratuit 14 jours',
highlighted: true,
@ -187,10 +187,10 @@ export default function LandingPage() {
period: '',
description: 'Pour les grandes entreprises',
features: [
{ text: 'Tout Professional +', included: true },
{ text: 'API access complet', included: true },
{ text: 'Tout Professionnel +', included: true },
{ text: 'Accès API complet', included: true },
{ text: 'Intégrations personnalisées', included: true },
{ text: 'Account manager dédié', included: true },
{ text: 'Responsable de compte dédié', included: true },
{ text: 'SLA garanti 99.9%', included: true },
{ text: 'Formation sur site', included: true },
{ text: 'Multi-organisations', included: true },
@ -323,7 +323,7 @@ export default function LandingPage() {
href="/dashboard"
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
>
<span>Accéder au Dashboard</span>
<span>Accéder au tableau de bord</span>
<LayoutDashboard className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
) : (
@ -709,13 +709,13 @@ export default function LandingPage() {
{
step: '03',
title: 'Réservez',
description: 'Confirmez votre booking en un clic',
description: 'Confirmez votre réservation en un clic',
icon: CheckCircle2,
},
{
step: '04',
title: 'Suivez',
description: 'Trackez votre envoi en temps réel',
description: 'Suivez votre envoi en temps réel',
icon: Container,
},
].map((step, index) => {
@ -833,7 +833,7 @@ export default function LandingPage() {
href="/dashboard"
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
>
<span>Accéder au Dashboard</span>
<span>Accéder au tableau de bord</span>
<LayoutDashboard className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
) : (

View File

@ -0,0 +1,244 @@
/**
* Export Button Component
*
* Reusable component for exporting data to CSV or Excel
*/
'use client';
import { useState, useRef, useEffect } from 'react';
import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
interface ExportButtonProps<T> {
data: T[];
filename: string;
columns: {
key: keyof T | string;
label: string;
format?: (value: any, row: T) => string;
}[];
disabled?: boolean;
}
export default function ExportButton<T extends Record<string, any>>({
data,
filename,
columns,
disabled = false,
}: ExportButtonProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const formatValue = (value: any): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'boolean') return value ? 'Oui' : 'Non';
if (value instanceof Date) return value.toLocaleDateString('fr-FR');
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
};
const getNestedValue = (obj: T, path: string): any => {
return path.split('.').reduce((acc, part) => acc?.[part], obj as any);
};
const generateCSV = (): string => {
// Headers
const headers = columns.map(col => `"${col.label.replace(/"/g, '""')}"`).join(';');
// Rows
const rows = data.map(row => {
return columns
.map(col => {
const value = getNestedValue(row, col.key as string);
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
// Escape quotes and wrap in quotes
return `"${formattedValue.replace(/"/g, '""')}"`;
})
.join(';');
});
return [headers, ...rows].join('\n');
};
const downloadFile = (content: string, mimeType: string, extension: string) => {
const BOM = '\uFEFF'; // UTF-8 BOM for Excel compatibility
const blob = new Blob([BOM + content], { type: `${mimeType};charset=utf-8` });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${filename}_${new Date().toISOString().split('T')[0]}.${extension}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const handleExportCSV = async () => {
setIsExporting(true);
try {
const csv = generateCSV();
downloadFile(csv, 'text/csv', 'csv');
} catch (error) {
console.error('Export CSV error:', error);
alert('Erreur lors de l\'export CSV');
} finally {
setIsExporting(false);
setIsOpen(false);
}
};
const handleExportExcel = async () => {
setIsExporting(true);
try {
// Generate Excel-compatible XML (SpreadsheetML)
const xmlHeader = `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Styles>
<Style ss:ID="Header">
<Font ss:Bold="1" ss:Color="#FFFFFF"/>
<Interior ss:Color="#3B82F6" ss:Pattern="Solid"/>
<Alignment ss:Horizontal="Center" ss:Vertical="Center"/>
</Style>
<Style ss:ID="Default">
<Alignment ss:Vertical="Center"/>
</Style>
</Styles>
<Worksheet ss:Name="Export">
<Table>`;
// Column widths
const columnWidths = columns.map(() => '<Column ss:AutoFitWidth="1" ss:Width="120"/>').join('');
// Header row
const headerRow = `<Row ss:StyleID="Header">
${columns.map(col => `<Cell><Data ss:Type="String">${escapeXml(col.label)}</Data></Cell>`).join('')}
</Row>`;
// Data rows
const dataRows = data
.map(row => {
const cells = columns
.map(col => {
const value = getNestedValue(row, col.key as string);
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
const type = typeof value === 'number' ? 'Number' : 'String';
return `<Cell ss:StyleID="Default"><Data ss:Type="${type}">${escapeXml(formattedValue)}</Data></Cell>`;
})
.join('');
return `<Row>${cells}</Row>`;
})
.join('');
const xmlFooter = `</Table>
</Worksheet>
</Workbook>`;
const xmlContent = xmlHeader + columnWidths + headerRow + dataRows + xmlFooter;
downloadFile(xmlContent, 'application/vnd.ms-excel', 'xls');
} catch (error) {
console.error('Export Excel error:', error);
alert('Erreur lors de l\'export Excel');
} finally {
setIsExporting(false);
setIsOpen(false);
}
};
const escapeXml = (str: string): string => {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
disabled={disabled || data.length === 0 || isExporting}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isExporting ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-600"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Export en cours...
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Exporter
<ChevronDown className="ml-2 h-4 w-4" />
</>
)}
</button>
{/* Dropdown Menu */}
{isOpen && !isExporting && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
<div className="py-1">
<button
onClick={handleExportCSV}
className="flex items-center w-full px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<FileText className="mr-3 h-4 w-4 text-green-600" />
<div className="text-left">
<div className="font-medium">Export CSV</div>
<div className="text-xs text-gray-500">Compatible Excel, Google Sheets</div>
</div>
</button>
<button
onClick={handleExportExcel}
className="flex items-center w-full px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<FileSpreadsheet className="mr-3 h-4 w-4 text-blue-600" />
<div className="text-left">
<div className="font-medium">Export Excel</div>
<div className="text-xs text-gray-500">Format .xls natif</div>
</div>
</button>
</div>
<div className="border-t border-gray-100 px-4 py-2">
<p className="text-xs text-gray-500">
{data.length} ligne{data.length > 1 ? 's' : ''} à exporter
</p>
</div>
</div>
)}
</div>
);
}

View File

@ -11,6 +11,18 @@ import { useState, useRef, useEffect } from 'react';
import { listNotifications, markNotificationAsRead, markAllNotificationsAsRead } from '@/lib/api';
import type { NotificationResponse } from '@/types/api';
import NotificationPanel from './NotificationPanel';
import {
CheckCircle,
RefreshCw,
XCircle,
DollarSign,
Ship,
Settings,
AlertTriangle,
Bell,
Megaphone,
type LucideIcon,
} from 'lucide-react';
export default function NotificationDropdown() {
const [isOpen, setIsOpen] = useState(false);
@ -83,17 +95,17 @@ export default function NotificationDropdown() {
return colors[priority as keyof typeof colors] || colors.low;
};
const getNotificationIcon = (type: string) => {
const icons: Record<string, string> = {
BOOKING_CONFIRMED: '✅',
BOOKING_UPDATED: '🔄',
BOOKING_CANCELLED: '❌',
RATE_ALERT: '💰',
CARRIER_UPDATE: '🚢',
SYSTEM: '⚙️',
WARNING: '⚠️',
const getNotificationIcon = (type: string): LucideIcon => {
const icons: Record<string, LucideIcon> = {
BOOKING_CONFIRMED: CheckCircle,
BOOKING_UPDATED: RefreshCw,
BOOKING_CANCELLED: XCircle,
RATE_ALERT: DollarSign,
CARRIER_UPDATE: Ship,
SYSTEM: Settings,
WARNING: AlertTriangle,
};
return icons[type] || '📢';
return icons[type] || Megaphone;
};
const formatTime = (dateString: string) => {
@ -104,10 +116,10 @@ export default function NotificationDropdown() {
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffMins < 1) return 'À l\'instant';
if (diffMins < 60) return `Il y a ${diffMins}min`;
if (diffHours < 24) return `Il y a ${diffHours}h`;
if (diffDays < 7) return `Il y a ${diffDays}j`;
return date.toLocaleDateString();
};
@ -146,7 +158,7 @@ export default function NotificationDropdown() {
disabled={markAllAsReadMutation.isPending}
className="text-xs text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
>
Mark all as read
Tout marquer comme lu
</button>
)}
</div>
@ -154,11 +166,11 @@ export default function NotificationDropdown() {
{/* Notifications List */}
<div className="max-h-96 overflow-y-auto">
{isLoading ? (
<div className="p-4 text-center text-sm text-gray-500">Loading notifications...</div>
<div className="p-4 text-center text-sm text-gray-500">Chargement des notifications...</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center">
<div className="text-4xl mb-2">🔔</div>
<p className="text-sm text-gray-500">No new notifications</p>
<Bell className="h-10 w-10 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">Aucune nouvelle notification</p>
</div>
) : (
<div className="divide-y">
@ -172,8 +184,8 @@ export default function NotificationDropdown() {
}`}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 text-2xl">
{getNotificationIcon(notification.type)}
<div className="flex-shrink-0">
{(() => { const Icon = getNotificationIcon(notification.type); return <Icon className="h-5 w-5 text-gray-500" />; })()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
@ -210,7 +222,7 @@ export default function NotificationDropdown() {
}}
className="w-full text-center text-sm text-blue-600 hover:text-blue-800 font-medium py-2 hover:bg-blue-50 rounded transition-colors"
>
View all notifications
Voir toutes les notifications
</button>
</div>
</div>

View File

@ -16,7 +16,7 @@ import {
deleteNotification
} from '@/lib/api';
import type { NotificationResponse } from '@/types/api';
import { X, Trash2, CheckCheck, Filter } from 'lucide-react';
import { X, Trash2, CheckCheck, Filter, Bell, Package, RefreshCw, XCircle, CheckCircle, Mail, Timer, FileText, Megaphone, User, Building2 } from 'lucide-react';
interface NotificationPanelProps {
isOpen: boolean;
@ -83,7 +83,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
e.stopPropagation();
if (confirm('Are you sure you want to delete this notification?')) {
if (confirm('Voulez-vous vraiment supprimer cette notification ?')) {
deleteNotificationMutation.mutate(notificationId);
}
};
@ -98,22 +98,22 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300';
};
const getNotificationIcon = (type: string) => {
const icons: Record<string, string> = {
booking_created: '📦',
booking_updated: '🔄',
booking_cancelled: '❌',
booking_confirmed: '✅',
csv_booking_accepted: '✅',
csv_booking_rejected: '❌',
csv_booking_request_sent: '📧',
rate_quote_expiring: '⏰',
document_uploaded: '📄',
system_announcement: '📢',
user_invited: '👤',
organization_update: '🏢',
const getNotificationIconComponent = (type: string) => {
const icons: Record<string, typeof Bell> = {
booking_created: Package,
booking_updated: RefreshCw,
booking_cancelled: XCircle,
booking_confirmed: CheckCircle,
csv_booking_accepted: CheckCircle,
csv_booking_rejected: XCircle,
csv_booking_request_sent: Mail,
rate_quote_expiring: Timer,
document_uploaded: FileText,
system_announcement: Megaphone,
user_invited: User,
organization_update: Building2,
};
return icons[type.toLowerCase()] || '🔔';
return icons[type.toLowerCase()] || Bell;
};
const formatTime = (dateString: string) => {
@ -124,13 +124,13 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
if (diffMins < 1) return 'À l\'instant';
if (diffMins < 60) return `Il y a ${diffMins}min`;
if (diffHours < 24) return `Il y a ${diffHours}h`;
if (diffDays < 7) return `Il y a ${diffDays}j`;
return date.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
});
};
@ -158,7 +158,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-600"
aria-label="Close panel"
aria-label="Fermer le panneau"
>
<X className="w-6 h-6" />
</button>
@ -182,7 +182,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
}`}
>
{filter.charAt(0).toUpperCase() + filter.slice(1)}
{filter === 'all' ? 'Toutes' : filter === 'unread' ? 'Non lues' : 'Lues'}
</button>
))}
</div>
@ -194,7 +194,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
className="flex items-center space-x-1 text-sm text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
>
<CheckCheck className="w-4 h-4" />
<span>Mark all as read</span>
<span>Tout marquer comme lu</span>
</button>
)}
</div>
@ -205,18 +205,18 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
<p className="text-sm text-gray-500">Loading notifications...</p>
<p className="text-sm text-gray-500">Chargement des notifications...</p>
</div>
</div>
) : notifications.length === 0 ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="text-6xl mb-4">🔔</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">No notifications</h3>
<Bell className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">Aucune notification</h3>
<p className="text-sm text-gray-500">
{selectedFilter === 'unread'
? "You're all caught up!"
: 'No notifications to display'}
? 'Vous êtes à jour !'
: 'Aucune notification à afficher'}
</p>
</div>
</div>
@ -232,8 +232,8 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
>
<div className="flex items-start space-x-4">
{/* Icon */}
<div className="flex-shrink-0 text-3xl">
{getNotificationIcon(notification.type)}
<div className="flex-shrink-0">
{(() => { const Icon = getNotificationIconComponent(notification.type); return <Icon className="h-6 w-6 text-gray-500" />; })()}
</div>
{/* Content */}
@ -250,7 +250,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
<button
onClick={(e) => handleDelete(e, notification.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-red-100 rounded"
title="Delete notification"
title="Supprimer la notification"
>
<Trash2 className="w-4 h-4 text-red-600" />
</button>
@ -287,7 +287,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
</div>
{notification.actionUrl && (
<span className="text-xs text-blue-600 font-medium group-hover:underline">
View details
Voir les tails
</span>
)}
</div>
@ -303,7 +303,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
{totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-4 border-t bg-gray-50">
<div className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
Page {currentPage} sur {totalPages}
</div>
<div className="flex space-x-2">
<button
@ -311,14 +311,14 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
disabled={currentPage === 1}
className="px-3 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
Précédent
</button>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
Suivant
</button>
</div>
</div>

View File

@ -3,44 +3,45 @@
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Users, Building2, Package, FileText, BarChart3, Settings, type LucideIcon } from 'lucide-react';
interface AdminMenuItem {
name: string;
href: string;
icon: string;
icon: LucideIcon;
description: string;
}
const adminMenuItems: AdminMenuItem[] = [
{
name: 'Users',
name: 'Utilisateurs',
href: '/dashboard/admin/users',
icon: '👥',
description: 'Manage users and permissions',
icon: Users,
description: 'Gérer les utilisateurs et les permissions',
},
{
name: 'Organizations',
name: 'Organisations',
href: '/dashboard/admin/organizations',
icon: '🏢',
description: 'Manage organizations and companies',
icon: Building2,
description: 'Gérer les organisations et entreprises',
},
{
name: 'Bookings',
name: 'Réservations',
href: '/dashboard/admin/bookings',
icon: '📦',
description: 'View and manage all bookings',
icon: Package,
description: 'Consulter et gérer toutes les réservations',
},
{
name: 'Documents',
href: '/dashboard/admin/documents',
icon: '📄',
description: 'Manage organization documents',
icon: FileText,
description: 'Gérer les documents des organisations',
},
{
name: 'CSV Rates',
name: 'Tarifs CSV',
href: '/dashboard/admin/csv-rates',
icon: '📊',
description: 'Upload and manage CSV rates',
icon: BarChart3,
description: 'Importer et gérer les tarifs CSV',
},
];
@ -84,8 +85,8 @@ export default function AdminPanelDropdown() {
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<span className="mr-3 text-xl"></span>
<span className="flex-1 text-left">Admin Panel</span>
<Settings className="mr-3 h-5 w-5" />
<span className="flex-1 text-left">Administration</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
@ -107,6 +108,7 @@ export default function AdminPanelDropdown() {
<div className="py-2">
{adminMenuItems.map(item => {
const isActive = pathname.startsWith(item.href);
const IconComponent = item.icon;
return (
<Link
key={item.name}
@ -115,7 +117,7 @@ export default function AdminPanelDropdown() {
isActive ? 'bg-blue-50' : ''
}`}
>
<span className="text-2xl mr-3 mt-0.5">{item.icon}</span>
<IconComponent className="h-5 w-5 mr-3 mt-0.5 text-gray-500" />
<div className="flex-1">
<div className={`text-sm font-medium ${isActive ? 'text-blue-700' : 'text-gray-900'}`}>
{item.name}