This commit is contained in:
David 2026-02-03 22:14:03 +01:00
parent cf19c36586
commit 1a86864d1f
5 changed files with 546 additions and 63 deletions

View File

@ -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
*/ */

View File

@ -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;
}

View File

@ -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
*/ */

View File

@ -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>
<input <div className="relative">
type="text" <input
value={originSearch} type="text"
onChange={e => { value={originSearch}
setOriginSearch(e.target.value); onChange={e => {
setShowOriginDropdown(true); setOriginSearch(e.target.value);
if (e.target.value.length < 2) { setShowOriginDropdown(true);
setSearchForm({ ...searchForm, origin: '' }); // Clear selection if user modifies the input
} if (selectedOriginPort && e.target.value !== selectedOriginPort.displayName) {
}} setSearchForm({ ...searchForm, origin: '', destination: '' });
onFocus={() => setShowOriginDropdown(true)} setSelectedOriginPort(null);
placeholder="ex: Rotterdam, Paris, FRPAR" setSelectedDestinationPort(null);
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${ setDestinationSearch('');
searchForm.origin ? 'border-green-500 bg-green-50' : 'border-gray-300' }
}`} }}
/> onFocus={() => setShowOriginDropdown(true)}
{showOriginDropdown && originPorts && originPorts.length > 0 && ( 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 && (
<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>
<input <div className="relative">
type="text" <input
value={destinationSearch} type="text"
onChange={e => { value={destinationSearch}
setDestinationSearch(e.target.value); onChange={e => {
setShowDestinationDropdown(true); setDestinationSearch(e.target.value);
if (e.target.value.length < 2) { setShowDestinationDropdown(true);
setSearchForm({ ...searchForm, destination: '' }); // Clear selection if user modifies the input
} if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) {
}} setSearchForm({ ...searchForm, destination: '' });
onFocus={() => setShowDestinationDropdown(true)} setSelectedDestinationPort(null);
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' onFocus={() => setShowDestinationDropdown(true)}
}`} onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
/> disabled={!searchForm.origin}
{showDestinationDropdown && destinationPorts && destinationPorts.length > 0 && ( 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 && (
<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"
/> />

View File

@ -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)}`
);
}