fix
This commit is contained in:
parent
cf19c36586
commit
1a86864d1f
@ -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<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
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<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
|
||||
*/
|
||||
|
||||
@ -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<Port | null>(null);
|
||||
const [selectedDestinationPort, setSelectedDestinationPort] = useState<Port | null>(null);
|
||||
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
|
||||
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(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() {
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
{/* Origin Port with Autocomplete */}
|
||||
{/* Origin Port with Autocomplete - Limited to CSV routes */}
|
||||
<div className="relative">
|
||||
<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>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={originSearch}
|
||||
onChange={e => {
|
||||
setOriginSearch(e.target.value);
|
||||
setShowOriginDropdown(true);
|
||||
if (e.target.value.length < 2) {
|
||||
setSearchForm({ ...searchForm, origin: '' });
|
||||
// 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)}
|
||||
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 ${
|
||||
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">
|
||||
{originPorts.map((port: Port) => (
|
||||
{filteredOrigins.slice(0, 15).map((port: RoutePortInfo) => (
|
||||
<button
|
||||
key={port.code}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchForm({ ...searchForm, origin: port.code });
|
||||
setSearchForm({ ...searchForm, origin: port.code, destination: '' });
|
||||
setOriginSearch(port.displayName);
|
||||
setSelectedOriginPort(port);
|
||||
setSelectedDestinationPort(null);
|
||||
setDestinationSearch('');
|
||||
setShowOriginDropdown(false);
|
||||
}}
|
||||
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>
|
||||
</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>
|
||||
|
||||
{/* Destination Port with Autocomplete */}
|
||||
{/* Destination Port with Autocomplete - Limited to routes from selected origin */}
|
||||
<div className="relative">
|
||||
<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>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={destinationSearch}
|
||||
onChange={e => {
|
||||
setDestinationSearch(e.target.value);
|
||||
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: '' });
|
||||
setSelectedDestinationPort(null);
|
||||
}
|
||||
}}
|
||||
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 ${
|
||||
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">
|
||||
{destinationPorts.map((port: Port) => (
|
||||
{filteredDestinations.slice(0, 15).map((port: RoutePortInfo) => (
|
||||
<button
|
||||
key={port.code}
|
||||
type="button"
|
||||
@ -275,13 +366,23 @@ export default function AdvancedSearchPage() {
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* 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="bg-gray-50 px-4 py-3 border-b border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
@ -293,12 +394,12 @@ export default function AdvancedSearchPage() {
|
||||
</div>
|
||||
<PortRouteMap
|
||||
portA={{
|
||||
lat: selectedOriginPort.coordinates.latitude,
|
||||
lng: selectedOriginPort.coordinates.longitude,
|
||||
lat: selectedOriginPort.latitude,
|
||||
lng: selectedOriginPort.longitude!,
|
||||
}}
|
||||
portB={{
|
||||
lat: selectedDestinationPort.coordinates.latitude,
|
||||
lng: selectedDestinationPort.coordinates.longitude,
|
||||
lat: selectedDestinationPort.latitude,
|
||||
lng: selectedDestinationPort.longitude!,
|
||||
}}
|
||||
height="400px"
|
||||
/>
|
||||
|
||||
@ -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<AvailableCompaniesRespons
|
||||
export async function getFilterOptions(): Promise<FilterOptionsResponse> {
|
||||
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