diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f1fc44f..b55ad1d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -37,7 +37,11 @@ "Bash(npx tsc:*)", "Bash(find:*)", "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": [], "ask": [] diff --git a/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts b/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts index a10192d..55f5353 100644 --- a/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts +++ b/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts @@ -65,7 +65,18 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort { constructor() { // 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 { diff --git a/apps/frontend/app/dashboard/layout.tsx b/apps/frontend/app/dashboard/layout.tsx index 5fced92..6f93f55 100644 --- a/apps/frontend/app/dashboard/layout.tsx +++ b/apps/frontend/app/dashboard/layout.tsx @@ -11,7 +11,6 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useState } from 'react'; import NotificationDropdown from '@/components/NotificationDropdown'; -import DebugUser from '@/components/DebugUser'; export default function DashboardLayout({ children }: { children: React.ReactNode }) { const { user, logout } = useAuth(); @@ -22,6 +21,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod { name: 'Dashboard', href: '/dashboard', icon: '📊' }, { name: 'Bookings', href: '/dashboard/bookings', icon: '📦' }, { name: 'Search Rates', href: '/dashboard/search', icon: '🔍' }, + { name: 'Search Advanced', href: '/dashboard/search-advanced', icon: '🔎' }, { name: 'My Profile', href: '/dashboard/profile', icon: '👤' }, { name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' }, { name: 'Users', href: '/dashboard/settings/users', icon: '👥' }, @@ -157,9 +157,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod {/* Page content */}
{children}
- - {/* Debug panel */} - ); } diff --git a/apps/frontend/app/dashboard/search-advanced/page.tsx b/apps/frontend/app/dashboard/search-advanced/page.tsx new file mode 100644 index 0000000..165de14 --- /dev/null +++ b/apps/frontend/app/dashboard/search-advanced/page.tsx @@ -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({ + 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 = () => ( +
+

1. Informations Générales

+ +
+
+ + setSearchForm({ ...searchForm, origin: e.target.value.toUpperCase() })} + placeholder="ex: FRPAR" + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +
+ +
+ + + setSearchForm({ ...searchForm, destination: e.target.value.toUpperCase() }) + } + placeholder="ex: CNSHA" + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +
+
+
+ ); + + const renderStep2 = () => ( +
+
+

2. Conditionnement

+ +
+ + {searchForm.packages.map((pkg, index) => ( +
+
+

Colis #{index + 1}

+ {searchForm.packages.length > 1 && ( + + )} +
+ +
+
+ + +
+ +
+ + updatePackage(index, 'quantity', parseInt(e.target.value) || 1)} + className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md" + /> +
+ +
+ + updatePackage(index, 'length', parseInt(e.target.value) || 0)} + className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md" + /> +
+ +
+ + updatePackage(index, 'width', parseInt(e.target.value) || 0)} + className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md" + /> +
+ +
+ + updatePackage(index, 'height', parseInt(e.target.value) || 0)} + className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md" + /> +
+
+ +
+
+ + updatePackage(index, 'weight', parseInt(e.target.value) || 0)} + className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md" + /> +
+ +
+ updatePackage(index, 'stackable', e.target.checked)} + className="h-4 w-4 text-blue-600 border-gray-300 rounded" + /> + +
+
+
+ ))} + +
+

Récapitulatif

+
+
Volume total: {calculateTotals().totalVolumeCBM.toFixed(2)} m³
+
Poids total: {calculateTotals().totalWeightKG} kg
+
Palettes: {calculateTotals().totalPallets}
+
+
+
+ ); + + const renderStep3 = () => ( +
+

3. Options & Services

+ +
+
+

Douane Import / Export

+
+ + + + +
+
+ +
+

Marchandise

+
+ + +
+
+ +
+

Manutention particulière

+
+ + + + +
+
+ +
+

Autres options

+
+ + +
+
+
+
+ ); + + return ( +
+ {/* Header */} +
+

Recherche Avancée de Tarifs

+

+ Formulaire complet avec toutes les options de transport +

+
+ + {/* Progress Steps */} +
+ {[1, 2, 3].map(step => ( +
+
= step ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600' + }`} + > + {step} +
+ {step < 3 && ( +
step ? 'bg-blue-600' : 'bg-gray-200' + }`} + /> + )} +
+ ))} +
+ + {/* Form */} +
+ {currentStep === 1 && renderStep1()} + {currentStep === 2 && renderStep2()} + {currentStep === 3 && renderStep3()} + + {/* Navigation */} +
+ + + {currentStep < 3 ? ( + + ) : ( + + )} +
+
+ +
+ ); +} diff --git a/apps/frontend/app/dashboard/search-advanced/results/page.tsx b/apps/frontend/app/dashboard/search-advanced/results/page.tsx new file mode 100644 index 0000000..e36b47d --- /dev/null +++ b/apps/frontend/app/dashboard/search-advanced/results/page.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+
+

Recherche des meilleurs tarifs en cours...

+

+ {origin} → {destination} +

+
+
+
+ ); + } + + if (error) { + return ( +
+
+
+
+

Erreur

+

{error}

+ +
+
+
+ ); + } + + if (results.length === 0) { + return ( +
+
+ + +
+
🔍
+

Aucun résultat trouvé

+

+ Aucun tarif ne correspond à votre recherche pour le trajet {origin} → {destination} +

+
+

💡 Suggestions :

+
    +
  • + • Ports disponibles : NLRTM, DEHAM, FRLEH, BEGNE (origine) → USNYC, USLAX, + CNSHG, SGSIN (destination) +
  • +
  • + • Volume : Essayez entre 1 et 200 CBM +
  • +
  • + • Poids : Essayez entre 100 et 30000 kg +
  • +
+
+ +
+
+
+ ); + } + + 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 ( +
+
+ {/* Header */} +
+ + +
+
+
+

Résultats de recherche

+

+ {origin}{destination}{' '} + • {volumeCBM} CBM • {weightKG} kg + {palletCount > 0 && ` • ${palletCount} palette${palletCount > 1 ? 's' : ''}`} +

+
+
+

Tarifs trouvés

+

{results.length}

+
+
+
+
+ + {/* Best Options */} + {bestOptions && ( +
+

+ 🏆 + Meilleurs choix pour votre recherche +

+ +
+ {optionCards.map(card => { + if (!card.option) return null; + + return ( +
+
+
+
+ {card.icon} +
+

{card.type}

+ + {card.badge} + +
+
+
+ +
+
+

Prix total

+

{formatPrice(card.option.priceEUR)}

+
+ +
+
+ Transporteur : + {card.option.companyName} +
+
+ Transit : + {card.option.transitDays} jours +
+
+ Type : + {card.option.containerType} +
+
+
+ + +
+
+ ); + })} +
+
+ )} + + {/* All Results */} +
+

Tous les tarifs disponibles ({results.length})

+ +
+ {results.map((result, index) => ( +
+
+
+

{result.companyName}

+

+ {result.origin} → {result.destination} • {result.containerType} +

+
+
+

{formatPrice(result.priceEUR)}

+

Prix total

+
+
+ +
+
+

Prix de base

+

+ {formatPrice(result.priceBreakdown.basePrice)} +

+
+
+

Frais volume

+

+ {formatPrice(result.priceBreakdown.volumeCharge)} +

+
+
+

Frais poids

+

+ {formatPrice(result.priceBreakdown.weightCharge)} +

+
+
+

Délai transit

+

{result.transitDays} jours

+
+
+ +
+
+ ✓ Valide jusqu'au {new Date(result.validUntil).toLocaleDateString('fr-FR')} + {result.hasSurcharges && ⚠️ Surcharges applicables} +
+ +
+
+ ))} +
+
+
+
+ ); +} diff --git a/apps/frontend/src/lib/api/rates.ts b/apps/frontend/src/lib/api/rates.ts index 0e88189..ac7ecdc 100644 --- a/apps/frontend/src/lib/api/rates.ts +++ b/apps/frontend/src/lib/api/rates.ts @@ -24,24 +24,24 @@ export async function searchRates(data: RateSearchRequest): Promise { - return post('/api/v1/rates/csv/search', data); + return post('/api/v1/rates/search-csv', data); } /** * Get available companies for filtering - * GET /api/v1/rates/csv/companies + * GET /api/v1/rates/companies */ export async function getAvailableCompanies(): Promise { - return post('/api/v1/rates/csv/companies'); + return post('/api/v1/rates/companies'); } /** * 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 { - return post('/api/v1/rates/csv/filter-options'); + return post('/api/v1/rates/filters/options'); } diff --git a/apps/frontend/src/lib/context/auth-context.tsx b/apps/frontend/src/lib/context/auth-context.tsx index eaeaedf..4714272 100644 --- a/apps/frontend/src/lib/context/auth-context.tsx +++ b/apps/frontend/src/lib/context/auth-context.tsx @@ -57,23 +57,34 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const checkAuth = async () => { try { if (isAuthenticated()) { - const storedUser = getStoredUser(); - if (storedUser) { - // Verify token is still valid by fetching current user + // Try to fetch current user from API + try { const currentUser = await getCurrentUser(); setUser(currentUser); // Update stored user 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) { - console.error('Auth check failed:', error); - // Token invalid, clear storage + console.error('Auth check failed, clearing tokens:', error); + // Token invalid or no user data, clear storage if (typeof window !== 'undefined') { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); localStorage.removeItem('user'); } + setUser(null); } finally { setLoading(false); }