569 lines
20 KiB
TypeScript
569 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useRef } from 'react';
|
|
import { useParams } from 'next/navigation';
|
|
import Image from 'next/image';
|
|
import {
|
|
FileText,
|
|
Download,
|
|
Loader2,
|
|
XCircle,
|
|
Package,
|
|
Ship,
|
|
Clock,
|
|
AlertCircle,
|
|
ArrowRight,
|
|
Lock,
|
|
Eye,
|
|
EyeOff,
|
|
} from 'lucide-react';
|
|
|
|
interface Document {
|
|
id: string;
|
|
type: string;
|
|
fileName: string;
|
|
mimeType: string;
|
|
size: number;
|
|
downloadUrl: string;
|
|
}
|
|
|
|
interface BookingSummary {
|
|
id: string;
|
|
bookingNumber?: string;
|
|
carrierName: string;
|
|
origin: string;
|
|
destination: string;
|
|
routeDescription: string;
|
|
volumeCBM: number;
|
|
weightKG: number;
|
|
palletCount: number;
|
|
price: number;
|
|
currency: string;
|
|
transitDays: number;
|
|
containerType: string;
|
|
acceptedAt: string;
|
|
}
|
|
|
|
interface CarrierDocumentsData {
|
|
booking: BookingSummary;
|
|
documents: Document[];
|
|
}
|
|
|
|
interface AccessRequirements {
|
|
requiresPassword: boolean;
|
|
bookingNumber?: string;
|
|
status: string;
|
|
}
|
|
|
|
const documentTypeLabels: Record<string, string> = {
|
|
BILL_OF_LADING: 'Connaissement',
|
|
PACKING_LIST: 'Liste de colisage',
|
|
COMMERCIAL_INVOICE: 'Facture commerciale',
|
|
CERTIFICATE_OF_ORIGIN: "Certificat d'origine",
|
|
OTHER: 'Autre document',
|
|
};
|
|
|
|
const formatFileSize = (bytes: number): string => {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
};
|
|
|
|
const getFileIcon = (mimeType: string) => {
|
|
if (mimeType.includes('pdf')) return '📄';
|
|
if (mimeType.includes('image')) return '🖼️';
|
|
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return '📊';
|
|
if (mimeType.includes('word') || mimeType.includes('document')) return '📝';
|
|
return '📎';
|
|
};
|
|
|
|
export default function CarrierDocumentsPage() {
|
|
const params = useParams();
|
|
const token = params.token as string;
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [data, setData] = useState<CarrierDocumentsData | null>(null);
|
|
const [downloading, setDownloading] = useState<string | null>(null);
|
|
|
|
// Password protection state
|
|
const [requirements, setRequirements] = useState<AccessRequirements | null>(null);
|
|
const [password, setPassword] = useState('');
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
|
const [verifying, setVerifying] = useState(false);
|
|
|
|
const hasCalledApi = useRef(false);
|
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
|
|
|
// Check access requirements first
|
|
const checkRequirements = async () => {
|
|
if (!token) {
|
|
setError('Lien invalide');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`${apiUrl}/api/v1/csv-booking-actions/documents/${token}/requirements`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
let errorData;
|
|
try {
|
|
errorData = await response.json();
|
|
} catch {
|
|
errorData = { message: `Erreur HTTP ${response.status}` };
|
|
}
|
|
|
|
const errorMessage = errorData.message || 'Erreur lors du chargement';
|
|
|
|
if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
|
|
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
|
|
}
|
|
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const reqData: AccessRequirements = await response.json();
|
|
setRequirements(reqData);
|
|
|
|
// If booking is not accepted yet
|
|
if (reqData.status !== 'ACCEPTED') {
|
|
setError(
|
|
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
|
|
);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// If no password required, fetch documents directly
|
|
if (!reqData.requiresPassword) {
|
|
await fetchDocumentsWithoutPassword();
|
|
} else {
|
|
setLoading(false);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error checking requirements:', err);
|
|
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Fetch documents without password (legacy bookings)
|
|
const fetchDocumentsWithoutPassword = async () => {
|
|
try {
|
|
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let errorData;
|
|
try {
|
|
errorData = await response.json();
|
|
} catch {
|
|
errorData = { message: `Erreur HTTP ${response.status}` };
|
|
}
|
|
|
|
const errorMessage = errorData.message || 'Erreur lors du chargement des documents';
|
|
|
|
if (
|
|
errorMessage.includes('pas encore été acceptée') ||
|
|
errorMessage.includes('not accepted')
|
|
) {
|
|
throw new Error(
|
|
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
|
|
);
|
|
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
|
|
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
|
|
} else if (errorMessage.includes('Mot de passe requis') || errorMessage.includes('required')) {
|
|
// Password is now required, show the form
|
|
setRequirements({ requiresPassword: true, status: 'ACCEPTED' });
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const responseData = await response.json();
|
|
setData(responseData);
|
|
setLoading(false);
|
|
} catch (err) {
|
|
console.error('Error fetching documents:', err);
|
|
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Fetch documents with password
|
|
const fetchDocumentsWithPassword = async (pwd: string) => {
|
|
setVerifying(true);
|
|
setPasswordError(null);
|
|
|
|
try {
|
|
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ password: pwd }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let errorData;
|
|
try {
|
|
errorData = await response.json();
|
|
} catch {
|
|
errorData = { message: `Erreur HTTP ${response.status}` };
|
|
}
|
|
|
|
const errorMessage = errorData.message || 'Erreur lors de la vérification';
|
|
|
|
if (
|
|
response.status === 401 ||
|
|
errorMessage.includes('incorrect') ||
|
|
errorMessage.includes('invalid')
|
|
) {
|
|
setPasswordError('Mot de passe incorrect. Vérifiez votre email pour retrouver le mot de passe.');
|
|
setVerifying(false);
|
|
return;
|
|
}
|
|
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const responseData = await response.json();
|
|
setData(responseData);
|
|
setVerifying(false);
|
|
} catch (err) {
|
|
console.error('Error verifying password:', err);
|
|
setPasswordError(err instanceof Error ? err.message : 'Erreur lors de la vérification');
|
|
setVerifying(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (hasCalledApi.current) return;
|
|
hasCalledApi.current = true;
|
|
checkRequirements();
|
|
}, [token]);
|
|
|
|
const handlePasswordSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!password.trim()) {
|
|
setPasswordError('Veuillez entrer le mot de passe');
|
|
return;
|
|
}
|
|
fetchDocumentsWithPassword(password.trim());
|
|
};
|
|
|
|
const handleDownload = async (doc: Document) => {
|
|
setDownloading(doc.id);
|
|
|
|
try {
|
|
// The downloadUrl is already a signed URL, open it directly
|
|
window.open(doc.downloadUrl, '_blank');
|
|
} catch (err) {
|
|
console.error('Error downloading document:', err);
|
|
alert('Erreur lors du téléchargement. Veuillez réessayer.');
|
|
} finally {
|
|
// Small delay to show loading state
|
|
setTimeout(() => setDownloading(null), 500);
|
|
}
|
|
};
|
|
|
|
const handleRefresh = () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setData(null);
|
|
setRequirements(null);
|
|
setPassword('');
|
|
setPasswordError(null);
|
|
hasCalledApi.current = false;
|
|
checkRequirements();
|
|
};
|
|
|
|
// Loading state
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-brand-gray">
|
|
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
|
|
<Loader2 className="w-16 h-16 text-brand-turquoise mx-auto mb-4 animate-spin" />
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Chargement...</h1>
|
|
<p className="text-gray-600">Veuillez patienter</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Error state
|
|
if (error) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-brand-gray">
|
|
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
|
|
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
|
|
<p className="text-gray-600 mb-6">{error}</p>
|
|
<button
|
|
onClick={handleRefresh}
|
|
className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 font-medium transition-colors"
|
|
>
|
|
Réessayer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Password form state
|
|
if (requirements?.requiresPassword && !data) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-brand-gray p-4">
|
|
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full">
|
|
<div className="text-center mb-6">
|
|
<div className="mx-auto w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mb-4">
|
|
<Lock className="w-8 h-8 text-brand-turquoise" />
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Accès sécurisé</h1>
|
|
<p className="text-gray-600">
|
|
Cette page est protégée. Entrez le mot de passe reçu par email pour accéder aux
|
|
documents.
|
|
</p>
|
|
{requirements.bookingNumber && (
|
|
<p className="mt-2 text-sm text-gray-500">
|
|
Réservation: <span className="font-mono font-bold">{requirements.bookingNumber}</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
|
<div>
|
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
|
Mot de passe
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showPassword ? 'text' : 'password'}
|
|
id="password"
|
|
value={password}
|
|
onChange={e => setPassword(e.target.value.toUpperCase())}
|
|
placeholder="Ex: A3B7K9"
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-brand-turquoise text-center text-xl tracking-widest font-mono uppercase"
|
|
autoComplete="off"
|
|
autoFocus
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
|
>
|
|
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
|
</button>
|
|
</div>
|
|
{passwordError && (
|
|
<p className="mt-2 text-sm text-red-600 flex items-center gap-1">
|
|
<AlertCircle className="w-4 h-4" />
|
|
{passwordError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={verifying}
|
|
className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
{verifying ? (
|
|
<>
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
Vérification...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Lock className="w-5 h-5" />
|
|
Accéder aux documents
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
|
|
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
|
<p className="text-sm text-amber-800">
|
|
<strong>Où trouver le mot de passe ?</strong>
|
|
<br />
|
|
Le mot de passe vous a été envoyé dans l'email de confirmation de la réservation. Il
|
|
correspond aux 6 derniers caractères du numéro de devis.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!data) return null;
|
|
|
|
const { booking, documents } = data;
|
|
|
|
return (
|
|
<div className="min-h-screen bg-brand-gray">
|
|
{/* Header */}
|
|
<header className="bg-white shadow-sm border-b">
|
|
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Image
|
|
src="/assets/logos/logo-black.svg"
|
|
alt="Xpeditis"
|
|
width={40}
|
|
height={48}
|
|
className="h-auto"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleRefresh}
|
|
className="text-sm text-brand-turquoise hover:text-brand-navy font-medium"
|
|
>
|
|
Actualiser
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="max-w-4xl mx-auto px-4 py-8">
|
|
{/* Booking Summary Card */}
|
|
<div className="bg-white rounded-xl shadow-md overflow-hidden mb-6">
|
|
<div className="bg-gradient-to-r from-brand-navy to-brand-navy/80 px-6 py-4">
|
|
<div className="flex items-center justify-center gap-4 text-white">
|
|
<span className="text-2xl font-bold">{booking.origin}</span>
|
|
<ArrowRight className="w-6 h-6" />
|
|
<span className="text-2xl font-bold">{booking.destination}</span>
|
|
</div>
|
|
{booking.bookingNumber && (
|
|
<p className="text-center text-white/70 text-sm mt-1">
|
|
N° {booking.bookingNumber}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
|
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
|
<p className="text-xs text-gray-500">Volume</p>
|
|
<p className="font-semibold text-gray-900">{booking.volumeCBM} CBM</p>
|
|
</div>
|
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
|
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
|
<p className="text-xs text-gray-500">Poids</p>
|
|
<p className="font-semibold text-gray-900">{booking.weightKG} kg</p>
|
|
</div>
|
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
|
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
|
<p className="text-xs text-gray-500">Transit</p>
|
|
<p className="font-semibold text-gray-900">{booking.transitDays} jours</p>
|
|
</div>
|
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
|
<Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
|
<p className="text-xs text-gray-500">Type</p>
|
|
<p className="font-semibold text-gray-900">{booking.containerType}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
|
<span className="text-gray-500">
|
|
Transporteur:{' '}
|
|
<span className="text-gray-900 font-medium">{booking.carrierName}</span>
|
|
</span>
|
|
<span className="text-gray-500">
|
|
Ref:{' '}
|
|
<span className="font-mono text-gray-900">
|
|
{booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Documents Section */}
|
|
<div className="bg-white rounded-xl shadow-md overflow-hidden">
|
|
<div className="px-6 py-4 border-b border-gray-100">
|
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
<FileText className="w-5 h-5 text-brand-turquoise" />
|
|
Documents ({documents.length})
|
|
</h2>
|
|
</div>
|
|
|
|
{documents.length === 0 ? (
|
|
<div className="p-8 text-center">
|
|
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
|
<p className="text-gray-600">Aucun document disponible pour le moment.</p>
|
|
<p className="text-gray-500 text-sm mt-1">
|
|
Les documents apparaîtront ici une fois ajoutés.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-100">
|
|
{documents.map(doc => (
|
|
<div
|
|
key={doc.id}
|
|
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-2xl">{getFileIcon(doc.mimeType)}</span>
|
|
<div>
|
|
<p className="font-medium text-gray-900">{doc.fileName}</p>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span className="text-xs px-2 py-0.5 bg-brand-turquoise/10 text-brand-navy rounded-full">
|
|
{documentTypeLabels[doc.type] || doc.type}
|
|
</span>
|
|
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => handleDownload(doc)}
|
|
disabled={downloading === doc.id}
|
|
className="flex items-center gap-2 px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
|
>
|
|
{downloading === doc.id ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
<span>...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Download className="w-4 h-4" />
|
|
<span>Télécharger</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<p className="mt-6 text-center text-sm text-gray-500">
|
|
Cette page affiche toujours les documents les plus récents de la réservation.
|
|
</p>
|
|
</main>
|
|
|
|
{/* Footer */}
|
|
<footer className="mt-auto py-6 text-center text-sm text-brand-navy/50">
|
|
<p>© {new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|