xpeditis2.0/apps/frontend/app/dashboard/booking/new/page.tsx
David 890bc189ee
Some checks failed
CI/CD Pipeline - Xpeditis PreProd / Frontend - Build & Test (push) Failing after 5m31s
CI/CD Pipeline - Xpeditis PreProd / Frontend - Docker Build & Push (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Backend - Build & Test (push) Failing after 5m42s
CI/CD Pipeline - Xpeditis PreProd / Backend - Docker Build & Push (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Deploy to PreProd Server (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Run Smoke Tests (push) Has been skipped
fix v0.2
2025-11-12 18:00:33 +01:00

661 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 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 [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 success page
router.push(`/dashboard/bookings?success=true&id=${result.id}`);
} 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;
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">
<div className="flex items-center justify-between">
{[1, 2, 3].map(step => (
<div key={step} className="flex items-center 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">
<span className="text-2xl mr-3"></span>
<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">
<span className="text-2xl mr-3">📋</span>
<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">
📧 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"
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
required
/>
<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>
);
}