diff --git a/apps/backend/src/application/controllers/rates.controller.ts b/apps/backend/src/application/controllers/rates.controller.ts index 558b014..18c045f 100644 --- a/apps/backend/src/application/controllers/rates.controller.ts +++ b/apps/backend/src/application/controllers/rates.controller.ts @@ -3,12 +3,14 @@ import { Post, Get, Body, + Query, HttpCode, HttpStatus, Logger, UsePipes, ValidationPipe, UseGuards, + Inject, } from '@nestjs/common'; import { ApiTags, @@ -17,6 +19,7 @@ import { ApiBadRequestResponse, ApiInternalServerErrorResponse, ApiBearerAuth, + ApiQuery, } from '@nestjs/swagger'; import { RateSearchRequestDto, RateSearchResponseDto } from '../dto'; import { RateQuoteMapper } from '../mappers'; @@ -25,8 +28,15 @@ import { CsvRateSearchService } from '@domain/services/csv-rate-search.service'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto'; -import { AvailableCompaniesDto, FilterOptionsDto } from '../dto/csv-rate-upload.dto'; +import { + AvailableCompaniesDto, + FilterOptionsDto, + AvailableOriginsDto, + AvailableDestinationsDto, + RoutePortInfoDto, +} from '../dto/csv-rate-upload.dto'; import { CsvRateMapper } from '../mappers/csv-rate.mapper'; +import { PortRepository, PORT_REPOSITORY } from '@domain/ports/out/port.repository'; @ApiTags('Rates') @Controller('rates') @@ -37,7 +47,8 @@ export class RatesController { constructor( private readonly rateSearchService: RateSearchService, private readonly csvRateSearchService: CsvRateSearchService, - private readonly csvRateMapper: CsvRateMapper + private readonly csvRateMapper: CsvRateMapper, + @Inject(PORT_REPOSITORY) private readonly portRepository: PortRepository ) {} @Post('search') @@ -271,6 +282,168 @@ export class RatesController { } } + /** + * Get available origin ports from CSV rates + * Returns only ports that have routes defined in CSV files + */ + @Get('available-routes/origins') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get available origin ports from CSV rates', + description: + 'Returns list of origin ports that have shipping routes defined in CSV rate files. Use this to populate origin port selection dropdown.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'List of available origin ports with details', + type: AvailableOriginsDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + async getAvailableOrigins(): Promise { + this.logger.log('Fetching available origin ports from CSV rates'); + + try { + // Get unique origin port codes from CSV rates + const originCodes = await this.csvRateSearchService.getAvailableOrigins(); + + // Fetch port details from database + const ports = await this.portRepository.findByCodes(originCodes); + + // Map to response DTO with port details + const origins: RoutePortInfoDto[] = originCodes.map(code => { + const port = ports.find(p => p.code === code); + if (port) { + return { + code: port.code, + name: port.name, + city: port.city, + country: port.country, + countryName: port.countryName, + displayName: port.getDisplayName(), + latitude: port.coordinates.latitude, + longitude: port.coordinates.longitude, + }; + } + // Fallback if port not found in database + return { + code, + name: code, + city: '', + country: code.substring(0, 2), + countryName: '', + displayName: code, + }; + }); + + // Sort by display name + origins.sort((a, b) => a.displayName.localeCompare(b.displayName)); + + return { + origins, + total: origins.length, + }; + } catch (error: any) { + this.logger.error( + `Failed to fetch available origins: ${error?.message || 'Unknown error'}`, + error?.stack + ); + throw error; + } + } + + /** + * Get available destination ports for a given origin + * Returns only destinations that have routes from the specified origin in CSV files + */ + @Get('available-routes/destinations') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get available destination ports for a given origin', + description: + 'Returns list of destination ports that have shipping routes from the specified origin port in CSV rate files. Use this to populate destination port selection dropdown after origin is selected.', + }) + @ApiQuery({ + name: 'origin', + required: true, + description: 'Origin port code (UN/LOCODE format, e.g., NLRTM)', + example: 'NLRTM', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'List of available destination ports with details', + type: AvailableDestinationsDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + @ApiBadRequestResponse({ + description: 'Origin port code is required', + }) + async getAvailableDestinations( + @Query('origin') origin: string + ): Promise { + this.logger.log(`Fetching available destinations for origin: ${origin}`); + + if (!origin) { + throw new Error('Origin port code is required'); + } + + try { + // Get destination port codes for this origin from CSV rates + const destinationCodes = await this.csvRateSearchService.getAvailableDestinations(origin); + + // Fetch port details from database + const ports = await this.portRepository.findByCodes(destinationCodes); + + // Map to response DTO with port details + const destinations: RoutePortInfoDto[] = destinationCodes.map(code => { + const port = ports.find(p => p.code === code); + if (port) { + return { + code: port.code, + name: port.name, + city: port.city, + country: port.country, + countryName: port.countryName, + displayName: port.getDisplayName(), + latitude: port.coordinates.latitude, + longitude: port.coordinates.longitude, + }; + } + // Fallback if port not found in database + return { + code, + name: code, + city: '', + country: code.substring(0, 2), + countryName: '', + displayName: code, + }; + }); + + // Sort by display name + destinations.sort((a, b) => a.displayName.localeCompare(b.displayName)); + + return { + origin: origin.toUpperCase(), + destinations, + total: destinations.length, + }; + } catch (error: any) { + this.logger.error( + `Failed to fetch available destinations: ${error?.message || 'Unknown error'}`, + error?.stack + ); + throw error; + } + } + /** * Get available companies */ diff --git a/apps/backend/src/application/dto/csv-rate-upload.dto.ts b/apps/backend/src/application/dto/csv-rate-upload.dto.ts index 93a7aca..be38e08 100644 --- a/apps/backend/src/application/dto/csv-rate-upload.dto.ts +++ b/apps/backend/src/application/dto/csv-rate-upload.dto.ts @@ -209,3 +209,101 @@ export class FilterOptionsDto { }) currencies: string[]; } + +/** + * Port Info for Route Response DTO + * Contains port details with coordinates for map display + */ +export class RoutePortInfoDto { + @ApiProperty({ + description: 'UN/LOCODE port code', + example: 'NLRTM', + }) + code: string; + + @ApiProperty({ + description: 'Port name', + example: 'Rotterdam', + }) + name: string; + + @ApiProperty({ + description: 'City name', + example: 'Rotterdam', + }) + city: string; + + @ApiProperty({ + description: 'Country code (ISO 3166-1 alpha-2)', + example: 'NL', + }) + country: string; + + @ApiProperty({ + description: 'Country full name', + example: 'Netherlands', + }) + countryName: string; + + @ApiProperty({ + description: 'Display name for UI', + example: 'Rotterdam, Netherlands (NLRTM)', + }) + displayName: string; + + @ApiProperty({ + description: 'Latitude coordinate', + example: 51.9244, + required: false, + }) + latitude?: number; + + @ApiProperty({ + description: 'Longitude coordinate', + example: 4.4777, + required: false, + }) + longitude?: number; +} + +/** + * Available Origins Response DTO + * Returns list of origin ports that have routes in CSV rates + */ +export class AvailableOriginsDto { + @ApiProperty({ + description: 'List of origin ports with available routes in CSV rates', + type: [RoutePortInfoDto], + }) + origins: RoutePortInfoDto[]; + + @ApiProperty({ + description: 'Total number of available origin ports', + example: 15, + }) + total: number; +} + +/** + * Available Destinations Response DTO + * Returns list of destination ports available for a given origin + */ +export class AvailableDestinationsDto { + @ApiProperty({ + description: 'Origin port code that was used to filter destinations', + example: 'NLRTM', + }) + origin: string; + + @ApiProperty({ + description: 'List of destination ports available from the given origin', + type: [RoutePortInfoDto], + }) + destinations: RoutePortInfoDto[]; + + @ApiProperty({ + description: 'Total number of available destinations for this origin', + example: 8, + }) + total: number; +} diff --git a/apps/backend/src/domain/services/csv-rate-search.service.ts b/apps/backend/src/domain/services/csv-rate-search.service.ts index bfcff6f..478c7b3 100644 --- a/apps/backend/src/domain/services/csv-rate-search.service.ts +++ b/apps/backend/src/domain/services/csv-rate-search.service.ts @@ -239,6 +239,60 @@ export class CsvRateSearchService implements SearchCsvRatesPort { return Array.from(types).sort(); } + /** + * Get all unique origin port codes from CSV rates + * Used to limit port selection to only those with available routes + */ + async getAvailableOrigins(): Promise { + const allRates = await this.loadAllRates(); + const origins = new Set(allRates.map(rate => rate.origin.getValue())); + return Array.from(origins).sort(); + } + + /** + * Get all destination port codes available for a given origin + * Used to limit destination selection based on selected origin + */ + async getAvailableDestinations(origin: string): Promise { + const allRates = await this.loadAllRates(); + const originCode = PortCode.create(origin); + + const destinations = new Set( + allRates + .filter(rate => rate.origin.equals(originCode)) + .map(rate => rate.destination.getValue()) + ); + + return Array.from(destinations).sort(); + } + + /** + * Get all available routes (origin-destination pairs) from CSV rates + * Returns a map of origin codes to their available destination codes + */ + async getAvailableRoutes(): Promise> { + const allRates = await this.loadAllRates(); + const routeMap = new Map>(); + + allRates.forEach(rate => { + const origin = rate.origin.getValue(); + const destination = rate.destination.getValue(); + + if (!routeMap.has(origin)) { + routeMap.set(origin, new Set()); + } + routeMap.get(origin)!.add(destination); + }); + + // Convert Sets to sorted arrays + const result = new Map(); + routeMap.forEach((destinations, origin) => { + result.set(origin, Array.from(destinations).sort()); + }); + + return result; + } + /** * Load all rates from all CSV files */ diff --git a/apps/frontend/app/dashboard/search-advanced/page.tsx b/apps/frontend/app/dashboard/search-advanced/page.tsx index e5f8ae2..92e1b28 100644 --- a/apps/frontend/app/dashboard/search-advanced/page.tsx +++ b/apps/frontend/app/dashboard/search-advanced/page.tsx @@ -2,15 +2,20 @@ * Advanced Rate Search Page * * Complete search form with all filters and best options display + * Uses only ports available in CSV rates for origin/destination selection */ 'use client'; -import { useState, useMemo } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { Search } from 'lucide-react'; +import { Search, Loader2 } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; -import { searchPorts, Port } from '@/lib/api/ports'; +import { + getAvailableOrigins, + getAvailableDestinations, + RoutePortInfo, +} from '@/lib/api/rates'; import dynamic from 'next/dynamic'; // Import dynamique pour éviter les erreurs SSR avec Leaflet @@ -93,24 +98,60 @@ export default function AdvancedSearchPage() { const [destinationSearch, setDestinationSearch] = useState(''); const [showOriginDropdown, setShowOriginDropdown] = useState(false); const [showDestinationDropdown, setShowDestinationDropdown] = useState(false); - const [selectedOriginPort, setSelectedOriginPort] = useState(null); - const [selectedDestinationPort, setSelectedDestinationPort] = useState(null); + const [selectedOriginPort, setSelectedOriginPort] = useState(null); + const [selectedDestinationPort, setSelectedDestinationPort] = useState(null); - // Port autocomplete queries - const { data: originPortsData } = useQuery({ - queryKey: ['ports', originSearch], - queryFn: () => searchPorts({ query: originSearch, limit: 10 }), - enabled: originSearch.length >= 2 && showOriginDropdown, + // Fetch available origins from CSV rates + const { data: originsData, isLoading: isLoadingOrigins } = useQuery({ + queryKey: ['available-origins'], + queryFn: getAvailableOrigins, }); - const { data: destinationPortsData } = useQuery({ - queryKey: ['ports', destinationSearch], - queryFn: () => searchPorts({ query: destinationSearch, limit: 10 }), - enabled: destinationSearch.length >= 2 && showDestinationDropdown, + // Fetch available destinations based on selected origin + const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({ + queryKey: ['available-destinations', searchForm.origin], + queryFn: () => getAvailableDestinations(searchForm.origin), + enabled: !!searchForm.origin, }); - const originPorts = originPortsData?.ports || []; - const destinationPorts = destinationPortsData?.ports || []; + // Filter origins based on search input + const filteredOrigins = (originsData?.origins || []).filter(port => { + if (!originSearch || originSearch.length < 1) return true; + const searchLower = originSearch.toLowerCase(); + return ( + port.code.toLowerCase().includes(searchLower) || + port.name.toLowerCase().includes(searchLower) || + port.city.toLowerCase().includes(searchLower) || + port.countryName.toLowerCase().includes(searchLower) + ); + }); + + // Filter destinations based on search input + const filteredDestinations = (destinationsData?.destinations || []).filter(port => { + if (!destinationSearch || destinationSearch.length < 1) return true; + const searchLower = destinationSearch.toLowerCase(); + return ( + port.code.toLowerCase().includes(searchLower) || + port.name.toLowerCase().includes(searchLower) || + port.city.toLowerCase().includes(searchLower) || + port.countryName.toLowerCase().includes(searchLower) + ); + }); + + // Reset destination when origin changes + useEffect(() => { + if (searchForm.origin && selectedDestinationPort) { + // Check if current destination is still valid for new origin + const isValidDestination = destinationsData?.destinations?.some( + d => d.code === searchForm.destination + ); + if (!isValidDestination) { + setSearchForm(prev => ({ ...prev, destination: '' })); + setSelectedDestinationPort(null); + setDestinationSearch(''); + } + } + }, [searchForm.origin, destinationsData]); // Calculate total volume and weight const calculateTotals = () => { @@ -188,38 +229,62 @@ export default function AdvancedSearchPage() {

1. Informations Générales

+ {/* Info banner about available routes */} +
+

+ Seuls les ports ayant des tarifs disponibles dans notre système sont proposés. + {originsData?.total && ( + ({originsData.total} ports d'origine disponibles) + )} +

+
+
- {/* Origin Port with Autocomplete */} + {/* Origin Port with Autocomplete - Limited to CSV routes */}
- { - setOriginSearch(e.target.value); - setShowOriginDropdown(true); - if (e.target.value.length < 2) { - setSearchForm({ ...searchForm, origin: '' }); - } - }} - onFocus={() => setShowOriginDropdown(true)} - placeholder="ex: Rotterdam, Paris, FRPAR" - className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${ - searchForm.origin ? 'border-green-500 bg-green-50' : 'border-gray-300' - }`} - /> - {showOriginDropdown && originPorts && originPorts.length > 0 && ( +
+ { + setOriginSearch(e.target.value); + setShowOriginDropdown(true); + // Clear selection if user modifies the input + if (selectedOriginPort && e.target.value !== selectedOriginPort.displayName) { + setSearchForm({ ...searchForm, origin: '', destination: '' }); + setSelectedOriginPort(null); + setSelectedDestinationPort(null); + setDestinationSearch(''); + } + }} + onFocus={() => setShowOriginDropdown(true)} + onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)} + placeholder="Rechercher un port d'origine..." + className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${ + searchForm.origin ? 'border-green-500 bg-green-50' : 'border-gray-300' + }`} + /> + {isLoadingOrigins && ( +
+ +
+ )} +
+ {showOriginDropdown && filteredOrigins.length > 0 && (
- {originPorts.map((port: Port) => ( + {filteredOrigins.slice(0, 15).map((port: RoutePortInfo) => (
))} + {filteredOrigins.length > 15 && ( +
+ +{filteredOrigins.length - 15} autres résultats. Affinez votre recherche. +
+ )} +
+ )} + {showOriginDropdown && filteredOrigins.length === 0 && !isLoadingOrigins && originsData && ( +
+

Aucun port d'origine trouvé pour "{originSearch}"

)}
- {/* Destination Port with Autocomplete */} + {/* Destination Port with Autocomplete - Limited to routes from selected origin */}
- { - setDestinationSearch(e.target.value); - setShowDestinationDropdown(true); - if (e.target.value.length < 2) { - setSearchForm({ ...searchForm, destination: '' }); - } - }} - onFocus={() => setShowDestinationDropdown(true)} - placeholder="ex: Shanghai, New York, CNSHA" - className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${ - searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300' - }`} - /> - {showDestinationDropdown && destinationPorts && destinationPorts.length > 0 && ( +
+ { + setDestinationSearch(e.target.value); + setShowDestinationDropdown(true); + // Clear selection if user modifies the input + if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) { + setSearchForm({ ...searchForm, destination: '' }); + setSelectedDestinationPort(null); + } + }} + onFocus={() => setShowDestinationDropdown(true)} + onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)} + disabled={!searchForm.origin} + placeholder={searchForm.origin ? 'Rechercher une destination...' : 'Sélectionnez d\'abord un port d\'origine'} + className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${ + searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300' + } ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`} + /> + {isLoadingDestinations && searchForm.origin && ( +
+ +
+ )} +
+ {searchForm.origin && destinationsData?.total !== undefined && ( +

+ {destinationsData.total} destination{destinationsData.total > 1 ? 's' : ''} disponible{destinationsData.total > 1 ? 's' : ''} depuis {selectedOriginPort?.name || searchForm.origin} +

+ )} + {showDestinationDropdown && filteredDestinations.length > 0 && (
- {destinationPorts.map((port: Port) => ( + {filteredDestinations.slice(0, 15).map((port: RoutePortInfo) => ( ))} + {filteredDestinations.length > 15 && ( +
+ +{filteredDestinations.length - 15} autres résultats. Affinez votre recherche. +
+ )} +
+ )} + {showDestinationDropdown && filteredDestinations.length === 0 && !isLoadingDestinations && searchForm.origin && destinationsData && ( +
+

Aucune destination trouvée pour "{destinationSearch}"

)}
{/* Carte interactive de la route maritime */} - {selectedOriginPort && selectedDestinationPort && ( + {selectedOriginPort && selectedDestinationPort && selectedOriginPort.latitude && selectedDestinationPort.latitude && (

@@ -293,12 +394,12 @@ export default function AdvancedSearchPage() {

diff --git a/apps/frontend/src/lib/api/rates.ts b/apps/frontend/src/lib/api/rates.ts index 63a5c0e..8e0bbaa 100644 --- a/apps/frontend/src/lib/api/rates.ts +++ b/apps/frontend/src/lib/api/rates.ts @@ -4,7 +4,7 @@ * Endpoints for searching shipping rates (both API and CSV-based) */ -import { post } from './client'; +import { get, post } from './client'; import type { RateSearchRequest, RateSearchResponse, @@ -14,6 +14,37 @@ import type { FilterOptionsResponse, } from '@/types/api'; +/** + * Route Port Info - port details with coordinates + */ +export interface RoutePortInfo { + code: string; + name: string; + city: string; + country: string; + countryName: string; + displayName: string; + latitude?: number; + longitude?: number; +} + +/** + * Available Origins Response + */ +export interface AvailableOriginsResponse { + origins: RoutePortInfo[]; + total: number; +} + +/** + * Available Destinations Response + */ +export interface AvailableDestinationsResponse { + origin: string; + destinations: RoutePortInfo[]; + total: number; +} + /** * Search shipping rates (API-based) * POST /api/v1/rates/search @@ -58,3 +89,29 @@ export async function getAvailableCompanies(): Promise { return post('/api/v1/rates/filters/options'); } + +/** + * Get available origin ports from CSV rates + * GET /api/v1/rates/available-routes/origins + * + * Returns only ports that have shipping routes defined in CSV rate files. + * Use this to populate origin port selection dropdown. + */ +export async function getAvailableOrigins(): Promise { + return get('/api/v1/rates/available-routes/origins'); +} + +/** + * Get available destination ports for a given origin + * GET /api/v1/rates/available-routes/destinations?origin=XXXX + * + * Returns only ports that have shipping routes from the specified origin port. + * Use this to populate destination port selection after origin is selected. + */ +export async function getAvailableDestinations( + origin: string +): Promise { + return get( + `/api/v1/rates/available-routes/destinations?origin=${encodeURIComponent(origin)}` + ); +}