feature search
This commit is contained in:
parent
2069cfb69d
commit
15766af3b5
@ -37,7 +37,11 @@
|
|||||||
"Bash(npx tsc:*)",
|
"Bash(npx tsc:*)",
|
||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(npm run backend:dev:*)",
|
"Bash(npm run backend:dev:*)",
|
||||||
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MTkyNzc5OCwiZXhwIjoxNzYxOTI4Njk4fQ.fD6rTwj5Kc4PxnczmEgkLW-PA95VXufogo4vFBbsuMY\")"
|
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MTkyNzc5OCwiZXhwIjoxNzYxOTI4Njk4fQ.fD6rTwj5Kc4PxnczmEgkLW-PA95VXufogo4vFBbsuMY\")",
|
||||||
|
"Bash(docker-compose up:*)",
|
||||||
|
"Bash(npm run frontend:dev:*)",
|
||||||
|
"Bash(python3:*)",
|
||||||
|
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MjI5MjI1NCwiZXhwIjoxNzYyMjkzMTU0fQ.aCVXH9_UbfBm3-rH5PnBc0jGMqCOBSOkmqmv6UJP9xs\" curl -s -X POST http://localhost:4000/api/v1/rates/search-csv -H \"Content-Type: application/json\" -H \"Authorization: Bearer $TOKEN\" -d '{\"\"\"\"origin\"\"\"\":\"\"\"\"NLRTM\"\"\"\",\"\"\"\"destination\"\"\"\":\"\"\"\"USNYC\"\"\"\",\"\"\"\"volumeCBM\"\"\"\":5,\"\"\"\"weightKG\"\"\"\":1000,\"\"\"\"palletCount\"\"\"\":3}')"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@ -65,7 +65,18 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// CSV files are stored in infrastructure/storage/csv-storage/rates/
|
// CSV files are stored in infrastructure/storage/csv-storage/rates/
|
||||||
this.csvDirectory = path.join(__dirname, '..', '..', 'storage', 'csv-storage', 'rates');
|
// Use absolute path based on project root (works in both dev and production)
|
||||||
|
// In production, process.cwd() points to the backend app directory
|
||||||
|
// In development with nest start --watch, it also points to the backend directory
|
||||||
|
this.csvDirectory = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'src',
|
||||||
|
'infrastructure',
|
||||||
|
'storage',
|
||||||
|
'csv-storage',
|
||||||
|
'rates'
|
||||||
|
);
|
||||||
|
this.logger.log(`CSV directory initialized: ${this.csvDirectory}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadRatesFromCsv(filePath: string): Promise<CsvRate[]> {
|
async loadRatesFromCsv(filePath: string): Promise<CsvRate[]> {
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import Link from 'next/link';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import NotificationDropdown from '@/components/NotificationDropdown';
|
import NotificationDropdown from '@/components/NotificationDropdown';
|
||||||
import DebugUser from '@/components/DebugUser';
|
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
@ -22,6 +21,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
{ name: 'Dashboard', href: '/dashboard', icon: '📊' },
|
{ name: 'Dashboard', href: '/dashboard', icon: '📊' },
|
||||||
{ name: 'Bookings', href: '/dashboard/bookings', icon: '📦' },
|
{ name: 'Bookings', href: '/dashboard/bookings', icon: '📦' },
|
||||||
{ name: 'Search Rates', href: '/dashboard/search', icon: '🔍' },
|
{ name: 'Search Rates', href: '/dashboard/search', icon: '🔍' },
|
||||||
|
{ name: 'Search Advanced', href: '/dashboard/search-advanced', icon: '🔎' },
|
||||||
{ name: 'My Profile', href: '/dashboard/profile', icon: '👤' },
|
{ name: 'My Profile', href: '/dashboard/profile', icon: '👤' },
|
||||||
{ name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' },
|
{ name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' },
|
||||||
{ name: 'Users', href: '/dashboard/settings/users', icon: '👥' },
|
{ name: 'Users', href: '/dashboard/settings/users', icon: '👥' },
|
||||||
@ -157,9 +157,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
<main className="p-6">{children}</main>
|
<main className="p-6">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Debug panel */}
|
|
||||||
<DebugUser />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
534
apps/frontend/app/dashboard/search-advanced/page.tsx
Normal file
534
apps/frontend/app/dashboard/search-advanced/page.tsx
Normal file
@ -0,0 +1,534 @@
|
|||||||
|
/**
|
||||||
|
* Advanced Rate Search Page
|
||||||
|
*
|
||||||
|
* Complete search form with all filters and best options display
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
interface Package {
|
||||||
|
type: 'caisse' | 'colis' | 'palette' | 'autre';
|
||||||
|
quantity: number;
|
||||||
|
length: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
weight: number;
|
||||||
|
stackable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchForm {
|
||||||
|
// General
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
|
||||||
|
// Conditionnement
|
||||||
|
packages: Package[];
|
||||||
|
|
||||||
|
// Douane
|
||||||
|
eurDocument: boolean;
|
||||||
|
customsStop: boolean;
|
||||||
|
exportAssistance: boolean;
|
||||||
|
|
||||||
|
// Marchandise
|
||||||
|
dangerousGoods: boolean;
|
||||||
|
specialHandling: boolean;
|
||||||
|
|
||||||
|
// Manutention
|
||||||
|
tailgate: boolean;
|
||||||
|
straps: boolean;
|
||||||
|
thermalCover: boolean;
|
||||||
|
|
||||||
|
// Autres
|
||||||
|
regulatedProducts: boolean;
|
||||||
|
appointment: boolean;
|
||||||
|
insurance: boolean;
|
||||||
|
t1Document: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdvancedSearchPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [searchForm, setSearchForm] = useState<SearchForm>({
|
||||||
|
origin: '',
|
||||||
|
destination: '',
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
type: 'palette',
|
||||||
|
quantity: 1,
|
||||||
|
length: 120,
|
||||||
|
width: 80,
|
||||||
|
height: 100,
|
||||||
|
weight: 500,
|
||||||
|
stackable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
eurDocument: false,
|
||||||
|
customsStop: false,
|
||||||
|
exportAssistance: false,
|
||||||
|
dangerousGoods: false,
|
||||||
|
specialHandling: false,
|
||||||
|
tailgate: false,
|
||||||
|
straps: false,
|
||||||
|
thermalCover: false,
|
||||||
|
regulatedProducts: false,
|
||||||
|
appointment: false,
|
||||||
|
insurance: false,
|
||||||
|
t1Document: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
|
||||||
|
// Calculate total volume and weight
|
||||||
|
const calculateTotals = () => {
|
||||||
|
let totalVolumeCBM = 0;
|
||||||
|
let totalWeightKG = 0;
|
||||||
|
let totalPallets = 0;
|
||||||
|
|
||||||
|
searchForm.packages.forEach(pkg => {
|
||||||
|
const volumeM3 = (pkg.length * pkg.width * pkg.height) / 1000000;
|
||||||
|
totalVolumeCBM += volumeM3 * pkg.quantity;
|
||||||
|
totalWeightKG += pkg.weight * pkg.quantity;
|
||||||
|
if (pkg.type === 'palette') {
|
||||||
|
totalPallets += pkg.quantity;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { totalVolumeCBM, totalWeightKG, totalPallets };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
const { totalVolumeCBM, totalWeightKG, totalPallets } = calculateTotals();
|
||||||
|
|
||||||
|
// Build query parameters
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
origin: searchForm.origin,
|
||||||
|
destination: searchForm.destination,
|
||||||
|
volumeCBM: totalVolumeCBM.toString(),
|
||||||
|
weightKG: totalWeightKG.toString(),
|
||||||
|
palletCount: totalPallets.toString(),
|
||||||
|
hasDangerousGoods: searchForm.dangerousGoods.toString(),
|
||||||
|
requiresSpecialHandling: searchForm.specialHandling.toString(),
|
||||||
|
requiresTailgate: searchForm.tailgate.toString(),
|
||||||
|
requiresStraps: searchForm.straps.toString(),
|
||||||
|
requiresThermalCover: searchForm.thermalCover.toString(),
|
||||||
|
hasRegulatedProducts: searchForm.regulatedProducts.toString(),
|
||||||
|
requiresAppointment: searchForm.appointment.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to results page
|
||||||
|
router.push(`/dashboard/search-advanced/results?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPackage = () => {
|
||||||
|
setSearchForm({
|
||||||
|
...searchForm,
|
||||||
|
packages: [
|
||||||
|
...searchForm.packages,
|
||||||
|
{
|
||||||
|
type: 'palette',
|
||||||
|
quantity: 1,
|
||||||
|
length: 120,
|
||||||
|
width: 80,
|
||||||
|
height: 100,
|
||||||
|
weight: 500,
|
||||||
|
stackable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePackage = (index: number) => {
|
||||||
|
setSearchForm({
|
||||||
|
...searchForm,
|
||||||
|
packages: searchForm.packages.filter((_, i) => i !== index),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePackage = (index: number, field: keyof Package, value: any) => {
|
||||||
|
const newPackages = [...searchForm.packages];
|
||||||
|
newPackages[index] = { ...newPackages[index], [field]: value };
|
||||||
|
setSearchForm({ ...searchForm, packages: newPackages });
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStep1 = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">1. Informations Générales</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Port d'origine *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchForm.origin}
|
||||||
|
onChange={e => setSearchForm({ ...searchForm, origin: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="ex: FRPAR"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Port de destination *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchForm.destination}
|
||||||
|
onChange={e =>
|
||||||
|
setSearchForm({ ...searchForm, destination: e.target.value.toUpperCase() })
|
||||||
|
}
|
||||||
|
placeholder="ex: CNSHA"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderStep2 = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">2. Conditionnement</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addPackage}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 rounded-md hover:bg-blue-100"
|
||||||
|
>
|
||||||
|
+ Ajouter un colis
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchForm.packages.map((pkg, index) => (
|
||||||
|
<div key={index} className="border border-gray-200 rounded-lg p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium text-gray-900">Colis #{index + 1}</h3>
|
||||||
|
{searchForm.packages.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removePackage(index)}
|
||||||
|
className="text-sm text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-5 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Type</label>
|
||||||
|
<select
|
||||||
|
value={pkg.type}
|
||||||
|
onChange={e => updatePackage(index, 'type', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
|
||||||
|
>
|
||||||
|
<option value="caisse">Caisse</option>
|
||||||
|
<option value="colis">Colis</option>
|
||||||
|
<option value="palette">Palette</option>
|
||||||
|
<option value="autre">Autre</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Quantité</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={pkg.quantity}
|
||||||
|
onChange={e => updatePackage(index, 'quantity', parseInt(e.target.value) || 1)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">L (cm)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={pkg.length}
|
||||||
|
onChange={e => updatePackage(index, 'length', parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">l (cm)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={pkg.width}
|
||||||
|
onChange={e => updatePackage(index, 'width', parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">H (cm)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={pkg.height}
|
||||||
|
onChange={e => updatePackage(index, 'height', parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Poids (kg)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={pkg.weight}
|
||||||
|
onChange={e => updatePackage(index, 'weight', parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center pt-6">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={pkg.stackable}
|
||||||
|
onChange={e => updatePackage(index, 'stackable', e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label className="ml-2 text-sm text-gray-700">Gerbable</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||||
|
<h3 className="text-sm font-medium text-blue-900 mb-2">Récapitulatif</h3>
|
||||||
|
<div className="text-sm text-blue-800 space-y-1">
|
||||||
|
<div>Volume total: {calculateTotals().totalVolumeCBM.toFixed(2)} m³</div>
|
||||||
|
<div>Poids total: {calculateTotals().totalWeightKG} kg</div>
|
||||||
|
<div>Palettes: {calculateTotals().totalPallets}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderStep3 = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">3. Options & Services</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-b pb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-3">Douane Import / Export</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={searchForm.eurDocument}
|
||||||
|
onChange={e => setSearchForm({ ...searchForm, eurDocument: e.target.checked })}
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">EUR 1</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={searchForm.t1Document}
|
||||||
|
onChange={e => setSearchForm({ ...searchForm, t1Document: e.target.checked })}
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">T1</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={searchForm.customsStop}
|
||||||
|
onChange={e => setSearchForm({ ...searchForm, customsStop: e.target.checked })}
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Stop douane</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={searchForm.exportAssistance}
|
||||||
|
onChange={e =>
|
||||||
|
setSearchForm({ ...searchForm, exportAssistance: e.target.checked })
|
||||||
|
}
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Assistance export</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b pb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-3">Marchandise</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={searchForm.dangerousGoods}
|
||||||
|
onChange={e => setSearchForm({ ...searchForm, dangerousGoods: e.target.checked })}
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Marchandise Dangereuse</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={searchForm.regulatedProducts}
|
||||||
|
onChange={e =>
|
||||||
|
setSearchForm({ ...searchForm, regulatedProducts: e.target.checked })
|
||||||
|
}
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Produits règlementés</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b pb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-3">Manutention particulière</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={searchForm.specialHandling}
|
||||||
|
onChange={e =>
|
||||||
|
setSearchForm({ ...searchForm, specialHandling: e.target.checked })
|
||||||
|
}
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Manutention spéciale</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={searchForm.tailgate}
|
||||||
|
onChange={e => setSearchForm({ ...searchForm, tailgate: e.target.checked })}
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Hayon</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={searchForm.straps}
|
||||||
|
onChange={e => setSearchForm({ ...searchForm, straps: e.target.checked })}
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Sangles</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={searchForm.thermalCover}
|
||||||
|
onChange={e => setSearchForm({ ...searchForm, thermalCover: e.target.checked })}
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Couverture thermique</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-3">Autres options</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={searchForm.appointment}
|
||||||
|
onChange={e => setSearchForm({ ...searchForm, appointment: e.target.checked })}
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Rendez-vous livraison</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={searchForm.insurance}
|
||||||
|
onChange={e => setSearchForm({ ...searchForm, insurance: e.target.checked })}
|
||||||
|
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Assurance</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Recherche Avancée de Tarifs</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Formulaire complet avec toutes les options de transport
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Steps */}
|
||||||
|
<div className="flex items-center justify-center space-x-4">
|
||||||
|
{[1, 2, 3].map(step => (
|
||||||
|
<div key={step} className="flex items-center">
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center w-10 h-10 rounded-full ${
|
||||||
|
currentStep >= step ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step}
|
||||||
|
</div>
|
||||||
|
{step < 3 && (
|
||||||
|
<div
|
||||||
|
className={`w-20 h-1 mx-2 ${
|
||||||
|
currentStep > step ? 'bg-blue-600' : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-8">
|
||||||
|
{currentStep === 1 && renderStep1()}
|
||||||
|
{currentStep === 2 && renderStep2()}
|
||||||
|
{currentStep === 3 && renderStep3()}
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="mt-8 flex items-center justify-between pt-6 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentStep(Math.max(1, currentStep - 1))}
|
||||||
|
disabled={currentStep === 1}
|
||||||
|
className="px-6 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Précédent
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{currentStep < 3 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentStep(currentStep + 1)}
|
||||||
|
disabled={!searchForm.origin || !searchForm.destination}
|
||||||
|
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Suivant
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSearch}
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
361
apps/frontend/app/dashboard/search-advanced/results/page.tsx
Normal file
361
apps/frontend/app/dashboard/search-advanced/results/page.tsx
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { searchCsvRates } from '@/lib/api/rates';
|
||||||
|
import type { CsvRateSearchResult } from '@/types/rates';
|
||||||
|
|
||||||
|
interface BestOptions {
|
||||||
|
eco: CsvRateSearchResult;
|
||||||
|
standard: CsvRateSearchResult;
|
||||||
|
fast: CsvRateSearchResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchResultsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [results, setResults] = useState<CsvRateSearchResult[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Parse search parameters from URL
|
||||||
|
const origin = searchParams.get('origin') || '';
|
||||||
|
const destination = searchParams.get('destination') || '';
|
||||||
|
const volumeCBM = parseFloat(searchParams.get('volumeCBM') || '0');
|
||||||
|
const weightKG = parseFloat(searchParams.get('weightKG') || '0');
|
||||||
|
const palletCount = parseInt(searchParams.get('palletCount') || '0');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!origin || !destination || !volumeCBM || !weightKG) {
|
||||||
|
router.push('/dashboard/search-advanced');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
performSearch();
|
||||||
|
}, [origin, destination, volumeCBM, weightKG, palletCount]);
|
||||||
|
|
||||||
|
const performSearch = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await searchCsvRates({
|
||||||
|
origin,
|
||||||
|
destination,
|
||||||
|
volumeCBM,
|
||||||
|
weightKG,
|
||||||
|
palletCount,
|
||||||
|
hasDangerousGoods: searchParams.get('hasDangerousGoods') === 'true',
|
||||||
|
requiresSpecialHandling: searchParams.get('requiresSpecialHandling') === 'true',
|
||||||
|
requiresTailgate: searchParams.get('requiresTailgate') === 'true',
|
||||||
|
requiresStraps: searchParams.get('requiresStraps') === 'true',
|
||||||
|
requiresThermalCover: searchParams.get('requiresThermalCover') === 'true',
|
||||||
|
hasRegulatedProducts: searchParams.get('hasRegulatedProducts') === 'true',
|
||||||
|
requiresAppointment: searchParams.get('requiresAppointment') === 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
setResults(response.results);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Search error:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Une erreur est survenue lors de la recherche');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBestOptions = (): BestOptions | null => {
|
||||||
|
if (results.length === 0) return null;
|
||||||
|
|
||||||
|
const sorted = [...results].sort((a, b) => a.priceEUR - b.priceEUR);
|
||||||
|
const fastest = [...results].sort((a, b) => a.transitDays - b.transitDays);
|
||||||
|
|
||||||
|
return {
|
||||||
|
eco: sorted[0],
|
||||||
|
standard: sorted[Math.floor(sorted.length / 2)] || sorted[0],
|
||||||
|
fast: fastest[0],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const bestOptions = getBestOptions();
|
||||||
|
|
||||||
|
const formatPrice = (price: number) => {
|
||||||
|
return new Intl.NumberFormat('fr-FR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(price);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<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="text-center">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-16 w-16 border-b-4 border-blue-600 mb-4"></div>
|
||||||
|
<p className="text-xl text-gray-700 font-medium">Recherche des meilleurs tarifs en cours...</p>
|
||||||
|
<p className="text-gray-500 mt-2">
|
||||||
|
{origin} → {destination}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<h3 className="text-xl font-bold text-red-900 mb-2">Erreur</h3>
|
||||||
|
<p className="text-red-700 mb-4">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard/search-advanced')}
|
||||||
|
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Retour à la recherche
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
||||||
|
>
|
||||||
|
← Retour à la recherche
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
|
||||||
|
<div className="text-6xl mb-4">🔍</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>
|
||||||
|
<ul className="text-sm text-gray-700 space-y-2">
|
||||||
|
<li>
|
||||||
|
• <strong>Ports disponibles :</strong> NLRTM, DEHAM, FRLEH, BEGNE (origine) → USNYC, USLAX,
|
||||||
|
CNSHG, SGSIN (destination)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• <strong>Volume :</strong> Essayez entre 1 et 200 CBM
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• <strong>Poids :</strong> Essayez entre 100 et 30000 kg
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Modifier la recherche
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionCards = [
|
||||||
|
{
|
||||||
|
type: 'Économique',
|
||||||
|
option: bestOptions?.eco,
|
||||||
|
colors: {
|
||||||
|
border: 'border-green-200',
|
||||||
|
bg: 'bg-green-50',
|
||||||
|
text: 'text-green-800',
|
||||||
|
button: 'bg-green-600 hover:bg-green-700',
|
||||||
|
},
|
||||||
|
icon: '💰',
|
||||||
|
badge: 'Le moins cher',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Standard',
|
||||||
|
option: bestOptions?.standard,
|
||||||
|
colors: {
|
||||||
|
border: 'border-blue-200',
|
||||||
|
bg: 'bg-blue-50',
|
||||||
|
text: 'text-blue-800',
|
||||||
|
button: 'bg-blue-600 hover:bg-blue-700',
|
||||||
|
},
|
||||||
|
icon: '⚖️',
|
||||||
|
badge: 'Équilibré',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Rapide',
|
||||||
|
option: bestOptions?.fast,
|
||||||
|
colors: {
|
||||||
|
border: 'border-purple-200',
|
||||||
|
bg: 'bg-purple-50',
|
||||||
|
text: 'text-purple-800',
|
||||||
|
button: 'bg-purple-600 hover:bg-purple-700',
|
||||||
|
},
|
||||||
|
icon: '⚡',
|
||||||
|
badge: 'Le plus rapide',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
{/* 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 à la recherche
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Résultats de recherche</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
<span className="font-semibold">{origin}</span> → <span className="font-semibold">{destination}</span>{' '}
|
||||||
|
• {volumeCBM} CBM • {weightKG} kg
|
||||||
|
{palletCount > 0 && ` • ${palletCount} palette${palletCount > 1 ? 's' : ''}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-500">Tarifs trouvés</p>
|
||||||
|
<p className="text-3xl font-bold text-blue-600">{results.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Best Options */}
|
||||||
|
{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>
|
||||||
|
Meilleurs choix pour votre recherche
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{optionCards.map(card => {
|
||||||
|
if (!card.option) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={card.type}
|
||||||
|
className={`border-2 ${card.colors.border} ${card.colors.bg} rounded-xl shadow-lg overflow-hidden transform transition-all duration-300 hover:scale-105 hover:shadow-2xl`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<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">
|
||||||
|
{card.badge}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg p-4 mb-4">
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<p className="text-sm text-gray-600 mb-1">Prix total</p>
|
||||||
|
<p className="text-3xl font-bold text-gray-900">{formatPrice(card.option.priceEUR)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 pt-3 space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Transporteur :</span>
|
||||||
|
<span className="font-semibold text-gray-900">{card.option.companyName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Transit :</span>
|
||||||
|
<span className="font-semibold text-gray-900">{card.option.transitDays} jours</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Type :</span>
|
||||||
|
<span className="font-semibold text-gray-900">{card.option.containerType}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors`}>
|
||||||
|
Sélectionner cette option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All Results */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">Tous les tarifs disponibles ({results.length})</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<div key={index} className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">{result.companyName}</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{result.origin} → {result.destination} • {result.containerType}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-3xl font-bold text-blue-600">{formatPrice(result.priceEUR)}</p>
|
||||||
|
<p className="text-sm text-gray-500">Prix total</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-gray-600 mb-1">Prix de base</p>
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{formatPrice(result.priceBreakdown.basePrice)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-gray-600 mb-1">Frais volume</p>
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{formatPrice(result.priceBreakdown.volumeCharge)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-gray-600 mb-1">Frais poids</p>
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{formatPrice(result.priceBreakdown.weightCharge)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-gray-600 mb-1">Délai transit</p>
|
||||||
|
<p className="font-semibold text-gray-900">{result.transitDays} jours</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>}
|
||||||
|
</div>
|
||||||
|
<button className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||||
|
Sélectionner
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -24,24 +24,24 @@ export async function searchRates(data: RateSearchRequest): Promise<RateSearchRe
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Search CSV-based rates with detailed pricing
|
* Search CSV-based rates with detailed pricing
|
||||||
* POST /api/v1/rates/csv/search
|
* POST /api/v1/rates/search-csv
|
||||||
*/
|
*/
|
||||||
export async function searchCsvRates(data: CsvRateSearchRequest): Promise<CsvRateSearchResponse> {
|
export async function searchCsvRates(data: CsvRateSearchRequest): Promise<CsvRateSearchResponse> {
|
||||||
return post<CsvRateSearchResponse>('/api/v1/rates/csv/search', data);
|
return post<CsvRateSearchResponse>('/api/v1/rates/search-csv', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available companies for filtering
|
* Get available companies for filtering
|
||||||
* GET /api/v1/rates/csv/companies
|
* GET /api/v1/rates/companies
|
||||||
*/
|
*/
|
||||||
export async function getAvailableCompanies(): Promise<AvailableCompaniesResponse> {
|
export async function getAvailableCompanies(): Promise<AvailableCompaniesResponse> {
|
||||||
return post<AvailableCompaniesResponse>('/api/v1/rates/csv/companies');
|
return post<AvailableCompaniesResponse>('/api/v1/rates/companies');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get filter options (companies, container types, currencies)
|
* Get filter options (companies, container types, currencies)
|
||||||
* GET /api/v1/rates/csv/filter-options
|
* GET /api/v1/rates/filters/options
|
||||||
*/
|
*/
|
||||||
export async function getFilterOptions(): Promise<FilterOptionsResponse> {
|
export async function getFilterOptions(): Promise<FilterOptionsResponse> {
|
||||||
return post<FilterOptionsResponse>('/api/v1/rates/csv/filter-options');
|
return post<FilterOptionsResponse>('/api/v1/rates/filters/options');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,23 +57,34 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
try {
|
try {
|
||||||
if (isAuthenticated()) {
|
if (isAuthenticated()) {
|
||||||
const storedUser = getStoredUser();
|
// Try to fetch current user from API
|
||||||
if (storedUser) {
|
try {
|
||||||
// Verify token is still valid by fetching current user
|
|
||||||
const currentUser = await getCurrentUser();
|
const currentUser = await getCurrentUser();
|
||||||
setUser(currentUser);
|
setUser(currentUser);
|
||||||
// Update stored user
|
// Update stored user
|
||||||
localStorage.setItem('user', JSON.stringify(currentUser));
|
localStorage.setItem('user', JSON.stringify(currentUser));
|
||||||
|
} catch (apiError) {
|
||||||
|
console.error('Failed to fetch user from API, checking localStorage:', apiError);
|
||||||
|
// If API fails, try to use stored user as fallback
|
||||||
|
const storedUser = getStoredUser();
|
||||||
|
if (storedUser) {
|
||||||
|
console.log('Using stored user as fallback:', storedUser);
|
||||||
|
setUser(storedUser);
|
||||||
|
} else {
|
||||||
|
// No stored user and API failed - clear everything
|
||||||
|
throw apiError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth check failed:', error);
|
console.error('Auth check failed, clearing tokens:', error);
|
||||||
// Token invalid, clear storage
|
// Token invalid or no user data, clear storage
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem('accessToken');
|
localStorage.removeItem('access_token');
|
||||||
localStorage.removeItem('refreshToken');
|
localStorage.removeItem('refresh_token');
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
}
|
}
|
||||||
|
setUser(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user