fix ui
This commit is contained in:
parent
3e654af8a3
commit
cf19c36586
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,13 +148,37 @@ 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>
|
||||
<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>
|
||||
Nouvelle Réservation
|
||||
</Link>
|
||||
<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"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nouvelle Réservation
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@ -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>
|
||||
|
||||
@ -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,21 +411,44 @@ export default function UserDocumentsPage() {
|
||||
Gérez tous les documents de vos réservations
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddDocumentClick}
|
||||
disabled={bookingsWithPendingStatus.length === 0}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Ajouter un document
|
||||
</button>
|
||||
<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}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Ajouter un document
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
@ -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>
|
||||
|
||||
@ -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 */}
|
||||
<span className="px-3 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full">
|
||||
{user?.role}
|
||||
</span>
|
||||
{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>
|
||||
|
||||
|
||||
@ -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 dé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>
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
Plus,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import ExportButton from '@/components/ExportButton';
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
@ -74,16 +75,32 @@ 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
|
||||
</Button>
|
||||
</Link>
|
||||
<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 */}
|
||||
@ -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>
|
||||
|
||||
@ -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'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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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,26 +223,48 @@ 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>
|
||||
Inviter un utilisateur
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/dashboard/settings/subscription"
|
||||
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>
|
||||
Mettre à niveau
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{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
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/dashboard/settings/subscription"
|
||||
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
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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'é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>
|
||||
|
||||
@ -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' 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 "All-in" pour éviter les surprises de surcharges</li>
|
||||
<li>Comparez les transitaires sur le total, pas seulement le fret de base</li>
|
||||
|
||||
@ -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' ou 40' 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' ou 40'</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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'Attention</h3>
|
||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Points d'Attention</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Toujours vérifier le classement tarifaire avant l'importation</li>
|
||||
<li>Conserver tous les documents 3 ans minimum (contrôle a posteriori)</li>
|
||||
|
||||
@ -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'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'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'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'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'acceptation par la compagnie maritime (certaines refusent certaines classes)</li>
|
||||
<li>Anticiper les surcharges DG (dangerous goods) qui peuvent être significatives</li>
|
||||
|
||||
@ -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'Arrivée</>}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{incoterms
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'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 "réserves" 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>
|
||||
|
||||
@ -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,35 +130,40 @@ 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) => (
|
||||
<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>
|
||||
<CardTitle className="text-xl mt-3 group-hover:text-blue-600 transition-colors">
|
||||
{topic.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-600">
|
||||
{topic.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topic.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
{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">
|
||||
<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}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-600">
|
||||
{topic.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topic.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer info */}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'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'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'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 où un conteneur n'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'avance)</li>
|
||||
<li>Respecter les cut-off avec une marge de sécurité (24h minimum)</li>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
244
apps/frontend/src/components/ExportButton.tsx
Normal file
244
apps/frontend/src/components/ExportButton.tsx
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 dé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>
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user