Some checks failed
CI/CD Pipeline - Xpeditis PreProd / Backend - Build & Test (push) Failing after 5m51s
CI/CD Pipeline - Xpeditis PreProd / Backend - Docker Build & Push (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Frontend - Build & Test (push) Successful in 10m57s
CI/CD Pipeline - Xpeditis PreProd / Frontend - Docker Build & Push (push) Failing after 12m28s
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
869 lines
36 KiB
TypeScript
869 lines
36 KiB
TypeScript
/**
|
||
* Multi-Step Booking Form
|
||
*
|
||
* Create a new booking in 4 steps:
|
||
* 1. Select Rate Quote
|
||
* 2. Shipper & Consignee Information
|
||
* 3. Container Details
|
||
* 4. Review & Confirmation
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { useRouter, useSearchParams } from 'next/navigation';
|
||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||
import { createBooking } from '@/lib/api';
|
||
|
||
type Step = 1 | 2 | 3 | 4;
|
||
|
||
interface Party {
|
||
name: string;
|
||
address: string;
|
||
city: string;
|
||
postalCode: string;
|
||
country: string;
|
||
contactName: string;
|
||
contactEmail: string;
|
||
contactPhone: string;
|
||
}
|
||
|
||
interface Container {
|
||
type: string;
|
||
quantity: number;
|
||
weight?: number;
|
||
temperature?: number;
|
||
isHazmat: boolean;
|
||
hazmatClass?: string;
|
||
commodityDescription: string;
|
||
}
|
||
|
||
interface BookingFormData {
|
||
rateQuoteId: string;
|
||
shipper: Party;
|
||
consignee: Party;
|
||
containers: Container[];
|
||
specialInstructions?: string;
|
||
}
|
||
|
||
const emptyParty: Party = {
|
||
name: '',
|
||
address: '',
|
||
city: '',
|
||
postalCode: '',
|
||
country: '',
|
||
contactName: '',
|
||
contactEmail: '',
|
||
contactPhone: '',
|
||
};
|
||
|
||
const emptyContainer: Container = {
|
||
type: '40HC',
|
||
quantity: 1,
|
||
isHazmat: false,
|
||
commodityDescription: '',
|
||
};
|
||
|
||
export default function NewBookingPage() {
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
const preselectedQuoteId = searchParams.get('quoteId');
|
||
|
||
const [currentStep, setCurrentStep] = useState<Step>(1);
|
||
const [formData, setFormData] = useState<BookingFormData>({
|
||
rateQuoteId: preselectedQuoteId || '',
|
||
shipper: { ...emptyParty },
|
||
consignee: { ...emptyParty },
|
||
containers: [{ ...emptyContainer }],
|
||
specialInstructions: '',
|
||
});
|
||
|
||
const [error, setError] = useState('');
|
||
|
||
// Fetch preselected quote if provided
|
||
// TODO: Implement rate quote getById API endpoint
|
||
const { data: preselectedQuote } = useQuery({
|
||
queryKey: ['rate-quote', preselectedQuoteId],
|
||
queryFn: async () => {
|
||
// Temporarily disabled - API endpoint not yet implemented
|
||
return null;
|
||
},
|
||
enabled: false, // Disabled until API is implemented
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (preselectedQuote) {
|
||
setFormData(prev => ({ ...prev, rateQuoteId: preselectedQuote.id }));
|
||
}
|
||
}, [preselectedQuote]);
|
||
|
||
// Create booking mutation
|
||
const createBookingMutation = useMutation({
|
||
mutationFn: (data: BookingFormData) => {
|
||
// TODO: Transform BookingFormData to CreateBookingRequest format
|
||
// Temporary type assertion until proper transformation is implemented
|
||
return createBooking(data as any);
|
||
},
|
||
onSuccess: booking => {
|
||
router.push(`/dashboard/bookings/${booking.id}`);
|
||
},
|
||
onError: (err: any) => {
|
||
setError(err.response?.data?.message || 'Failed to create booking');
|
||
},
|
||
});
|
||
|
||
const handleNext = () => {
|
||
setError('');
|
||
if (currentStep < 4) {
|
||
setCurrentStep(prev => (prev + 1) as Step);
|
||
}
|
||
};
|
||
|
||
const handleBack = () => {
|
||
setError('');
|
||
if (currentStep > 1) {
|
||
setCurrentStep(prev => (prev - 1) as Step);
|
||
}
|
||
};
|
||
|
||
const handleSubmit = () => {
|
||
setError('');
|
||
createBookingMutation.mutate(formData);
|
||
};
|
||
|
||
const updateParty = (type: 'shipper' | 'consignee', field: keyof Party, value: string) => {
|
||
setFormData(prev => ({
|
||
...prev,
|
||
[type]: {
|
||
...prev[type],
|
||
[field]: value,
|
||
},
|
||
}));
|
||
};
|
||
|
||
const updateContainer = (index: number, field: keyof Container, value: any) => {
|
||
setFormData(prev => ({
|
||
...prev,
|
||
containers: prev.containers.map((c, i) => (i === index ? { ...c, [field]: value } : c)),
|
||
}));
|
||
};
|
||
|
||
const addContainer = () => {
|
||
setFormData(prev => ({
|
||
...prev,
|
||
containers: [...prev.containers, { ...emptyContainer }],
|
||
}));
|
||
};
|
||
|
||
const removeContainer = (index: number) => {
|
||
if (formData.containers.length > 1) {
|
||
setFormData(prev => ({
|
||
...prev,
|
||
containers: prev.containers.filter((_, i) => i !== index),
|
||
}));
|
||
}
|
||
};
|
||
|
||
const isStepValid = (step: Step): boolean => {
|
||
switch (step) {
|
||
case 1:
|
||
return !!formData.rateQuoteId;
|
||
case 2:
|
||
return (
|
||
formData.shipper.name.trim() !== '' &&
|
||
formData.shipper.contactEmail.trim() !== '' &&
|
||
formData.consignee.name.trim() !== '' &&
|
||
formData.consignee.contactEmail.trim() !== ''
|
||
);
|
||
case 3:
|
||
return formData.containers.every(
|
||
c => c.commodityDescription.trim() !== '' && c.quantity > 0
|
||
);
|
||
case 4:
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="max-w-4xl mx-auto space-y-6">
|
||
{/* Header */}
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900">Create New Booking</h1>
|
||
<p className="text-sm text-gray-500 mt-1">Complete the booking process in 4 simple steps</p>
|
||
</div>
|
||
|
||
{/* Progress Steps */}
|
||
<div className="bg-white rounded-lg shadow p-6">
|
||
<nav aria-label="Progress">
|
||
<ol className="flex items-center justify-between">
|
||
{[
|
||
{ number: 1, name: 'Rate Quote' },
|
||
{ number: 2, name: 'Parties' },
|
||
{ number: 3, name: 'Containers' },
|
||
{ number: 4, name: 'Review' },
|
||
].map((step, idx) => (
|
||
<li key={step.number} className={`flex items-center ${idx !== 3 ? 'flex-1' : ''}`}>
|
||
<div className="flex flex-col items-center">
|
||
<div
|
||
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 ${
|
||
currentStep === step.number
|
||
? 'border-blue-600 bg-blue-600 text-white'
|
||
: currentStep > step.number
|
||
? 'border-green-600 bg-green-600 text-white'
|
||
: 'border-gray-300 bg-white text-gray-500'
|
||
}`}
|
||
>
|
||
{currentStep > step.number ? (
|
||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||
clipRule="evenodd"
|
||
/>
|
||
</svg>
|
||
) : (
|
||
<span className="text-sm font-semibold">{step.number}</span>
|
||
)}
|
||
</div>
|
||
<span
|
||
className={`mt-2 text-xs font-medium ${
|
||
currentStep === step.number
|
||
? 'text-blue-600'
|
||
: currentStep > step.number
|
||
? 'text-green-600'
|
||
: 'text-gray-500'
|
||
}`}
|
||
>
|
||
{step.name}
|
||
</span>
|
||
</div>
|
||
{idx !== 3 && (
|
||
<div
|
||
className={`flex-1 h-0.5 mx-4 ${
|
||
currentStep > step.number ? 'bg-green-600' : 'bg-gray-300'
|
||
}`}
|
||
/>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</nav>
|
||
</div>
|
||
|
||
{/* Error Message */}
|
||
{error && (
|
||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||
<div className="text-sm text-red-800">{error}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step Content */}
|
||
<div className="bg-white rounded-lg shadow p-6">
|
||
{/* Step 1: Rate Quote Selection */}
|
||
{currentStep === 1 && (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||
Step 1: Select Rate Quote
|
||
</h2>
|
||
{preselectedQuote ? (
|
||
<div className="border border-green-200 bg-green-50 rounded-lg p-4">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex items-center space-x-4">
|
||
<div className="flex-shrink-0">
|
||
{preselectedQuote.carrier.logoUrl ? (
|
||
<img
|
||
src={preselectedQuote.carrier.logoUrl}
|
||
alt={preselectedQuote.carrier.name}
|
||
className="h-12 w-12 object-contain"
|
||
/>
|
||
) : (
|
||
<div className="h-12 w-12 bg-blue-100 rounded flex items-center justify-center text-blue-600 font-semibold">
|
||
{preselectedQuote.carrier.name.substring(0, 2).toUpperCase()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-gray-900">
|
||
{preselectedQuote.carrier.name}
|
||
</h3>
|
||
<p className="text-sm text-gray-500">
|
||
{preselectedQuote.route.originPort} →{' '}
|
||
{preselectedQuote.route.destinationPort}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-2xl font-bold text-blue-600">
|
||
${preselectedQuote.pricing.totalAmount.toLocaleString()}
|
||
</div>
|
||
<div className="text-sm text-gray-500">
|
||
{preselectedQuote.pricing.currency}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 grid grid-cols-3 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-gray-500">ETD:</span>{' '}
|
||
<span className="font-medium">
|
||
{new Date(preselectedQuote.route.etd).toLocaleDateString()}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-500">Transit:</span>{' '}
|
||
<span className="font-medium">{preselectedQuote.route.transitDays} days</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-500">ETA:</span>{' '}
|
||
<span className="font-medium">
|
||
{new Date(preselectedQuote.route.eta).toLocaleDateString()}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-8">
|
||
<svg
|
||
className="mx-auto h-12 w-12 text-gray-400"
|
||
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>
|
||
<h3 className="mt-2 text-sm font-medium text-gray-900">No rate quote selected</h3>
|
||
<p className="mt-1 text-sm text-gray-500">
|
||
Please search for rates first and select a quote to book
|
||
</p>
|
||
<div className="mt-6">
|
||
<a
|
||
href="/dashboard/search"
|
||
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"
|
||
>
|
||
Search Rates
|
||
</a>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 2: Shipper & Consignee */}
|
||
{currentStep === 2 && (
|
||
<div className="space-y-6">
|
||
<h2 className="text-lg font-semibold text-gray-900">
|
||
Step 2: Shipper & Consignee Information
|
||
</h2>
|
||
|
||
{/* Shipper */}
|
||
<div>
|
||
<h3 className="text-md font-medium text-gray-900 mb-4">Shipper Details</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div className="md:col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700">Company Name *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.shipper.name}
|
||
onChange={e => updateParty('shipper', 'name', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700">Address *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.shipper.address}
|
||
onChange={e => updateParty('shipper', 'address', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">City *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.shipper.city}
|
||
onChange={e => updateParty('shipper', 'city', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Postal Code *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.shipper.postalCode}
|
||
onChange={e => updateParty('shipper', 'postalCode', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700">Country *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.shipper.country}
|
||
onChange={e => updateParty('shipper', 'country', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Contact Name *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.shipper.contactName}
|
||
onChange={e => updateParty('shipper', 'contactName', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Contact Email *</label>
|
||
<input
|
||
type="email"
|
||
required
|
||
value={formData.shipper.contactEmail}
|
||
onChange={e => updateParty('shipper', 'contactEmail', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700">Contact Phone *</label>
|
||
<input
|
||
type="tel"
|
||
required
|
||
value={formData.shipper.contactPhone}
|
||
onChange={e => updateParty('shipper', 'contactPhone', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<hr className="border-gray-200" />
|
||
|
||
{/* Consignee */}
|
||
<div>
|
||
<h3 className="text-md font-medium text-gray-900 mb-4">Consignee Details</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div className="md:col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700">Company Name *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.consignee.name}
|
||
onChange={e => updateParty('consignee', 'name', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700">Address *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.consignee.address}
|
||
onChange={e => updateParty('consignee', 'address', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">City *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.consignee.city}
|
||
onChange={e => updateParty('consignee', 'city', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Postal Code *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.consignee.postalCode}
|
||
onChange={e => updateParty('consignee', 'postalCode', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700">Country *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.consignee.country}
|
||
onChange={e => updateParty('consignee', 'country', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Contact Name *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.consignee.contactName}
|
||
onChange={e => updateParty('consignee', 'contactName', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Contact Email *</label>
|
||
<input
|
||
type="email"
|
||
required
|
||
value={formData.consignee.contactEmail}
|
||
onChange={e => updateParty('consignee', 'contactEmail', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700">Contact Phone *</label>
|
||
<input
|
||
type="tel"
|
||
required
|
||
value={formData.consignee.contactPhone}
|
||
onChange={e => updateParty('consignee', 'contactPhone', e.target.value)}
|
||
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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 3: Container Details */}
|
||
{currentStep === 3 && (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold text-gray-900">Step 3: Container Details</h2>
|
||
<button
|
||
type="button"
|
||
onClick={addContainer}
|
||
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||
>
|
||
<span className="mr-1">➕</span>
|
||
Add Container
|
||
</button>
|
||
</div>
|
||
|
||
{formData.containers.map((container, index) => (
|
||
<div key={index} className="border border-gray-200 rounded-lg p-4">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-md font-medium text-gray-900">Container {index + 1}</h3>
|
||
{formData.containers.length > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => removeContainer(index)}
|
||
className="text-red-600 hover:text-red-800 text-sm"
|
||
>
|
||
Remove
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">
|
||
Container Type *
|
||
</label>
|
||
<select
|
||
value={container.type}
|
||
onChange={e => updateContainer(index, 'type', e.target.value)}
|
||
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="20GP">20' GP</option>
|
||
<option value="40GP">40' GP</option>
|
||
<option value="40HC">40' HC</option>
|
||
<option value="45HC">45' HC</option>
|
||
<option value="20RF">20' Reefer</option>
|
||
<option value="40RF">40' Reefer</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Quantity *</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={container.quantity}
|
||
onChange={e =>
|
||
updateContainer(index, 'quantity', parseInt(e.target.value) || 1)
|
||
}
|
||
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"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Weight (kg)</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={container.weight || ''}
|
||
onChange={e =>
|
||
updateContainer(
|
||
index,
|
||
'weight',
|
||
e.target.value ? parseFloat(e.target.value) : undefined
|
||
)
|
||
}
|
||
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"
|
||
/>
|
||
</div>
|
||
|
||
{(container.type === '20RF' || container.type === '40RF') && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">
|
||
Temperature (°C)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={container.temperature || ''}
|
||
onChange={e =>
|
||
updateContainer(
|
||
index,
|
||
'temperature',
|
||
e.target.value ? parseFloat(e.target.value) : undefined
|
||
)
|
||
}
|
||
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"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="md:col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700">
|
||
Commodity Description *
|
||
</label>
|
||
<textarea
|
||
required
|
||
rows={2}
|
||
value={container.commodityDescription}
|
||
onChange={e => updateContainer(index, 'commodityDescription', e.target.value)}
|
||
placeholder="e.g., Electronics, Textiles, Machinery..."
|
||
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"
|
||
/>
|
||
</div>
|
||
|
||
<div className="md:col-span-2">
|
||
<div className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
id={`hazmat-${index}`}
|
||
checked={container.isHazmat}
|
||
onChange={e => updateContainer(index, 'isHazmat', e.target.checked)}
|
||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||
/>
|
||
<label
|
||
htmlFor={`hazmat-${index}`}
|
||
className="ml-2 block text-sm text-gray-900"
|
||
>
|
||
Contains Hazardous Materials
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{container.isHazmat && (
|
||
<div className="md:col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700">
|
||
Hazmat Class (IMO)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={container.hazmatClass || ''}
|
||
onChange={e => updateContainer(index, 'hazmatClass', e.target.value)}
|
||
placeholder="e.g., Class 3, Class 8"
|
||
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"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 4: Review & Confirmation */}
|
||
{currentStep === 4 && (
|
||
<div className="space-y-6">
|
||
<h2 className="text-lg font-semibold text-gray-900">Step 4: Review & Confirmation</h2>
|
||
|
||
{/* Rate Quote Summary */}
|
||
{preselectedQuote && (
|
||
<div>
|
||
<h3 className="text-md font-medium text-gray-900 mb-3">Rate Quote</h3>
|
||
<div className="bg-gray-50 rounded-lg p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<div className="font-semibold">{preselectedQuote.carrier.name}</div>
|
||
<div className="text-sm text-gray-600">
|
||
{preselectedQuote.route.originPort} →{' '}
|
||
{preselectedQuote.route.destinationPort}
|
||
</div>
|
||
<div className="text-sm text-gray-600">
|
||
Transit: {preselectedQuote.route.transitDays} days
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-xl font-bold text-blue-600">
|
||
${preselectedQuote.pricing.totalAmount.toLocaleString()}
|
||
</div>
|
||
<div className="text-sm text-gray-500">
|
||
{preselectedQuote.pricing.currency}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Shipper */}
|
||
<div>
|
||
<h3 className="text-md font-medium text-gray-900 mb-3">Shipper</h3>
|
||
<div className="bg-gray-50 rounded-lg p-4 text-sm">
|
||
<div className="font-semibold">{formData.shipper.name}</div>
|
||
<div className="text-gray-600">
|
||
{formData.shipper.address}, {formData.shipper.city}, {formData.shipper.postalCode}
|
||
, {formData.shipper.country}
|
||
</div>
|
||
<div className="text-gray-600 mt-2">
|
||
Contact: {formData.shipper.contactName} ({formData.shipper.contactEmail},{' '}
|
||
{formData.shipper.contactPhone})
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Consignee */}
|
||
<div>
|
||
<h3 className="text-md font-medium text-gray-900 mb-3">Consignee</h3>
|
||
<div className="bg-gray-50 rounded-lg p-4 text-sm">
|
||
<div className="font-semibold">{formData.consignee.name}</div>
|
||
<div className="text-gray-600">
|
||
{formData.consignee.address}, {formData.consignee.city},{' '}
|
||
{formData.consignee.postalCode}, {formData.consignee.country}
|
||
</div>
|
||
<div className="text-gray-600 mt-2">
|
||
Contact: {formData.consignee.contactName} ({formData.consignee.contactEmail},{' '}
|
||
{formData.consignee.contactPhone})
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Containers */}
|
||
<div>
|
||
<h3 className="text-md font-medium text-gray-900 mb-3">Containers</h3>
|
||
<div className="space-y-2">
|
||
{formData.containers.map((container, index) => (
|
||
<div key={index} className="bg-gray-50 rounded-lg p-4 text-sm">
|
||
<div className="font-semibold">
|
||
{container.quantity}x {container.type}
|
||
</div>
|
||
<div className="text-gray-600">Commodity: {container.commodityDescription}</div>
|
||
{container.weight && (
|
||
<div className="text-gray-600">Weight: {container.weight} kg</div>
|
||
)}
|
||
{container.isHazmat && (
|
||
<div className="text-red-600 font-medium">
|
||
⚠️ Hazmat {container.hazmatClass && `- ${container.hazmatClass}`}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Special Instructions */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Special Instructions (Optional)
|
||
</label>
|
||
<textarea
|
||
rows={4}
|
||
value={formData.specialInstructions || ''}
|
||
onChange={e => setFormData({ ...formData, specialInstructions: e.target.value })}
|
||
placeholder="Any special handling requirements, pickup instructions, etc."
|
||
className="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"
|
||
/>
|
||
</div>
|
||
|
||
{/* Terms */}
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||
<div className="text-sm text-yellow-800">
|
||
<p className="font-semibold mb-2">Please review carefully:</p>
|
||
<ul className="list-disc list-inside space-y-1">
|
||
<li>All information provided is accurate and complete</li>
|
||
<li>You agree to the carrier's terms and conditions</li>
|
||
<li>Final booking confirmation will be sent via email</li>
|
||
<li>Payment details will be provided separately</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Navigation Buttons */}
|
||
<div className="flex items-center justify-between bg-white rounded-lg shadow p-6">
|
||
<button
|
||
type="button"
|
||
onClick={handleBack}
|
||
disabled={currentStep === 1}
|
||
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<svg className="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 19l-7-7 7-7"
|
||
/>
|
||
</svg>
|
||
Back
|
||
</button>
|
||
|
||
{currentStep < 4 ? (
|
||
<button
|
||
type="button"
|
||
onClick={handleNext}
|
||
disabled={!isStepValid(currentStep)}
|
||
className="inline-flex items-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Next
|
||
<svg className="ml-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||
</svg>
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={handleSubmit}
|
||
disabled={createBookingMutation.isPending || !isStepValid(4)}
|
||
className="inline-flex items-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{createBookingMutation.isPending ? (
|
||
<>
|
||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||
Creating Booking...
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="mr-2">✓</span>
|
||
Confirm Booking
|
||
</>
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|