xpeditis2.0/apps/frontend/app/dashboard/booking/new/page.tsx
2026-03-18 15:11:09 +01:00

664 lines
27 KiB
TypeScript

/**
* CSV Booking Creation Page
*
* Multi-step form for creating a CSV-based booking request
*/
'use client';
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';
interface BookingForm {
// Rate data (pre-filled from search results)
carrierName: string;
carrierEmail: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
palletCount: number;
priceUSD: number;
priceEUR: number;
primaryCurrency: 'USD' | 'EUR';
transitDays: number;
containerType: string;
// Documents to upload
documents: File[];
// Optional notes
notes?: string;
}
const DOCUMENT_TYPES = [
{ value: 'BILL_OF_LADING', label: 'Bill of Lading (Connaissement)' },
{ value: 'PACKING_LIST', label: 'Packing List (Liste de colisage)' },
{ value: 'COMMERCIAL_INVOICE', label: 'Commercial Invoice (Facture commerciale)' },
{ value: 'CERTIFICATE_OF_ORIGIN', label: 'Certificate of Origin (Certificat d\'origine)' },
{ value: 'OTHER', label: 'Autre document' },
];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png'];
function NewBookingPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [termsAccepted, setTermsAccepted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<BookingForm>({
carrierName: '',
carrierEmail: '',
origin: '',
destination: '',
volumeCBM: 0,
weightKG: 0,
palletCount: 0,
priceUSD: 0,
priceEUR: 0,
primaryCurrency: 'EUR',
transitDays: 0,
containerType: '',
documents: [],
notes: '',
});
// Load rate data from URL params
useEffect(() => {
const rateDataParam = searchParams.get('rateData');
if (rateDataParam) {
try {
const rateData: CsvRateSearchResult = JSON.parse(decodeURIComponent(rateDataParam));
setFormData(prev => ({
...prev,
carrierName: rateData.companyName,
carrierEmail: rateData.companyEmail,
origin: rateData.origin,
destination: rateData.destination,
volumeCBM: parseFloat(searchParams.get('volumeCBM') || '0'),
weightKG: parseFloat(searchParams.get('weightKG') || '0'),
palletCount: parseInt(searchParams.get('palletCount') || '0'),
priceUSD: rateData.priceUSD,
priceEUR: rateData.priceEUR,
primaryCurrency: rateData.primaryCurrency as 'USD' | 'EUR',
transitDays: rateData.transitDays,
containerType: rateData.containerType,
}));
} catch (err) {
console.error('Failed to parse rate data:', err);
setError('Données de tarif invalides');
}
} else {
// No rate data - redirect back to search
router.push('/dashboard/search-advanced');
}
}, [searchParams, router]);
const handleFileChange = (files: FileList | null) => {
if (!files) return;
const newFiles: File[] = [];
const errors: string[] = [];
Array.from(files).forEach(file => {
// Check file size
if (file.size > MAX_FILE_SIZE) {
errors.push(`${file.name}: Fichier trop volumineux (max 5MB)`);
return;
}
// Check file type
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
if (!ACCEPTED_FILE_TYPES.includes(fileExtension)) {
errors.push(`${file.name}: Type de fichier non accepté`);
return;
}
newFiles.push(file);
});
if (errors.length > 0) {
setError(errors.join('\n'));
} else {
setError(null);
}
setFormData(prev => ({
...prev,
documents: [...prev.documents, ...newFiles],
}));
};
const removeDocument = (index: number) => {
setFormData(prev => ({
...prev,
documents: prev.documents.filter((_, i) => i !== index),
}));
};
const handleSubmit = async () => {
setIsSubmitting(true);
setError(null);
try {
// Create FormData for multipart upload
const formDataToSend = new FormData();
// Append all booking data
formDataToSend.append('carrierName', formData.carrierName);
formDataToSend.append('carrierEmail', formData.carrierEmail);
formDataToSend.append('origin', formData.origin);
formDataToSend.append('destination', formData.destination);
formDataToSend.append('volumeCBM', formData.volumeCBM.toString());
formDataToSend.append('weightKG', formData.weightKG.toString());
formDataToSend.append('palletCount', formData.palletCount.toString());
formDataToSend.append('priceUSD', formData.priceUSD.toString());
formDataToSend.append('priceEUR', formData.priceEUR.toString());
formDataToSend.append('primaryCurrency', formData.primaryCurrency);
formDataToSend.append('transitDays', formData.transitDays.toString());
formDataToSend.append('containerType', formData.containerType);
if (formData.notes) {
formDataToSend.append('notes', formData.notes);
}
// Append documents
formData.documents.forEach((file) => {
formDataToSend.append('documents', file);
});
// Send to API using client function
const result = await createCsvBooking(formDataToSend);
// Redirect to commission payment page
router.push(`/dashboard/booking/${result.id}/pay`);
} catch (err) {
console.error('Booking creation error:', err);
setError(err instanceof Error ? err.message : 'Une erreur est survenue');
} finally {
setIsSubmitting(false);
}
};
const canProceedToStep2 = formData.carrierName && formData.origin && formData.destination;
const canProceedToStep3 = formData.documents.length >= 1;
const canSubmit = canProceedToStep3 && termsAccepted;
const formatPrice = (price: number, currency: string) => {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: currency,
}).format(price);
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<button
onClick={() => router.back()}
className="mb-4 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
Retour aux résultats
</button>
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Nouvelle demande de réservation</h1>
<p className="text-gray-600">
Envoyez une demande de réservation directement au transporteur
</p>
</div>
</div>
{/* Progress Steps */}
<div className="mb-8 bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between">
{[1, 2, 3].map(step => (
<div key={step} className={`flex items-center ${step < 3 ? 'flex-1' : ''}`}>
<div className="flex items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
currentStep >= step
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
{step}
</div>
<div className="ml-3">
<p
className={`text-sm font-medium ${
currentStep >= step ? 'text-blue-600' : 'text-gray-500'
}`}
>
{step === 1 && 'Détails'}
{step === 2 && 'Documents'}
{step === 3 && 'Révision'}
</p>
</div>
</div>
{step < 3 && (
<div
className={`flex-1 h-1 mx-4 ${
currentStep > step ? 'bg-blue-600' : 'bg-gray-200'
}`}
/>
)}
</div>
))}
</div>
</div>
{/* Error Message */}
{error && (
<div className="mb-6 bg-red-50 border-2 border-red-200 rounded-lg p-4">
<div className="flex items-start">
<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>
</div>
</div>
</div>
)}
{/* Step Content */}
<div className="bg-white rounded-lg shadow-lg p-8">
{/* Step 1: Transport Details (Read-only) */}
{currentStep === 1 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Détails du transport
</h2>
<div className="space-y-6">
{/* Carrier Info */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Transporteur
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-600">Nom</p>
<p className="font-semibold text-gray-900">{formData.carrierName}</p>
</div>
<div>
<p className="text-sm text-gray-600">Email</p>
<p className="font-semibold text-gray-900">{formData.carrierEmail}</p>
</div>
</div>
</div>
{/* Route Info */}
<div className="bg-gray-50 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Trajet
</h3>
<div className="flex items-center justify-between">
<div className="text-center">
<p className="text-sm text-gray-600 mb-1">Origine</p>
<p className="text-xl font-bold text-blue-600">{formData.origin}</p>
</div>
<div className="flex-1 mx-8">
<div className="border-t-2 border-dashed border-gray-300 relative">
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white px-3">
<span className="text-sm text-gray-600">{formData.transitDays} jours</span>
</div>
</div>
</div>
<div className="text-center">
<p className="text-sm text-gray-600 mb-1">Destination</p>
<p className="text-xl font-bold text-blue-600">{formData.destination}</p>
</div>
</div>
</div>
{/* Shipment Details */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Volume</p>
<p className="text-lg font-bold text-gray-900">{formData.volumeCBM} CBM</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Poids</p>
<p className="text-lg font-bold text-gray-900">{formData.weightKG} kg</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Palettes</p>
<p className="text-lg font-bold text-gray-900">{formData.palletCount || 'N/A'}</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Type</p>
<p className="text-lg font-bold text-gray-900">{formData.containerType}</p>
</div>
</div>
{/* Pricing */}
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Prix estimé
</h3>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Prix en EUR</p>
<p className="text-3xl font-bold text-green-600">
{formatPrice(formData.priceEUR, 'EUR')}
</p>
</div>
{formData.priceUSD > 0 && (
<div className="text-right">
<p className="text-sm text-gray-600">Prix en USD</p>
<p className="text-xl font-semibold text-gray-700">
{formatPrice(formData.priceUSD, 'USD')}
</p>
</div>
)}
</div>
</div>
</div>
<div className="mt-8 flex justify-end">
<button
onClick={() => setCurrentStep(2)}
disabled={!canProceedToStep2}
className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold"
>
Continuer
</button>
</div>
</div>
)}
{/* Step 2: Document Upload */}
{currentStep === 2 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Documents requis
</h2>
<div className="mb-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg p-4">
<div className="flex items-start">
<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">
Veuillez télécharger au moins <strong>1 document</strong> pour continuer.
Formats acceptés: PDF, DOC, DOCX, JPG, PNG (max 5MB par fichier)
</p>
</div>
</div>
</div>
{/* File Upload */}
<div className="space-y-4 mb-8">
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-400 transition-colors">
<input
type="file"
id="file-upload"
multiple
accept={ACCEPTED_FILE_TYPES.join(',')}
onChange={(e) => handleFileChange(e.target.files)}
className="hidden"
/>
<label
htmlFor="file-upload"
className="cursor-pointer flex flex-col items-center"
>
<svg
className="w-16 h-16 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-lg font-semibold text-gray-700 mb-2">
Cliquez pour sélectionner des fichiers
</p>
<p className="text-sm text-gray-500">
ou glissez-déposez vos documents ici
</p>
</label>
</div>
{/* Document Type Suggestions */}
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm font-semibold text-gray-700 mb-2">
Documents recommandés :
</p>
<ul className="text-sm text-gray-600 space-y-1">
{DOCUMENT_TYPES.map(type => (
<li key={type.value}> {type.label}</li>
))}
</ul>
</div>
</div>
{/* Uploaded Files List */}
{formData.documents.length > 0 && (
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Documents sélectionnés ({formData.documents.length})
</h3>
<div className="space-y-2">
{formData.documents.map((file, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 rounded-lg p-4 border border-gray-200"
>
<div className="flex items-center space-x-3">
<svg
className="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<div>
<p className="font-medium text-gray-900">{file.name}</p>
<p className="text-xs text-gray-500">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<button
onClick={() => removeDocument(index)}
className="text-red-600 hover:text-red-800 font-medium text-sm"
>
Supprimer
</button>
</div>
))}
</div>
</div>
)}
{/* Optional Notes */}
<div className="mb-8">
<label className="block text-sm font-medium text-gray-700 mb-2">
Notes ou instructions spéciales (optionnel)
</label>
<textarea
value={formData.notes || ''}
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
rows={4}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Ajoutez des instructions spéciales pour le transporteur..."
/>
</div>
<div className="flex justify-between">
<button
onClick={() => setCurrentStep(1)}
className="px-8 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-semibold"
>
Retour
</button>
<button
onClick={() => setCurrentStep(3)}
disabled={!canProceedToStep3}
className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold"
>
Continuer
</button>
</div>
</div>
)}
{/* Step 3: Review & Submit */}
{currentStep === 3 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Révision et envoi
</h2>
<div className="space-y-6 mb-8">
{/* Summary */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Récapitulatif
</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Transporteur :</span>
<span className="font-semibold text-gray-900">{formData.carrierName}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Trajet :</span>
<span className="font-semibold text-gray-900">
{formData.origin} {formData.destination}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Volume :</span>
<span className="font-semibold text-gray-900">{formData.volumeCBM} CBM</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Poids :</span>
<span className="font-semibold text-gray-900">{formData.weightKG} kg</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Documents :</span>
<span className="font-semibold text-gray-900">
{formData.documents.length} fichier(s)
</span>
</div>
<div className="flex justify-between border-t pt-3 mt-3">
<span className="text-gray-900 font-semibold">Prix total :</span>
<span className="text-2xl font-bold text-green-600">
{formatPrice(formData.priceEUR, 'EUR')}
</span>
</div>
</div>
</div>
{/* 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">
<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">
<span className="mr-2">1.</span>
<span>
Votre demande sera <strong>envoyée par email</strong> au transporteur ({formData.carrierEmail})
</span>
</li>
<li className="flex items-start">
<span className="mr-2">2.</span>
<span>
Le transporteur recevra tous vos documents et détails de transport
</span>
</li>
<li className="flex items-start">
<span className="mr-2">3.</span>
<span>
Il pourra <strong>accepter ou refuser</strong> la demande directement depuis son email
</span>
</li>
<li className="flex items-start">
<span className="mr-2">4.</span>
<span>
Vous recevrez une <strong>notification</strong> dès que le transporteur répond (sous 7 jours maximum)
</span>
</li>
</ul>
</div>
{/* Terms */}
<div className="bg-gray-50 rounded-lg p-4">
<label className="flex items-start cursor-pointer">
<input
type="checkbox"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="ml-3 text-sm text-gray-700">
Je confirme que les informations fournies sont exactes et que j'accepte les{' '}
<a href="#" className="text-blue-600 hover:underline">
conditions générales
</a>
</span>
</label>
</div>
</div>
<div className="flex justify-between">
<button
onClick={() => setCurrentStep(2)}
disabled={isSubmitting}
className="px-8 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-semibold disabled:opacity-50"
>
← Retour
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold"
>
{isSubmitting ? (
<span className="flex items-center">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
Envoi en cours...
</span>
) : (
' Envoyer la demande'
)}
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
export default function NewBookingPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Chargement...</div>}>
<NewBookingPageContent />
</Suspense>
);
}