fix
This commit is contained in:
parent
cf19c36586
commit
1a86864d1f
@ -3,12 +3,14 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Get,
|
Get,
|
||||||
Body,
|
Body,
|
||||||
|
Query,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
Inject,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@ -17,6 +19,7 @@ import {
|
|||||||
ApiBadRequestResponse,
|
ApiBadRequestResponse,
|
||||||
ApiInternalServerErrorResponse,
|
ApiInternalServerErrorResponse,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
|
ApiQuery,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
|
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
|
||||||
import { RateQuoteMapper } from '../mappers';
|
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 { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
|
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 { CsvRateMapper } from '../mappers/csv-rate.mapper';
|
||||||
|
import { PortRepository, PORT_REPOSITORY } from '@domain/ports/out/port.repository';
|
||||||
|
|
||||||
@ApiTags('Rates')
|
@ApiTags('Rates')
|
||||||
@Controller('rates')
|
@Controller('rates')
|
||||||
@ -37,7 +47,8 @@ export class RatesController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly rateSearchService: RateSearchService,
|
private readonly rateSearchService: RateSearchService,
|
||||||
private readonly csvRateSearchService: CsvRateSearchService,
|
private readonly csvRateSearchService: CsvRateSearchService,
|
||||||
private readonly csvRateMapper: CsvRateMapper
|
private readonly csvRateMapper: CsvRateMapper,
|
||||||
|
@Inject(PORT_REPOSITORY) private readonly portRepository: PortRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('search')
|
@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<AvailableOriginsDto> {
|
||||||
|
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<AvailableDestinationsDto> {
|
||||||
|
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
|
* Get available companies
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -209,3 +209,101 @@ export class FilterOptionsDto {
|
|||||||
})
|
})
|
||||||
currencies: string[];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -239,6 +239,60 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
return Array.from(types).sort();
|
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<string[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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<Map<string, string[]>> {
|
||||||
|
const allRates = await this.loadAllRates();
|
||||||
|
const routeMap = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
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<string, string[]>();
|
||||||
|
routeMap.forEach((destinations, origin) => {
|
||||||
|
result.set(origin, Array.from(destinations).sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load all rates from all CSV files
|
* Load all rates from all CSV files
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -2,15 +2,20 @@
|
|||||||
* Advanced Rate Search Page
|
* Advanced Rate Search Page
|
||||||
*
|
*
|
||||||
* Complete search form with all filters and best options display
|
* Complete search form with all filters and best options display
|
||||||
|
* Uses only ports available in CSV rates for origin/destination selection
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Search } from 'lucide-react';
|
import { Search, Loader2 } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 dynamic from 'next/dynamic';
|
||||||
|
|
||||||
// Import dynamique pour éviter les erreurs SSR avec Leaflet
|
// Import dynamique pour éviter les erreurs SSR avec Leaflet
|
||||||
@ -93,24 +98,60 @@ export default function AdvancedSearchPage() {
|
|||||||
const [destinationSearch, setDestinationSearch] = useState('');
|
const [destinationSearch, setDestinationSearch] = useState('');
|
||||||
const [showOriginDropdown, setShowOriginDropdown] = useState(false);
|
const [showOriginDropdown, setShowOriginDropdown] = useState(false);
|
||||||
const [showDestinationDropdown, setShowDestinationDropdown] = useState(false);
|
const [showDestinationDropdown, setShowDestinationDropdown] = useState(false);
|
||||||
const [selectedOriginPort, setSelectedOriginPort] = useState<Port | null>(null);
|
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
|
||||||
const [selectedDestinationPort, setSelectedDestinationPort] = useState<Port | null>(null);
|
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(null);
|
||||||
|
|
||||||
// Port autocomplete queries
|
// Fetch available origins from CSV rates
|
||||||
const { data: originPortsData } = useQuery({
|
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
|
||||||
queryKey: ['ports', originSearch],
|
queryKey: ['available-origins'],
|
||||||
queryFn: () => searchPorts({ query: originSearch, limit: 10 }),
|
queryFn: getAvailableOrigins,
|
||||||
enabled: originSearch.length >= 2 && showOriginDropdown,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: destinationPortsData } = useQuery({
|
// Fetch available destinations based on selected origin
|
||||||
queryKey: ['ports', destinationSearch],
|
const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({
|
||||||
queryFn: () => searchPorts({ query: destinationSearch, limit: 10 }),
|
queryKey: ['available-destinations', searchForm.origin],
|
||||||
enabled: destinationSearch.length >= 2 && showDestinationDropdown,
|
queryFn: () => getAvailableDestinations(searchForm.origin),
|
||||||
|
enabled: !!searchForm.origin,
|
||||||
});
|
});
|
||||||
|
|
||||||
const originPorts = originPortsData?.ports || [];
|
// Filter origins based on search input
|
||||||
const destinationPorts = destinationPortsData?.ports || [];
|
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
|
// Calculate total volume and weight
|
||||||
const calculateTotals = () => {
|
const calculateTotals = () => {
|
||||||
@ -188,38 +229,62 @@ export default function AdvancedSearchPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">1. Informations Générales</h2>
|
<h2 className="text-xl font-semibold text-gray-900">1. Informations Générales</h2>
|
||||||
|
|
||||||
|
{/* Info banner about available routes */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-md p-3">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
Seuls les ports ayant des tarifs disponibles dans notre système sont proposés.
|
||||||
|
{originsData?.total && (
|
||||||
|
<span className="font-medium"> ({originsData.total} ports d'origine disponibles)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{/* Origin Port with Autocomplete */}
|
{/* Origin Port with Autocomplete - Limited to CSV routes */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Port d'origine * {searchForm.origin && <span className="text-green-600 text-xs">✓ Sélectionné</span>}
|
Port d'origine * {searchForm.origin && <span className="text-green-600 text-xs">✓ Sélectionné</span>}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={originSearch}
|
value={originSearch}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setOriginSearch(e.target.value);
|
setOriginSearch(e.target.value);
|
||||||
setShowOriginDropdown(true);
|
setShowOriginDropdown(true);
|
||||||
if (e.target.value.length < 2) {
|
// Clear selection if user modifies the input
|
||||||
setSearchForm({ ...searchForm, origin: '' });
|
if (selectedOriginPort && e.target.value !== selectedOriginPort.displayName) {
|
||||||
|
setSearchForm({ ...searchForm, origin: '', destination: '' });
|
||||||
|
setSelectedOriginPort(null);
|
||||||
|
setSelectedDestinationPort(null);
|
||||||
|
setDestinationSearch('');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onFocus={() => setShowOriginDropdown(true)}
|
onFocus={() => setShowOriginDropdown(true)}
|
||||||
placeholder="ex: Rotterdam, Paris, FRPAR"
|
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 ${
|
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'
|
searchForm.origin ? 'border-green-500 bg-green-50' : 'border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{showOriginDropdown && originPorts && originPorts.length > 0 && (
|
{isLoadingOrigins && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showOriginDropdown && filteredOrigins.length > 0 && (
|
||||||
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
|
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
|
||||||
{originPorts.map((port: Port) => (
|
{filteredOrigins.slice(0, 15).map((port: RoutePortInfo) => (
|
||||||
<button
|
<button
|
||||||
key={port.code}
|
key={port.code}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchForm({ ...searchForm, origin: port.code });
|
setSearchForm({ ...searchForm, origin: port.code, destination: '' });
|
||||||
setOriginSearch(port.displayName);
|
setOriginSearch(port.displayName);
|
||||||
setSelectedOriginPort(port);
|
setSelectedOriginPort(port);
|
||||||
|
setSelectedDestinationPort(null);
|
||||||
|
setDestinationSearch('');
|
||||||
setShowOriginDropdown(false);
|
setShowOriginDropdown(false);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
|
||||||
@ -230,34 +295,60 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{filteredOrigins.length > 15 && (
|
||||||
|
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
|
||||||
|
+{filteredOrigins.length - 15} autres résultats. Affinez votre recherche.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showOriginDropdown && filteredOrigins.length === 0 && !isLoadingOrigins && originsData && (
|
||||||
|
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
|
||||||
|
<p className="text-sm text-gray-500">Aucun port d'origine trouvé pour "{originSearch}"</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Destination Port with Autocomplete */}
|
{/* Destination Port with Autocomplete - Limited to routes from selected origin */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Port de destination * {searchForm.destination && <span className="text-green-600 text-xs">✓ Sélectionné</span>}
|
Port de destination * {searchForm.destination && <span className="text-green-600 text-xs">✓ Sélectionné</span>}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={destinationSearch}
|
value={destinationSearch}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setDestinationSearch(e.target.value);
|
setDestinationSearch(e.target.value);
|
||||||
setShowDestinationDropdown(true);
|
setShowDestinationDropdown(true);
|
||||||
if (e.target.value.length < 2) {
|
// Clear selection if user modifies the input
|
||||||
|
if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) {
|
||||||
setSearchForm({ ...searchForm, destination: '' });
|
setSearchForm({ ...searchForm, destination: '' });
|
||||||
|
setSelectedDestinationPort(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onFocus={() => setShowDestinationDropdown(true)}
|
onFocus={() => setShowDestinationDropdown(true)}
|
||||||
placeholder="ex: Shanghai, New York, CNSHA"
|
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 ${
|
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.destination ? 'border-green-500 bg-green-50' : 'border-gray-300'
|
||||||
}`}
|
} ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||||||
/>
|
/>
|
||||||
{showDestinationDropdown && destinationPorts && destinationPorts.length > 0 && (
|
{isLoadingDestinations && searchForm.origin && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{searchForm.origin && destinationsData?.total !== undefined && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{destinationsData.total} destination{destinationsData.total > 1 ? 's' : ''} disponible{destinationsData.total > 1 ? 's' : ''} depuis {selectedOriginPort?.name || searchForm.origin}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{showDestinationDropdown && filteredDestinations.length > 0 && (
|
||||||
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
|
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
|
||||||
{destinationPorts.map((port: Port) => (
|
{filteredDestinations.slice(0, 15).map((port: RoutePortInfo) => (
|
||||||
<button
|
<button
|
||||||
key={port.code}
|
key={port.code}
|
||||||
type="button"
|
type="button"
|
||||||
@ -275,13 +366,23 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{filteredDestinations.length > 15 && (
|
||||||
|
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
|
||||||
|
+{filteredDestinations.length - 15} autres résultats. Affinez votre recherche.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showDestinationDropdown && filteredDestinations.length === 0 && !isLoadingDestinations && searchForm.origin && destinationsData && (
|
||||||
|
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
|
||||||
|
<p className="text-sm text-gray-500">Aucune destination trouvée pour "{destinationSearch}"</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Carte interactive de la route maritime */}
|
{/* Carte interactive de la route maritime */}
|
||||||
{selectedOriginPort && selectedDestinationPort && (
|
{selectedOriginPort && selectedDestinationPort && selectedOriginPort.latitude && selectedDestinationPort.latitude && (
|
||||||
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden">
|
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
|
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
@ -293,12 +394,12 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
<PortRouteMap
|
<PortRouteMap
|
||||||
portA={{
|
portA={{
|
||||||
lat: selectedOriginPort.coordinates.latitude,
|
lat: selectedOriginPort.latitude,
|
||||||
lng: selectedOriginPort.coordinates.longitude,
|
lng: selectedOriginPort.longitude!,
|
||||||
}}
|
}}
|
||||||
portB={{
|
portB={{
|
||||||
lat: selectedDestinationPort.coordinates.latitude,
|
lat: selectedDestinationPort.latitude,
|
||||||
lng: selectedDestinationPort.coordinates.longitude,
|
lng: selectedDestinationPort.longitude!,
|
||||||
}}
|
}}
|
||||||
height="400px"
|
height="400px"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
* Endpoints for searching shipping rates (both API and CSV-based)
|
* Endpoints for searching shipping rates (both API and CSV-based)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { post } from './client';
|
import { get, post } from './client';
|
||||||
import type {
|
import type {
|
||||||
RateSearchRequest,
|
RateSearchRequest,
|
||||||
RateSearchResponse,
|
RateSearchResponse,
|
||||||
@ -14,6 +14,37 @@ import type {
|
|||||||
FilterOptionsResponse,
|
FilterOptionsResponse,
|
||||||
} from '@/types/api';
|
} 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)
|
* Search shipping rates (API-based)
|
||||||
* POST /api/v1/rates/search
|
* POST /api/v1/rates/search
|
||||||
@ -58,3 +89,29 @@ export async function getAvailableCompanies(): Promise<AvailableCompaniesRespons
|
|||||||
export async function getFilterOptions(): Promise<FilterOptionsResponse> {
|
export async function getFilterOptions(): Promise<FilterOptionsResponse> {
|
||||||
return post<FilterOptionsResponse>('/api/v1/rates/filters/options');
|
return post<FilterOptionsResponse>('/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<AvailableOriginsResponse> {
|
||||||
|
return get<AvailableOriginsResponse>('/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<AvailableDestinationsResponse> {
|
||||||
|
return get<AvailableDestinationsResponse>(
|
||||||
|
`/api/v1/rates/available-routes/destinations?origin=${encodeURIComponent(origin)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user