fix search
This commit is contained in:
parent
a27b1d6cfa
commit
7fc43444a9
363
apps/backend/scripts/generate-ports-seed.ts
Normal file
363
apps/backend/scripts/generate-ports-seed.ts
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
/**
|
||||||
|
* Script to generate ports seed migration from sea-ports JSON data
|
||||||
|
*
|
||||||
|
* Data source: https://github.com/marchah/sea-ports
|
||||||
|
* License: MIT
|
||||||
|
*
|
||||||
|
* This script:
|
||||||
|
* 1. Reads sea-ports.json from /tmp
|
||||||
|
* 2. Parses and validates port data
|
||||||
|
* 3. Generates SQL INSERT statements
|
||||||
|
* 4. Creates a TypeORM migration file
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
interface SeaPort {
|
||||||
|
name: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
coordinates: [number, number]; // [longitude, latitude]
|
||||||
|
province?: string;
|
||||||
|
timezone?: string;
|
||||||
|
unlocs: string[];
|
||||||
|
code?: string;
|
||||||
|
alias?: string[];
|
||||||
|
regions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeaPortsData {
|
||||||
|
[locode: string]: SeaPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedPort {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
countryName: string;
|
||||||
|
countryCode: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
timezone: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Country code to name mapping (ISO 3166-1 alpha-2)
|
||||||
|
const countryNames: { [key: string]: string } = {
|
||||||
|
AE: 'United Arab Emirates',
|
||||||
|
AG: 'Antigua and Barbuda',
|
||||||
|
AL: 'Albania',
|
||||||
|
AM: 'Armenia',
|
||||||
|
AO: 'Angola',
|
||||||
|
AR: 'Argentina',
|
||||||
|
AT: 'Austria',
|
||||||
|
AU: 'Australia',
|
||||||
|
AZ: 'Azerbaijan',
|
||||||
|
BA: 'Bosnia and Herzegovina',
|
||||||
|
BB: 'Barbados',
|
||||||
|
BD: 'Bangladesh',
|
||||||
|
BE: 'Belgium',
|
||||||
|
BG: 'Bulgaria',
|
||||||
|
BH: 'Bahrain',
|
||||||
|
BN: 'Brunei',
|
||||||
|
BR: 'Brazil',
|
||||||
|
BS: 'Bahamas',
|
||||||
|
BZ: 'Belize',
|
||||||
|
CA: 'Canada',
|
||||||
|
CH: 'Switzerland',
|
||||||
|
CI: 'Ivory Coast',
|
||||||
|
CL: 'Chile',
|
||||||
|
CM: 'Cameroon',
|
||||||
|
CN: 'China',
|
||||||
|
CO: 'Colombia',
|
||||||
|
CR: 'Costa Rica',
|
||||||
|
CU: 'Cuba',
|
||||||
|
CY: 'Cyprus',
|
||||||
|
CZ: 'Czech Republic',
|
||||||
|
DE: 'Germany',
|
||||||
|
DJ: 'Djibouti',
|
||||||
|
DK: 'Denmark',
|
||||||
|
DO: 'Dominican Republic',
|
||||||
|
DZ: 'Algeria',
|
||||||
|
EC: 'Ecuador',
|
||||||
|
EE: 'Estonia',
|
||||||
|
EG: 'Egypt',
|
||||||
|
ES: 'Spain',
|
||||||
|
FI: 'Finland',
|
||||||
|
FJ: 'Fiji',
|
||||||
|
FR: 'France',
|
||||||
|
GA: 'Gabon',
|
||||||
|
GB: 'United Kingdom',
|
||||||
|
GE: 'Georgia',
|
||||||
|
GH: 'Ghana',
|
||||||
|
GI: 'Gibraltar',
|
||||||
|
GR: 'Greece',
|
||||||
|
GT: 'Guatemala',
|
||||||
|
GY: 'Guyana',
|
||||||
|
HK: 'Hong Kong',
|
||||||
|
HN: 'Honduras',
|
||||||
|
HR: 'Croatia',
|
||||||
|
HT: 'Haiti',
|
||||||
|
HU: 'Hungary',
|
||||||
|
ID: 'Indonesia',
|
||||||
|
IE: 'Ireland',
|
||||||
|
IL: 'Israel',
|
||||||
|
IN: 'India',
|
||||||
|
IQ: 'Iraq',
|
||||||
|
IR: 'Iran',
|
||||||
|
IS: 'Iceland',
|
||||||
|
IT: 'Italy',
|
||||||
|
JM: 'Jamaica',
|
||||||
|
JO: 'Jordan',
|
||||||
|
JP: 'Japan',
|
||||||
|
KE: 'Kenya',
|
||||||
|
KH: 'Cambodia',
|
||||||
|
KR: 'South Korea',
|
||||||
|
KW: 'Kuwait',
|
||||||
|
KZ: 'Kazakhstan',
|
||||||
|
LB: 'Lebanon',
|
||||||
|
LK: 'Sri Lanka',
|
||||||
|
LR: 'Liberia',
|
||||||
|
LT: 'Lithuania',
|
||||||
|
LV: 'Latvia',
|
||||||
|
LY: 'Libya',
|
||||||
|
MA: 'Morocco',
|
||||||
|
MC: 'Monaco',
|
||||||
|
MD: 'Moldova',
|
||||||
|
ME: 'Montenegro',
|
||||||
|
MG: 'Madagascar',
|
||||||
|
MK: 'North Macedonia',
|
||||||
|
MM: 'Myanmar',
|
||||||
|
MN: 'Mongolia',
|
||||||
|
MO: 'Macau',
|
||||||
|
MR: 'Mauritania',
|
||||||
|
MT: 'Malta',
|
||||||
|
MU: 'Mauritius',
|
||||||
|
MV: 'Maldives',
|
||||||
|
MX: 'Mexico',
|
||||||
|
MY: 'Malaysia',
|
||||||
|
MZ: 'Mozambique',
|
||||||
|
NA: 'Namibia',
|
||||||
|
NG: 'Nigeria',
|
||||||
|
NI: 'Nicaragua',
|
||||||
|
NL: 'Netherlands',
|
||||||
|
NO: 'Norway',
|
||||||
|
NZ: 'New Zealand',
|
||||||
|
OM: 'Oman',
|
||||||
|
PA: 'Panama',
|
||||||
|
PE: 'Peru',
|
||||||
|
PG: 'Papua New Guinea',
|
||||||
|
PH: 'Philippines',
|
||||||
|
PK: 'Pakistan',
|
||||||
|
PL: 'Poland',
|
||||||
|
PR: 'Puerto Rico',
|
||||||
|
PT: 'Portugal',
|
||||||
|
PY: 'Paraguay',
|
||||||
|
QA: 'Qatar',
|
||||||
|
RO: 'Romania',
|
||||||
|
RS: 'Serbia',
|
||||||
|
RU: 'Russia',
|
||||||
|
SA: 'Saudi Arabia',
|
||||||
|
SD: 'Sudan',
|
||||||
|
SE: 'Sweden',
|
||||||
|
SG: 'Singapore',
|
||||||
|
SI: 'Slovenia',
|
||||||
|
SK: 'Slovakia',
|
||||||
|
SN: 'Senegal',
|
||||||
|
SO: 'Somalia',
|
||||||
|
SR: 'Suriname',
|
||||||
|
SY: 'Syria',
|
||||||
|
TH: 'Thailand',
|
||||||
|
TN: 'Tunisia',
|
||||||
|
TR: 'Turkey',
|
||||||
|
TT: 'Trinidad and Tobago',
|
||||||
|
TW: 'Taiwan',
|
||||||
|
TZ: 'Tanzania',
|
||||||
|
UA: 'Ukraine',
|
||||||
|
UG: 'Uganda',
|
||||||
|
US: 'United States',
|
||||||
|
UY: 'Uruguay',
|
||||||
|
VE: 'Venezuela',
|
||||||
|
VN: 'Vietnam',
|
||||||
|
YE: 'Yemen',
|
||||||
|
ZA: 'South Africa',
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseSeaPorts(filePath: string): ParsedPort[] {
|
||||||
|
const jsonData = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const seaPorts: SeaPortsData = JSON.parse(jsonData);
|
||||||
|
|
||||||
|
const parsedPorts: ParsedPort[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const [locode, port] of Object.entries(seaPorts)) {
|
||||||
|
// Validate required fields
|
||||||
|
if (!port.name || !port.coordinates || port.coordinates.length !== 2) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract country code from UN/LOCODE (first 2 characters)
|
||||||
|
const countryCode = locode.substring(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
// Skip if invalid country code
|
||||||
|
if (!countryNames[countryCode]) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate coordinates
|
||||||
|
const [longitude, latitude] = port.coordinates;
|
||||||
|
if (
|
||||||
|
latitude < -90 || latitude > 90 ||
|
||||||
|
longitude < -180 || longitude > 180
|
||||||
|
) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedPorts.push({
|
||||||
|
code: locode.toUpperCase(),
|
||||||
|
name: port.name.trim(),
|
||||||
|
city: port.city?.trim() || port.name.trim(),
|
||||||
|
country: countryCode,
|
||||||
|
countryName: countryNames[countryCode] || port.country,
|
||||||
|
countryCode: countryCode,
|
||||||
|
latitude: Number(latitude.toFixed(6)),
|
||||||
|
longitude: Number(longitude.toFixed(6)),
|
||||||
|
timezone: port.timezone || null,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Parsed ${parsedPorts.length} ports`);
|
||||||
|
console.log(`⚠️ Skipped ${skipped} invalid entries`);
|
||||||
|
|
||||||
|
return parsedPorts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSQLInserts(ports: ParsedPort[]): string {
|
||||||
|
const batchSize = 100;
|
||||||
|
const batches: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < ports.length; i += batchSize) {
|
||||||
|
const batch = ports.slice(i, i + batchSize);
|
||||||
|
const values = batch.map(port => {
|
||||||
|
const name = port.name.replace(/'/g, "''");
|
||||||
|
const city = port.city.replace(/'/g, "''");
|
||||||
|
const countryName = port.countryName.replace(/'/g, "''");
|
||||||
|
const timezone = port.timezone ? `'${port.timezone}'` : 'NULL';
|
||||||
|
|
||||||
|
return `(
|
||||||
|
'${port.code}',
|
||||||
|
'${name}',
|
||||||
|
'${city}',
|
||||||
|
'${port.country}',
|
||||||
|
'${countryName}',
|
||||||
|
${port.latitude},
|
||||||
|
${port.longitude},
|
||||||
|
${timezone},
|
||||||
|
${port.isActive}
|
||||||
|
)`;
|
||||||
|
}).join(',\n ');
|
||||||
|
|
||||||
|
batches.push(`
|
||||||
|
// Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(ports.length / batchSize)} (${batch.length} ports)
|
||||||
|
await queryRunner.query(\`
|
||||||
|
INSERT INTO ports (code, name, city, country, country_name, latitude, longitude, timezone, is_active)
|
||||||
|
VALUES ${values}
|
||||||
|
\`);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return batches.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMigration(ports: ParsedPort[]): string {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const className = `SeedPorts${timestamp}`;
|
||||||
|
const sqlInserts = generateSQLInserts(ports);
|
||||||
|
|
||||||
|
const migrationContent = `/**
|
||||||
|
* Migration: Seed Ports Table
|
||||||
|
*
|
||||||
|
* Source: sea-ports (https://github.com/marchah/sea-ports)
|
||||||
|
* License: MIT
|
||||||
|
* Generated: ${new Date().toISOString()}
|
||||||
|
* Total ports: ${ports.length}
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class ${className} implements MigrationInterface {
|
||||||
|
name = '${className}';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
console.log('Seeding ${ports.length} maritime ports...');
|
||||||
|
|
||||||
|
${sqlInserts}
|
||||||
|
|
||||||
|
console.log('✅ Successfully seeded ${ports.length} ports');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(\`TRUNCATE TABLE ports RESTART IDENTITY CASCADE\`);
|
||||||
|
console.log('🗑️ Cleared all ports');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return migrationContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const seaPortsPath = '/tmp/sea-ports.json';
|
||||||
|
|
||||||
|
console.log('🚢 Generating Ports Seed Migration\n');
|
||||||
|
|
||||||
|
// Check if sea-ports.json exists
|
||||||
|
if (!fs.existsSync(seaPortsPath)) {
|
||||||
|
console.error('❌ Error: /tmp/sea-ports.json not found!');
|
||||||
|
console.log('Please download it first:');
|
||||||
|
console.log('curl -o /tmp/sea-ports.json https://raw.githubusercontent.com/marchah/sea-ports/master/lib/ports.json');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ports
|
||||||
|
console.log('📖 Parsing sea-ports.json...');
|
||||||
|
const ports = parseSeaPorts(seaPortsPath);
|
||||||
|
|
||||||
|
// Sort by country, then by name
|
||||||
|
ports.sort((a, b) => {
|
||||||
|
if (a.country !== b.country) {
|
||||||
|
return a.country.localeCompare(b.country);
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate migration
|
||||||
|
console.log('\n📝 Generating migration file...');
|
||||||
|
const migrationContent = generateMigration(ports);
|
||||||
|
|
||||||
|
// Write migration file
|
||||||
|
const migrationsDir = path.join(__dirname, '../src/infrastructure/persistence/typeorm/migrations');
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const fileName = `${timestamp}-SeedPorts.ts`;
|
||||||
|
const filePath = path.join(migrationsDir, fileName);
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, migrationContent, 'utf-8');
|
||||||
|
|
||||||
|
console.log(`\n✅ Migration created: ${fileName}`);
|
||||||
|
console.log(`📍 Location: ${filePath}`);
|
||||||
|
console.log(`\n📊 Summary:`);
|
||||||
|
console.log(` - Total ports: ${ports.length}`);
|
||||||
|
console.log(` - Countries: ${new Set(ports.map(p => p.country)).size}`);
|
||||||
|
console.log(` - Ports with timezone: ${ports.filter(p => p.timezone).length}`);
|
||||||
|
console.log(`\n🚀 Run the migration:`);
|
||||||
|
console.log(` cd apps/backend`);
|
||||||
|
console.log(` npm run migration:run`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
@ -8,6 +8,7 @@ import * as Joi from 'joi';
|
|||||||
// Import feature modules
|
// Import feature modules
|
||||||
import { AuthModule } from './application/auth/auth.module';
|
import { AuthModule } from './application/auth/auth.module';
|
||||||
import { RatesModule } from './application/rates/rates.module';
|
import { RatesModule } from './application/rates/rates.module';
|
||||||
|
import { PortsModule } from './application/ports/ports.module';
|
||||||
import { BookingsModule } from './application/bookings/bookings.module';
|
import { BookingsModule } from './application/bookings/bookings.module';
|
||||||
import { OrganizationsModule } from './application/organizations/organizations.module';
|
import { OrganizationsModule } from './application/organizations/organizations.module';
|
||||||
import { UsersModule } from './application/users/users.module';
|
import { UsersModule } from './application/users/users.module';
|
||||||
@ -95,6 +96,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
// Feature modules
|
// Feature modules
|
||||||
AuthModule,
|
AuthModule,
|
||||||
RatesModule,
|
RatesModule,
|
||||||
|
PortsModule,
|
||||||
BookingsModule,
|
BookingsModule,
|
||||||
CsvBookingsModule,
|
CsvBookingsModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
|
|||||||
98
apps/backend/src/application/controllers/ports.controller.ts
Normal file
98
apps/backend/src/application/controllers/ports.controller.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
UsePipes,
|
||||||
|
ValidationPipe,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBadRequestResponse,
|
||||||
|
ApiInternalServerErrorResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { PortSearchRequestDto, PortSearchResponseDto } from '../dto/port.dto';
|
||||||
|
import { PortMapper } from '../mappers/port.mapper';
|
||||||
|
import { PortSearchService } from '@domain/services/port-search.service';
|
||||||
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@ApiTags('Ports')
|
||||||
|
@Controller('ports')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class PortsController {
|
||||||
|
private readonly logger = new Logger(PortsController.name);
|
||||||
|
|
||||||
|
constructor(private readonly portSearchService: PortSearchService) {}
|
||||||
|
|
||||||
|
@Get('search')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Search ports (autocomplete)',
|
||||||
|
description:
|
||||||
|
'Search for maritime ports by name, city, or UN/LOCODE code. Returns up to 50 results ordered by relevance. Requires authentication.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'Port search completed successfully',
|
||||||
|
type: PortSearchResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: 'Unauthorized - missing or invalid token',
|
||||||
|
})
|
||||||
|
@ApiBadRequestResponse({
|
||||||
|
description: 'Invalid request parameters',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
statusCode: 400,
|
||||||
|
message: ['query must be a string'],
|
||||||
|
error: 'Bad Request',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiInternalServerErrorResponse({
|
||||||
|
description: 'Internal server error',
|
||||||
|
})
|
||||||
|
async searchPorts(
|
||||||
|
@Query() dto: PortSearchRequestDto,
|
||||||
|
@CurrentUser() user: UserPayload
|
||||||
|
): Promise<PortSearchResponseDto> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
this.logger.log(
|
||||||
|
`[User: ${user.email}] Searching ports: query="${dto.query}", limit=${dto.limit || 10}, country=${dto.countryFilter || 'all'}`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call domain service
|
||||||
|
const result = await this.portSearchService.search({
|
||||||
|
query: dto.query,
|
||||||
|
limit: dto.limit,
|
||||||
|
countryFilter: dto.countryFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.log(
|
||||||
|
`[User: ${user.email}] Port search completed: ${result.totalMatches} results in ${duration}ms`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map to response DTO
|
||||||
|
return PortMapper.toSearchResponseDto(result.ports, result.totalMatches);
|
||||||
|
} catch (error: any) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.error(
|
||||||
|
`[User: ${user.email}] Port search failed after ${duration}ms: ${error?.message || 'Unknown error'}`,
|
||||||
|
error?.stack
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,3 +7,6 @@ export * from './create-booking-request.dto';
|
|||||||
export * from './booking-response.dto';
|
export * from './booking-response.dto';
|
||||||
export * from './booking-filter.dto';
|
export * from './booking-filter.dto';
|
||||||
export * from './booking-export.dto';
|
export * from './booking-export.dto';
|
||||||
|
|
||||||
|
// Port DTOs
|
||||||
|
export * from './port.dto';
|
||||||
|
|||||||
146
apps/backend/src/application/dto/port.dto.ts
Normal file
146
apps/backend/src/application/dto/port.dto.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsNumber, IsOptional, IsBoolean, Min, Max } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port search request DTO
|
||||||
|
*/
|
||||||
|
export class PortSearchRequestDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'Rotterdam',
|
||||||
|
description: 'Search query - can be port name, city, or UN/LOCODE code',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
query: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 10,
|
||||||
|
description: 'Maximum number of results to return (default: 10)',
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 50,
|
||||||
|
})
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
@Min(1)
|
||||||
|
@Max(50)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'NL',
|
||||||
|
description: 'Filter by ISO 3166-1 alpha-2 country code (e.g., NL, FR, US)',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
countryFilter?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port coordinates DTO
|
||||||
|
*/
|
||||||
|
export class PortCoordinatesDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 51.9244,
|
||||||
|
description: 'Latitude',
|
||||||
|
})
|
||||||
|
@IsNumber()
|
||||||
|
latitude: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 4.4777,
|
||||||
|
description: 'Longitude',
|
||||||
|
})
|
||||||
|
@IsNumber()
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port response DTO
|
||||||
|
*/
|
||||||
|
export class PortResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||||
|
description: 'Port unique identifier',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'NLRTM',
|
||||||
|
description: 'UN/LOCODE port code',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'Port of Rotterdam',
|
||||||
|
description: 'Port name',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'Rotterdam',
|
||||||
|
description: 'City name',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
city: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'NL',
|
||||||
|
description: 'ISO 3166-1 alpha-2 country code',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
country: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'Netherlands',
|
||||||
|
description: 'Full country name',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
countryName: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Port coordinates (latitude/longitude)',
|
||||||
|
type: PortCoordinatesDto,
|
||||||
|
})
|
||||||
|
coordinates: PortCoordinatesDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'Europe/Amsterdam',
|
||||||
|
description: 'IANA timezone identifier',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
timezone?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: true,
|
||||||
|
description: 'Whether the port is active',
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'Port of Rotterdam, Netherlands (NLRTM)',
|
||||||
|
description: 'Full display name with code',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port search response DTO
|
||||||
|
*/
|
||||||
|
export class PortSearchResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'List of matching ports',
|
||||||
|
type: [PortResponseDto],
|
||||||
|
})
|
||||||
|
ports: PortResponseDto[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 10,
|
||||||
|
description: 'Number of ports returned',
|
||||||
|
})
|
||||||
|
@IsNumber()
|
||||||
|
totalMatches: number;
|
||||||
|
}
|
||||||
@ -1,2 +1,3 @@
|
|||||||
export * from './rate-quote.mapper';
|
export * from './rate-quote.mapper';
|
||||||
export * from './booking.mapper';
|
export * from './booking.mapper';
|
||||||
|
export * from './port.mapper';
|
||||||
|
|||||||
44
apps/backend/src/application/mappers/port.mapper.ts
Normal file
44
apps/backend/src/application/mappers/port.mapper.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Port } from '@domain/entities/port.entity';
|
||||||
|
import { PortResponseDto, PortCoordinatesDto, PortSearchResponseDto } from '../dto/port.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PortMapper {
|
||||||
|
/**
|
||||||
|
* Map Port entity to PortResponseDto
|
||||||
|
*/
|
||||||
|
static toDto(port: Port): PortResponseDto {
|
||||||
|
return {
|
||||||
|
id: port.id,
|
||||||
|
code: port.code,
|
||||||
|
name: port.name,
|
||||||
|
city: port.city,
|
||||||
|
country: port.country,
|
||||||
|
countryName: port.countryName,
|
||||||
|
coordinates: {
|
||||||
|
latitude: port.coordinates.latitude,
|
||||||
|
longitude: port.coordinates.longitude,
|
||||||
|
},
|
||||||
|
timezone: port.timezone,
|
||||||
|
isActive: port.isActive,
|
||||||
|
displayName: port.getDisplayName(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map array of Port entities to array of PortResponseDto
|
||||||
|
*/
|
||||||
|
static toDtoArray(ports: Port[]): PortResponseDto[] {
|
||||||
|
return ports.map(port => this.toDto(port));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Port search output to PortSearchResponseDto
|
||||||
|
*/
|
||||||
|
static toSearchResponseDto(ports: Port[], totalMatches: number): PortSearchResponseDto {
|
||||||
|
return {
|
||||||
|
ports: this.toDtoArray(ports),
|
||||||
|
totalMatches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/backend/src/application/ports/ports.module.ts
Normal file
33
apps/backend/src/application/ports/ports.module.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { PortsController } from '../controllers/ports.controller';
|
||||||
|
|
||||||
|
// Import domain services
|
||||||
|
import { PortSearchService } from '@domain/services/port-search.service';
|
||||||
|
|
||||||
|
// Import domain ports
|
||||||
|
import { PORT_REPOSITORY } from '@domain/ports/out/port.repository';
|
||||||
|
|
||||||
|
// Import infrastructure implementations
|
||||||
|
import { TypeOrmPortRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-port.repository';
|
||||||
|
import { PortOrmEntity } from '../../infrastructure/persistence/typeorm/entities/port.orm-entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([PortOrmEntity])],
|
||||||
|
controllers: [PortsController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: PORT_REPOSITORY,
|
||||||
|
useClass: TypeOrmPortRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PortSearchService,
|
||||||
|
useFactory: (portRepo: any) => {
|
||||||
|
return new PortSearchService(portRepo);
|
||||||
|
},
|
||||||
|
inject: [PORT_REPOSITORY],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [PORT_REPOSITORY, PortSearchService],
|
||||||
|
})
|
||||||
|
export class PortsModule {}
|
||||||
@ -0,0 +1,225 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class SeedMajorPorts1733184000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO ports (code, name, city, country, country_name, latitude, longitude, timezone, is_active)
|
||||||
|
VALUES
|
||||||
|
-- ASIA (60+ ports)
|
||||||
|
('CNSHA', 'Shanghai Port', 'Shanghai', 'CN', 'China', 31.2304, 121.4737, 'Asia/Shanghai', true),
|
||||||
|
('SGSIN', 'Singapore Port', 'Singapore', 'SG', 'Singapore', 1.3521, 103.8198, 'Asia/Singapore', true),
|
||||||
|
('HKHKG', 'Hong Kong Port', 'Hong Kong', 'HK', 'Hong Kong', 22.3193, 114.1694, 'Asia/Hong_Kong', true),
|
||||||
|
('KRPUS', 'Busan Port', 'Busan', 'KR', 'South Korea', 35.1796, 129.0756, 'Asia/Seoul', true),
|
||||||
|
('JPTYO', 'Tokyo Port', 'Tokyo', 'JP', 'Japan', 35.6532, 139.7604, 'Asia/Tokyo', true),
|
||||||
|
('AEDXB', 'Jebel Ali Port', 'Dubai', 'AE', 'United Arab Emirates', 24.9857, 55.0272, 'Asia/Dubai', true),
|
||||||
|
('CNYTN', 'Yantian Port', 'Shenzhen', 'CN', 'China', 22.5817, 114.2633, 'Asia/Shanghai', true),
|
||||||
|
('CNNGB', 'Ningbo-Zhoushan Port', 'Ningbo', 'CN', 'China', 29.8683, 121.544, 'Asia/Shanghai', true),
|
||||||
|
('CNQIN', 'Qingdao Port', 'Qingdao', 'CN', 'China', 36.0671, 120.3826, 'Asia/Shanghai', true),
|
||||||
|
('CNTXG', 'Tianjin Port', 'Tianjin', 'CN', 'China', 38.9833, 117.75, 'Asia/Shanghai', true),
|
||||||
|
('CNXMN', 'Xiamen Port', 'Xiamen', 'CN', 'China', 24.4798, 118.0819, 'Asia/Shanghai', true),
|
||||||
|
('CNDLC', 'Dalian Port', 'Dalian', 'CN', 'China', 38.9140, 121.6147, 'Asia/Shanghai', true),
|
||||||
|
('CNGZH', 'Guangzhou Port', 'Guangzhou', 'CN', 'China', 23.1291, 113.2644, 'Asia/Shanghai', true),
|
||||||
|
('CNSGH', 'Shekou Port', 'Shenzhen', 'CN', 'China', 22.4814, 113.9107, 'Asia/Shanghai', true),
|
||||||
|
('JPOSA', 'Osaka Port', 'Osaka', 'JP', 'Japan', 34.6526, 135.4305, 'Asia/Tokyo', true),
|
||||||
|
('JPNGO', 'Nagoya Port', 'Nagoya', 'JP', 'Japan', 35.0833, 136.8833, 'Asia/Tokyo', true),
|
||||||
|
('JPYOK', 'Yokohama Port', 'Yokohama', 'JP', 'Japan', 35.4437, 139.6380, 'Asia/Tokyo', true),
|
||||||
|
('JPKOB', 'Kobe Port', 'Kobe', 'JP', 'Japan', 34.6901, 135.1955, 'Asia/Tokyo', true),
|
||||||
|
('KRICP', 'Incheon Port', 'Incheon', 'KR', 'South Korea', 37.4563, 126.7052, 'Asia/Seoul', true),
|
||||||
|
('KRKAN', 'Gwangyang Port', 'Gwangyang', 'KR', 'South Korea', 34.9400, 127.7000, 'Asia/Seoul', true),
|
||||||
|
('TWKHH', 'Kaohsiung Port', 'Kaohsiung', 'TW', 'Taiwan', 22.6273, 120.3014, 'Asia/Taipei', true),
|
||||||
|
('TWKEL', 'Keelung Port', 'Keelung', 'TW', 'Taiwan', 25.1478, 121.7445, 'Asia/Taipei', true),
|
||||||
|
('TWTPE', 'Taipei Port', 'Taipei', 'TW', 'Taiwan', 25.1333, 121.4167, 'Asia/Taipei', true),
|
||||||
|
('MYTPP', 'Port Klang', 'Port Klang', 'MY', 'Malaysia', 2.9989, 101.3935, 'Asia/Kuala_Lumpur', true),
|
||||||
|
('MYPKG', 'Penang Port', 'Penang', 'MY', 'Malaysia', 5.4164, 100.3327, 'Asia/Kuala_Lumpur', true),
|
||||||
|
('THLCH', 'Laem Chabang Port', 'Chonburi', 'TH', 'Thailand', 13.0833, 100.8833, 'Asia/Bangkok', true),
|
||||||
|
('THBKK', 'Bangkok Port', 'Bangkok', 'TH', 'Thailand', 13.7563, 100.5018, 'Asia/Bangkok', true),
|
||||||
|
('VNSGN', 'Ho Chi Minh Port', 'Ho Chi Minh City', 'VN', 'Vietnam', 10.7769, 106.7009, 'Asia/Ho_Chi_Minh', true),
|
||||||
|
('VNHPH', 'Haiphong Port', 'Haiphong', 'VN', 'Vietnam', 20.8449, 106.6881, 'Asia/Ho_Chi_Minh', true),
|
||||||
|
('IDTPP', 'Tanjung Priok Port', 'Jakarta', 'ID', 'Indonesia', -6.1045, 106.8833, 'Asia/Jakarta', true),
|
||||||
|
('IDSBW', 'Surabaya Port', 'Surabaya', 'ID', 'Indonesia', -7.2575, 112.7521, 'Asia/Jakarta', true),
|
||||||
|
('PHMNL', 'Manila Port', 'Manila', 'PH', 'Philippines', 14.5995, 120.9842, 'Asia/Manila', true),
|
||||||
|
('PHCBU', 'Cebu Port', 'Cebu', 'PH', 'Philippines', 10.3157, 123.8854, 'Asia/Manila', true),
|
||||||
|
('INNSA', 'Nhava Sheva Port', 'Mumbai', 'IN', 'India', 18.9480, 72.9508, 'Asia/Kolkata', true),
|
||||||
|
('INCCU', 'Chennai Port', 'Chennai', 'IN', 'India', 13.0827, 80.2707, 'Asia/Kolkata', true),
|
||||||
|
('INCOK', 'Cochin Port', 'Kochi', 'IN', 'India', 9.9312, 76.2673, 'Asia/Kolkata', true),
|
||||||
|
('INMUN', 'Mundra Port', 'Mundra', 'IN', 'India', 22.8333, 69.7167, 'Asia/Kolkata', true),
|
||||||
|
('INTUT', 'Tuticorin Port', 'Tuticorin', 'IN', 'India', 8.7642, 78.1348, 'Asia/Kolkata', true),
|
||||||
|
('PKKHI', 'Karachi Port', 'Karachi', 'PK', 'Pakistan', 24.8607, 67.0011, 'Asia/Karachi', true),
|
||||||
|
('LKCMB', 'Colombo Port', 'Colombo', 'LK', 'Sri Lanka', 6.9271, 79.8612, 'Asia/Colombo', true),
|
||||||
|
('BDCGP', 'Chittagong Port', 'Chittagong', 'BD', 'Bangladesh', 22.3569, 91.7832, 'Asia/Dhaka', true),
|
||||||
|
('OMMCT', 'Muscat Port', 'Muscat', 'OM', 'Oman', 23.6100, 58.5400, 'Asia/Muscat', true),
|
||||||
|
('AEJEA', 'Jebel Ali Port', 'Jebel Ali', 'AE', 'United Arab Emirates', 24.9857, 55.0272, 'Asia/Dubai', true),
|
||||||
|
('AESHJ', 'Sharjah Port', 'Sharjah', 'AE', 'United Arab Emirates', 25.3463, 55.4209, 'Asia/Dubai', true),
|
||||||
|
('SAGAS', 'Dammam Port', 'Dammam', 'SA', 'Saudi Arabia', 26.4207, 50.0888, 'Asia/Riyadh', true),
|
||||||
|
('SAJED', 'Jeddah Port', 'Jeddah', 'SA', 'Saudi Arabia', 21.5169, 39.1748, 'Asia/Riyadh', true),
|
||||||
|
('KWKWI', 'Kuwait Port', 'Kuwait', 'KW', 'Kuwait', 29.3759, 47.9774, 'Asia/Kuwait', true),
|
||||||
|
('QADOG', 'Doha Port', 'Doha', 'QA', 'Qatar', 25.2854, 51.5310, 'Asia/Qatar', true),
|
||||||
|
('BHBAH', 'Manama Port', 'Manama', 'BH', 'Bahrain', 26.2285, 50.5860, 'Asia/Bahrain', true),
|
||||||
|
('TRIST', 'Istanbul Port', 'Istanbul', 'TR', 'Turkey', 41.0082, 28.9784, 'Europe/Istanbul', true),
|
||||||
|
('TRMER', 'Mersin Port', 'Mersin', 'TR', 'Turkey', 36.8121, 34.6415, 'Europe/Istanbul', true),
|
||||||
|
('ILHFA', 'Haifa Port', 'Haifa', 'IL', 'Israel', 32.8156, 34.9892, 'Asia/Jerusalem', true),
|
||||||
|
('ILASH', 'Ashdod Port', 'Ashdod', 'IL', 'Israel', 31.8044, 34.6553, 'Asia/Jerusalem', true),
|
||||||
|
('JOJOR', 'Aqaba Port', 'Aqaba', 'JO', 'Jordan', 29.5267, 35.0081, 'Asia/Amman', true),
|
||||||
|
('LBBEY', 'Beirut Port', 'Beirut', 'LB', 'Lebanon', 33.8886, 35.4955, 'Asia/Beirut', true),
|
||||||
|
('RUULY', 'Vladivostok Port', 'Vladivostok', 'RU', 'Russia', 43.1332, 131.9113, 'Asia/Vladivostok', true),
|
||||||
|
('RUVVO', 'Vostochny Port', 'Vostochny', 'RU', 'Russia', 42.7167, 133.0667, 'Asia/Vladivostok', true),
|
||||||
|
|
||||||
|
-- EUROPE (60+ ports)
|
||||||
|
('NLRTM', 'Rotterdam Port', 'Rotterdam', 'NL', 'Netherlands', 51.9225, 4.4792, 'Europe/Amsterdam', true),
|
||||||
|
('DEHAM', 'Hamburg Port', 'Hamburg', 'DE', 'Germany', 53.5511, 9.9937, 'Europe/Berlin', true),
|
||||||
|
('BEANR', 'Antwerp Port', 'Antwerp', 'BE', 'Belgium', 51.2194, 4.4025, 'Europe/Brussels', true),
|
||||||
|
('FRLEH', 'Le Havre Port', 'Le Havre', 'FR', 'France', 49.4944, 0.1079, 'Europe/Paris', true),
|
||||||
|
('ESBCN', 'Barcelona Port', 'Barcelona', 'ES', 'Spain', 41.3851, 2.1734, 'Europe/Madrid', true),
|
||||||
|
('FRFOS', 'Marseille Port', 'Marseille', 'FR', 'France', 43.2965, 5.3698, 'Europe/Paris', true),
|
||||||
|
('GBSOU', 'Southampton Port', 'Southampton', 'GB', 'United Kingdom', 50.9097, -1.4044, 'Europe/London', true),
|
||||||
|
('GBFEL', 'Felixstowe Port', 'Felixstowe', 'GB', 'United Kingdom', 51.9563, 1.3417, 'Europe/London', true),
|
||||||
|
('GBLON', 'London Gateway Port', 'London', 'GB', 'United Kingdom', 51.5074, -0.1278, 'Europe/London', true),
|
||||||
|
('GBDOV', 'Dover Port', 'Dover', 'GB', 'United Kingdom', 51.1295, 1.3089, 'Europe/London', true),
|
||||||
|
('DEBER', 'Bremerhaven Port', 'Bremerhaven', 'DE', 'Germany', 53.5395, 8.5809, 'Europe/Berlin', true),
|
||||||
|
('DEBRV', 'Bremen Port', 'Bremen', 'DE', 'Germany', 53.0793, 8.8017, 'Europe/Berlin', true),
|
||||||
|
('NLAMS', 'Amsterdam Port', 'Amsterdam', 'NL', 'Netherlands', 52.3676, 4.9041, 'Europe/Amsterdam', true),
|
||||||
|
('NLVLI', 'Vlissingen Port', 'Vlissingen', 'NL', 'Netherlands', 51.4427, 3.5734, 'Europe/Amsterdam', true),
|
||||||
|
('BEZEE', 'Zeebrugge Port', 'Zeebrugge', 'BE', 'Belgium', 51.3333, 3.2000, 'Europe/Brussels', true),
|
||||||
|
('BEGNE', 'Ghent Port', 'Ghent', 'BE', 'Belgium', 51.0543, 3.7174, 'Europe/Brussels', true),
|
||||||
|
('FRDKK', 'Dunkerque Port', 'Dunkerque', 'FR', 'France', 51.0343, 2.3768, 'Europe/Paris', true),
|
||||||
|
('ITGOA', 'Genoa Port', 'Genoa', 'IT', 'Italy', 44.4056, 8.9463, 'Europe/Rome', true),
|
||||||
|
('ITLSP', 'La Spezia Port', 'La Spezia', 'IT', 'Italy', 44.1024, 9.8241, 'Europe/Rome', true),
|
||||||
|
('ITVCE', 'Venice Port', 'Venice', 'IT', 'Italy', 45.4408, 12.3155, 'Europe/Rome', true),
|
||||||
|
('ITNAP', 'Naples Port', 'Naples', 'IT', 'Italy', 40.8518, 14.2681, 'Europe/Rome', true),
|
||||||
|
('ESALG', 'Algeciras Port', 'Algeciras', 'ES', 'Spain', 36.1408, -5.4534, 'Europe/Madrid', true),
|
||||||
|
('ESVLC', 'Valencia Port', 'Valencia', 'ES', 'Spain', 39.4699, -0.3763, 'Europe/Madrid', true),
|
||||||
|
('ESBIO', 'Bilbao Port', 'Bilbao', 'ES', 'Spain', 43.2630, -2.9350, 'Europe/Madrid', true),
|
||||||
|
('PTLIS', 'Lisbon Port', 'Lisbon', 'PT', 'Portugal', 38.7223, -9.1393, 'Europe/Lisbon', true),
|
||||||
|
('PTSIE', 'Sines Port', 'Sines', 'PT', 'Portugal', 37.9553, -8.8738, 'Europe/Lisbon', true),
|
||||||
|
('GRATH', 'Piraeus Port', 'Athens', 'GR', 'Greece', 37.9838, 23.7275, 'Europe/Athens', true),
|
||||||
|
('GRTHE', 'Thessaloniki Port', 'Thessaloniki', 'GR', 'Greece', 40.6401, 22.9444, 'Europe/Athens', true),
|
||||||
|
('SESOE', 'Stockholm Port', 'Stockholm', 'SE', 'Sweden', 59.3293, 18.0686, 'Europe/Stockholm', true),
|
||||||
|
('SEGOT', 'Gothenburg Port', 'Gothenburg', 'SE', 'Sweden', 57.7089, 11.9746, 'Europe/Stockholm', true),
|
||||||
|
('DKAAR', 'Aarhus Port', 'Aarhus', 'DK', 'Denmark', 56.1629, 10.2039, 'Europe/Copenhagen', true),
|
||||||
|
('DKCPH', 'Copenhagen Port', 'Copenhagen', 'DK', 'Denmark', 55.6761, 12.5683, 'Europe/Copenhagen', true),
|
||||||
|
('NOSVG', 'Stavanger Port', 'Stavanger', 'NO', 'Norway', 58.9700, 5.7331, 'Europe/Oslo', true),
|
||||||
|
('NOOSL', 'Oslo Port', 'Oslo', 'NO', 'Norway', 59.9139, 10.7522, 'Europe/Oslo', true),
|
||||||
|
('FIHEL', 'Helsinki Port', 'Helsinki', 'FI', 'Finland', 60.1695, 24.9354, 'Europe/Helsinki', true),
|
||||||
|
('PLGDN', 'Gdansk Port', 'Gdansk', 'PL', 'Poland', 54.3520, 18.6466, 'Europe/Warsaw', true),
|
||||||
|
('PLGDY', 'Gdynia Port', 'Gdynia', 'PL', 'Poland', 54.5189, 18.5305, 'Europe/Warsaw', true),
|
||||||
|
('RULED', 'St. Petersburg Port', 'St. Petersburg', 'RU', 'Russia', 59.9343, 30.3351, 'Europe/Moscow', true),
|
||||||
|
('RUKLG', 'Kaliningrad Port', 'Kaliningrad', 'RU', 'Russia', 54.7104, 20.4522, 'Europe/Kaliningrad', true),
|
||||||
|
('RUNVS', 'Novorossiysk Port', 'Novorossiysk', 'RU', 'Russia', 44.7170, 37.7688, 'Europe/Moscow', true),
|
||||||
|
('EESLL', 'Tallinn Port', 'Tallinn', 'EE', 'Estonia', 59.4370, 24.7536, 'Europe/Tallinn', true),
|
||||||
|
('LVRIX', 'Riga Port', 'Riga', 'LV', 'Latvia', 56.9496, 24.1052, 'Europe/Riga', true),
|
||||||
|
('LTKLA', 'Klaipeda Port', 'Klaipeda', 'LT', 'Lithuania', 55.7033, 21.1443, 'Europe/Vilnius', true),
|
||||||
|
('ROCND', 'Constanta Port', 'Constanta', 'RO', 'Romania', 44.1598, 28.6348, 'Europe/Bucharest', true),
|
||||||
|
('BGVAR', 'Varna Port', 'Varna', 'BG', 'Bulgaria', 43.2141, 27.9147, 'Europe/Sofia', true),
|
||||||
|
('UAODS', 'Odessa Port', 'Odessa', 'UA', 'Ukraine', 46.4825, 30.7233, 'Europe/Kiev', true),
|
||||||
|
('UAIEV', 'Ilyichevsk Port', 'Ilyichevsk', 'UA', 'Ukraine', 46.3000, 30.6500, 'Europe/Kiev', true),
|
||||||
|
('TRAMB', 'Ambarli Port', 'Istanbul', 'TR', 'Turkey', 40.9808, 28.6875, 'Europe/Istanbul', true),
|
||||||
|
('TRIZM', 'Izmir Port', 'Izmir', 'TR', 'Turkey', 38.4237, 27.1428, 'Europe/Istanbul', true),
|
||||||
|
('HRRJK', 'Rijeka Port', 'Rijeka', 'HR', 'Croatia', 45.3271, 14.4422, 'Europe/Zagreb', true),
|
||||||
|
('SIKOP', 'Koper Port', 'Koper', 'SI', 'Slovenia', 45.5481, 13.7301, 'Europe/Ljubljana', true),
|
||||||
|
('MTMLA', 'Marsaxlokk Port', 'Marsaxlokk', 'MT', 'Malta', 35.8419, 14.5431, 'Europe/Malta', true),
|
||||||
|
('CYCAS', 'Limassol Port', 'Limassol', 'CY', 'Cyprus', 34.6773, 33.0439, 'Asia/Nicosia', true),
|
||||||
|
('IEORK', 'Cork Port', 'Cork', 'IE', 'Ireland', 51.8985, -8.4756, 'Europe/Dublin', true),
|
||||||
|
('IEDUB', 'Dublin Port', 'Dublin', 'IE', 'Ireland', 53.3498, -6.2603, 'Europe/Dublin', true),
|
||||||
|
|
||||||
|
-- NORTH AMERICA (30+ ports)
|
||||||
|
('USLAX', 'Los Angeles Port', 'Los Angeles', 'US', 'United States', 33.7405, -118.2720, 'America/Los_Angeles', true),
|
||||||
|
('USLGB', 'Long Beach Port', 'Long Beach', 'US', 'United States', 33.7701, -118.1937, 'America/Los_Angeles', true),
|
||||||
|
('USNYC', 'New York Port', 'New York', 'US', 'United States', 40.7128, -74.0060, 'America/New_York', true),
|
||||||
|
('USSAV', 'Savannah Port', 'Savannah', 'US', 'United States', 32.0809, -81.0912, 'America/New_York', true),
|
||||||
|
('USSEA', 'Seattle Port', 'Seattle', 'US', 'United States', 47.6062, -122.3321, 'America/Los_Angeles', true),
|
||||||
|
('USORF', 'Norfolk Port', 'Norfolk', 'US', 'United States', 36.8508, -76.2859, 'America/New_York', true),
|
||||||
|
('USHOU', 'Houston Port', 'Houston', 'US', 'United States', 29.7604, -95.3698, 'America/Chicago', true),
|
||||||
|
('USOAK', 'Oakland Port', 'Oakland', 'US', 'United States', 37.8044, -122.2711, 'America/Los_Angeles', true),
|
||||||
|
('USMIA', 'Miami Port', 'Miami', 'US', 'United States', 25.7617, -80.1918, 'America/New_York', true),
|
||||||
|
('USBAL', 'Baltimore Port', 'Baltimore', 'US', 'United States', 39.2904, -76.6122, 'America/New_York', true),
|
||||||
|
('USCHA', 'Charleston Port', 'Charleston', 'US', 'United States', 32.7765, -79.9311, 'America/New_York', true),
|
||||||
|
('USTPA', 'Tampa Port', 'Tampa', 'US', 'United States', 27.9506, -82.4572, 'America/New_York', true),
|
||||||
|
('USJAX', 'Jacksonville Port', 'Jacksonville', 'US', 'United States', 30.3322, -81.6557, 'America/New_York', true),
|
||||||
|
('USPHL', 'Philadelphia Port', 'Philadelphia', 'US', 'United States', 39.9526, -75.1652, 'America/New_York', true),
|
||||||
|
('USBOS', 'Boston Port', 'Boston', 'US', 'United States', 42.3601, -71.0589, 'America/New_York', true),
|
||||||
|
('USPDX', 'Portland Port', 'Portland', 'US', 'United States', 45.5152, -122.6784, 'America/Los_Angeles', true),
|
||||||
|
('USTAC', 'Tacoma Port', 'Tacoma', 'US', 'United States', 47.2529, -122.4443, 'America/Los_Angeles', true),
|
||||||
|
('USMSY', 'New Orleans Port', 'New Orleans', 'US', 'United States', 29.9511, -90.0715, 'America/Chicago', true),
|
||||||
|
('USEVE', 'Everglades Port', 'Fort Lauderdale', 'US', 'United States', 26.1224, -80.1373, 'America/New_York', true),
|
||||||
|
('CAYVR', 'Vancouver Port', 'Vancouver', 'CA', 'Canada', 49.2827, -123.1207, 'America/Vancouver', true),
|
||||||
|
('CATOR', 'Toronto Port', 'Toronto', 'CA', 'Canada', 43.6532, -79.3832, 'America/Toronto', true),
|
||||||
|
('CAMON', 'Montreal Port', 'Montreal', 'CA', 'Canada', 45.5017, -73.5673, 'America/Toronto', true),
|
||||||
|
('CAHAL', 'Halifax Port', 'Halifax', 'CA', 'Canada', 44.6488, -63.5752, 'America/Halifax', true),
|
||||||
|
('CAPRR', 'Prince Rupert Port', 'Prince Rupert', 'CA', 'Canada', 54.3150, -130.3208, 'America/Vancouver', true),
|
||||||
|
('MXVER', 'Veracruz Port', 'Veracruz', 'MX', 'Mexico', 19.1738, -96.1342, 'America/Mexico_City', true),
|
||||||
|
('MXMZT', 'Manzanillo Port', 'Manzanillo', 'MX', 'Mexico', 19.0543, -104.3188, 'America/Mexico_City', true),
|
||||||
|
('MXLZC', 'Lazaro Cardenas Port', 'Lazaro Cardenas', 'MX', 'Mexico', 17.9558, -102.2001, 'America/Mexico_City', true),
|
||||||
|
('MXATM', 'Altamira Port', 'Altamira', 'MX', 'Mexico', 22.3965, -97.9319, 'America/Mexico_City', true),
|
||||||
|
('MXENS', 'Ensenada Port', 'Ensenada', 'MX', 'Mexico', 31.8667, -116.6167, 'America/Tijuana', true),
|
||||||
|
('PAMIT', 'Balboa Port', 'Panama City', 'PA', 'Panama', 8.9824, -79.5199, 'America/Panama', true),
|
||||||
|
('PACRQ', 'Cristobal Port', 'Colon', 'PA', 'Panama', 9.3547, -79.9000, 'America/Panama', true),
|
||||||
|
('JMKIN', 'Kingston Port', 'Kingston', 'JM', 'Jamaica', 17.9714, -76.7931, 'America/Jamaica', true),
|
||||||
|
|
||||||
|
-- SOUTH AMERICA (20+ ports)
|
||||||
|
('BRSSZ', 'Santos Port', 'Santos', 'BR', 'Brazil', -23.9608, -46.3122, 'America/Sao_Paulo', true),
|
||||||
|
('BRRIO', 'Rio de Janeiro Port', 'Rio de Janeiro', 'BR', 'Brazil', -22.9068, -43.1729, 'America/Sao_Paulo', true),
|
||||||
|
('BRPNG', 'Paranagua Port', 'Paranagua', 'BR', 'Brazil', -25.5163, -48.5297, 'America/Sao_Paulo', true),
|
||||||
|
('BRRIG', 'Rio Grande Port', 'Rio Grande', 'BR', 'Brazil', -32.0350, -52.0986, 'America/Sao_Paulo', true),
|
||||||
|
('BRITJ', 'Itajai Port', 'Itajai', 'BR', 'Brazil', -26.9078, -48.6631, 'America/Sao_Paulo', true),
|
||||||
|
('BRVIX', 'Vitoria Port', 'Vitoria', 'BR', 'Brazil', -20.3155, -40.3128, 'America/Sao_Paulo', true),
|
||||||
|
('BRFOR', 'Fortaleza Port', 'Fortaleza', 'BR', 'Brazil', -3.7172, -38.5433, 'America/Fortaleza', true),
|
||||||
|
('BRMAO', 'Manaus Port', 'Manaus', 'BR', 'Brazil', -3.1190, -60.0217, 'America/Manaus', true),
|
||||||
|
('ARBUE', 'Buenos Aires Port', 'Buenos Aires', 'AR', 'Argentina', -34.6037, -58.3816, 'America/Argentina/Buenos_Aires', true),
|
||||||
|
('CLVAP', 'Valparaiso Port', 'Valparaiso', 'CL', 'Chile', -33.0472, -71.6127, 'America/Santiago', true),
|
||||||
|
('CLSAI', 'San Antonio Port', 'San Antonio', 'CL', 'Chile', -33.5931, -71.6200, 'America/Santiago', true),
|
||||||
|
('CLIQQ', 'Iquique Port', 'Iquique', 'CL', 'Chile', -20.2208, -70.1431, 'America/Santiago', true),
|
||||||
|
('PECLL', 'Callao Port', 'Lima', 'PE', 'Peru', -12.0464, -77.0428, 'America/Lima', true),
|
||||||
|
('PEPAI', 'Paita Port', 'Paita', 'PE', 'Peru', -5.0892, -81.1144, 'America/Lima', true),
|
||||||
|
('UYMVD', 'Montevideo Port', 'Montevideo', 'UY', 'Uruguay', -34.9011, -56.1645, 'America/Montevideo', true),
|
||||||
|
('COGPB', 'Guayaquil Port', 'Guayaquil', 'EC', 'Ecuador', -2.1709, -79.9224, 'America/Guayaquil', true),
|
||||||
|
('ECGYE', 'Manta Port', 'Manta', 'EC', 'Ecuador', -0.9590, -80.7089, 'America/Guayaquil', true),
|
||||||
|
('COBAQ', 'Barranquilla Port', 'Barranquilla', 'CO', 'Colombia', 10.9685, -74.7813, 'America/Bogota', true),
|
||||||
|
('COBUN', 'Buenaventura Port', 'Buenaventura', 'CO', 'Colombia', 3.8801, -77.0318, 'America/Bogota', true),
|
||||||
|
('COCAR', 'Cartagena Port', 'Cartagena', 'CO', 'Colombia', 10.3910, -75.4794, 'America/Bogota', true),
|
||||||
|
('VELGN', 'La Guaira Port', 'Caracas', 'VE', 'Venezuela', 10.6000, -66.9333, 'America/Caracas', true),
|
||||||
|
('VEPBL', 'Puerto Cabello Port', 'Puerto Cabello', 'VE', 'Venezuela', 10.4731, -68.0125, 'America/Caracas', true),
|
||||||
|
|
||||||
|
-- AFRICA (20+ ports)
|
||||||
|
('ZACPT', 'Cape Town Port', 'Cape Town', 'ZA', 'South Africa', -33.9249, 18.4241, 'Africa/Johannesburg', true),
|
||||||
|
('ZADUR', 'Durban Port', 'Durban', 'ZA', 'South Africa', -29.8587, 31.0218, 'Africa/Johannesburg', true),
|
||||||
|
('ZAPLZ', 'Port Elizabeth Port', 'Port Elizabeth', 'ZA', 'South Africa', -33.9608, 25.6022, 'Africa/Johannesburg', true),
|
||||||
|
('EGPSD', 'Port Said Port', 'Port Said', 'EG', 'Egypt', 31.2653, 32.3019, 'Africa/Cairo', true),
|
||||||
|
('EGALY', 'Alexandria Port', 'Alexandria', 'EG', 'Egypt', 31.2001, 29.9187, 'Africa/Cairo', true),
|
||||||
|
('EGDAM', 'Damietta Port', 'Damietta', 'EG', 'Egypt', 31.4175, 31.8144, 'Africa/Cairo', true),
|
||||||
|
('NGLOS', 'Lagos Port', 'Lagos', 'NG', 'Nigeria', 6.5244, 3.3792, 'Africa/Lagos', true),
|
||||||
|
('NGAPQ', 'Apapa Port', 'Lagos', 'NG', 'Nigeria', 6.4481, 3.3594, 'Africa/Lagos', true),
|
||||||
|
('KEMBQ', 'Mombasa Port', 'Mombasa', 'KE', 'Kenya', -4.0435, 39.6682, 'Africa/Nairobi', true),
|
||||||
|
('TZTZA', 'Dar es Salaam Port', 'Dar es Salaam', 'TZ', 'Tanzania', -6.8160, 39.2803, 'Africa/Dar_es_Salaam', true),
|
||||||
|
('MAAGD', 'Agadir Port', 'Agadir', 'MA', 'Morocco', 30.4278, -9.5981, 'Africa/Casablanca', true),
|
||||||
|
('MACAS', 'Casablanca Port', 'Casablanca', 'MA', 'Morocco', 33.5731, -7.5898, 'Africa/Casablanca', true),
|
||||||
|
('MAPTM', 'Tanger Med Port', 'Tangier', 'MA', 'Morocco', 35.8767, -5.4200, 'Africa/Casablanca', true),
|
||||||
|
('DZDZE', 'Algiers Port', 'Algiers', 'DZ', 'Algeria', 36.7538, 3.0588, 'Africa/Algiers', true),
|
||||||
|
('TNTUN', 'Tunis Port', 'Tunis', 'TN', 'Tunisia', 36.8065, 10.1815, 'Africa/Tunis', true),
|
||||||
|
('TNRAD', 'Rades Port', 'Rades', 'TN', 'Tunisia', 36.7667, 10.2833, 'Africa/Tunis', true),
|
||||||
|
('GHALG', 'Tema Port', 'Tema', 'GH', 'Ghana', 5.6167, -0.0167, 'Africa/Accra', true),
|
||||||
|
('CICTG', 'Abidjan Port', 'Abidjan', 'CI', 'Ivory Coast', 5.3600, -4.0083, 'Africa/Abidjan', true),
|
||||||
|
('SNDKR', 'Dakar Port', 'Dakar', 'SN', 'Senegal', 14.6928, -17.4467, 'Africa/Dakar', true),
|
||||||
|
('AOLAD', 'Luanda Port', 'Luanda', 'AO', 'Angola', -8.8383, 13.2344, 'Africa/Luanda', true),
|
||||||
|
('DJJIB', 'Djibouti Port', 'Djibouti', 'DJ', 'Djibouti', 11.5886, 43.1456, 'Africa/Djibouti', true),
|
||||||
|
('MUMRU', 'Port Louis', 'Port Louis', 'MU', 'Mauritius', -20.1609, 57.5012, 'Indian/Mauritius', true),
|
||||||
|
|
||||||
|
-- OCEANIA (10+ ports)
|
||||||
|
('AUSYD', 'Sydney Port', 'Sydney', 'AU', 'Australia', -33.8688, 151.2093, 'Australia/Sydney', true),
|
||||||
|
('AUMEL', 'Melbourne Port', 'Melbourne', 'AU', 'Australia', -37.8136, 144.9631, 'Australia/Melbourne', true),
|
||||||
|
('AUBNE', 'Brisbane Port', 'Brisbane', 'AU', 'Australia', -27.4698, 153.0251, 'Australia/Brisbane', true),
|
||||||
|
('AUPER', 'Fremantle Port', 'Perth', 'AU', 'Australia', -32.0569, 115.7439, 'Australia/Perth', true),
|
||||||
|
('AUADL', 'Adelaide Port', 'Adelaide', 'AU', 'Australia', -34.9285, 138.6007, 'Australia/Adelaide', true),
|
||||||
|
('AUDRW', 'Darwin Port', 'Darwin', 'AU', 'Australia', -12.4634, 130.8456, 'Australia/Darwin', true),
|
||||||
|
('NZAKL', 'Auckland Port', 'Auckland', 'NZ', 'New Zealand', -36.8485, 174.7633, 'Pacific/Auckland', true),
|
||||||
|
('NZTRG', 'Tauranga Port', 'Tauranga', 'NZ', 'New Zealand', -37.6878, 176.1651, 'Pacific/Auckland', true),
|
||||||
|
('NZWLG', 'Wellington Port', 'Wellington', 'NZ', 'New Zealand', -41.2865, 174.7762, 'Pacific/Auckland', true),
|
||||||
|
('NZCHC', 'Christchurch Port', 'Christchurch', 'NZ', 'New Zealand', -43.5321, 172.6362, 'Pacific/Auckland', true),
|
||||||
|
('PGSOL', 'Lae Port', 'Lae', 'PG', 'Papua New Guinea', -6.7333, 147.0000, 'Pacific/Port_Moresby', true),
|
||||||
|
('FJSUV', 'Suva Port', 'Suva', 'FJ', 'Fiji', -18.1248, 178.4501, 'Pacific/Fiji', true)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DELETE FROM ports`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { searchPorts, Port } from '@/lib/api/ports';
|
||||||
|
|
||||||
interface Package {
|
interface Package {
|
||||||
type: 'caisse' | 'colis' | 'palette' | 'autre';
|
type: 'caisse' | 'colis' | 'palette' | 'autre';
|
||||||
@ -79,6 +81,26 @@ export default function AdvancedSearchPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [originSearch, setOriginSearch] = useState('');
|
||||||
|
const [destinationSearch, setDestinationSearch] = useState('');
|
||||||
|
const [showOriginDropdown, setShowOriginDropdown] = useState(false);
|
||||||
|
const [showDestinationDropdown, setShowDestinationDropdown] = useState(false);
|
||||||
|
|
||||||
|
// Port autocomplete queries
|
||||||
|
const { data: originPortsData } = useQuery({
|
||||||
|
queryKey: ['ports', originSearch],
|
||||||
|
queryFn: () => searchPorts({ query: originSearch, limit: 10 }),
|
||||||
|
enabled: originSearch.length >= 2 && showOriginDropdown,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: destinationPortsData } = useQuery({
|
||||||
|
queryKey: ['ports', destinationSearch],
|
||||||
|
queryFn: () => searchPorts({ query: destinationSearch, limit: 10 }),
|
||||||
|
enabled: destinationSearch.length >= 2 && showDestinationDropdown,
|
||||||
|
});
|
||||||
|
|
||||||
|
const originPorts = originPortsData?.ports || [];
|
||||||
|
const destinationPorts = destinationPortsData?.ports || [];
|
||||||
|
|
||||||
// Calculate total volume and weight
|
// Calculate total volume and weight
|
||||||
const calculateTotals = () => {
|
const calculateTotals = () => {
|
||||||
@ -157,32 +179,92 @@ export default function AdvancedSearchPage() {
|
|||||||
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
{/* Origin Port with Autocomplete */}
|
||||||
|
<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 *
|
Port d'origine * {searchForm.origin && <span className="text-green-600 text-xs">✓ Sélectionné</span>}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchForm.origin}
|
value={originSearch}
|
||||||
onChange={e => setSearchForm({ ...searchForm, origin: e.target.value.toUpperCase() })}
|
onChange={e => {
|
||||||
placeholder="ex: FRPAR"
|
setOriginSearch(e.target.value);
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
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 && (
|
||||||
|
<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) => (
|
||||||
|
<button
|
||||||
|
key={port.code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchForm({ ...searchForm, origin: port.code });
|
||||||
|
setOriginSearch(port.displayName);
|
||||||
|
setShowOriginDropdown(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-gray-900">{port.name}</div>
|
||||||
|
<div className="text-gray-500 text-xs mt-1">
|
||||||
|
{port.code} - {port.city}, {port.countryName}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Destination Port with Autocomplete */}
|
||||||
|
<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 *
|
Port de destination * {searchForm.destination && <span className="text-green-600 text-xs">✓ Sélectionné</span>}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchForm.destination}
|
value={destinationSearch}
|
||||||
onChange={e =>
|
onChange={e => {
|
||||||
setSearchForm({ ...searchForm, destination: e.target.value.toUpperCase() })
|
setDestinationSearch(e.target.value);
|
||||||
|
setShowDestinationDropdown(true);
|
||||||
|
if (e.target.value.length < 2) {
|
||||||
|
setSearchForm({ ...searchForm, destination: '' });
|
||||||
}
|
}
|
||||||
placeholder="ex: CNSHA"
|
}}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
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 && (
|
||||||
|
<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) => (
|
||||||
|
<button
|
||||||
|
key={port.code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchForm({ ...searchForm, destination: port.code });
|
||||||
|
setDestinationSearch(port.displayName);
|
||||||
|
setShowDestinationDropdown(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-gray-900">{port.name}</div>
|
||||||
|
<div className="text-gray-500 text-xs mt-1">
|
||||||
|
{port.code} - {port.city}, {port.countryName}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { searchRates } from '@/lib/api';
|
import { searchRates } from '@/lib/api';
|
||||||
|
import { searchPorts, Port } from '@/lib/api/ports';
|
||||||
|
|
||||||
type ContainerType = '20GP' | '40GP' | '40HC' | '45HC' | '20RF' | '40RF';
|
type ContainerType = '20GP' | '40GP' | '40HC' | '45HC' | '20RF' | '40RF';
|
||||||
type Mode = 'SEA' | 'AIR' | 'ROAD' | 'RAIL';
|
type Mode = 'SEA' | 'AIR' | 'ROAD' | 'RAIL';
|
||||||
@ -43,19 +44,21 @@ export default function RateSearchPage() {
|
|||||||
const [sortBy, setSortBy] = useState<'price' | 'transitTime' | 'co2'>('price');
|
const [sortBy, setSortBy] = useState<'price' | 'transitTime' | 'co2'>('price');
|
||||||
|
|
||||||
// Port autocomplete
|
// Port autocomplete
|
||||||
// TODO: Implement searchPorts API endpoint
|
const { data: originPortsData } = useQuery({
|
||||||
const { data: originPorts } = useQuery({
|
|
||||||
queryKey: ['ports', originSearch],
|
queryKey: ['ports', originSearch],
|
||||||
queryFn: async () => [],
|
queryFn: () => searchPorts({ query: originSearch, limit: 10 }),
|
||||||
enabled: false, // Disabled until API is implemented
|
enabled: originSearch.length >= 2, // Only search after 2 characters
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: destinationPorts } = useQuery({
|
const { data: destinationPortsData } = useQuery({
|
||||||
queryKey: ['ports', destinationSearch],
|
queryKey: ['ports', destinationSearch],
|
||||||
queryFn: async () => [],
|
queryFn: () => searchPorts({ query: destinationSearch, limit: 10 }),
|
||||||
enabled: false, // Disabled until API is implemented
|
enabled: destinationSearch.length >= 2, // Only search after 2 characters
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const originPorts = originPortsData?.ports || [];
|
||||||
|
const destinationPorts = destinationPortsData?.ports || [];
|
||||||
|
|
||||||
// Rate search
|
// Rate search
|
||||||
const {
|
const {
|
||||||
data: rateQuotes,
|
data: rateQuotes,
|
||||||
@ -147,19 +150,19 @@ export default function RateSearchPage() {
|
|||||||
/>
|
/>
|
||||||
{originPorts && originPorts.length > 0 && (
|
{originPorts && originPorts.length > 0 && (
|
||||||
<div className="mt-2 bg-white border border-gray-200 rounded-md shadow-sm max-h-48 overflow-y-auto">
|
<div className="mt-2 bg-white border border-gray-200 rounded-md shadow-sm max-h-48 overflow-y-auto">
|
||||||
{originPorts.map((port: any) => (
|
{originPorts.map((port: Port) => (
|
||||||
<button
|
<button
|
||||||
key={port.code}
|
key={port.code}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchForm({ ...searchForm, originPort: port.code });
|
setSearchForm({ ...searchForm, originPort: port.code });
|
||||||
setOriginSearch(`${port.name} (${port.code})`);
|
setOriginSearch(port.displayName);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-2 hover:bg-gray-50 text-sm"
|
className="w-full text-left px-4 py-2 hover:bg-gray-50 text-sm"
|
||||||
>
|
>
|
||||||
<div className="font-medium">{port.name}</div>
|
<div className="font-medium">{port.name}</div>
|
||||||
<div className="text-gray-500 text-xs">
|
<div className="text-gray-500 text-xs">
|
||||||
{port.code} - {port.country}
|
{port.code} - {port.city}, {port.countryName}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -187,19 +190,19 @@ export default function RateSearchPage() {
|
|||||||
/>
|
/>
|
||||||
{destinationPorts && destinationPorts.length > 0 && (
|
{destinationPorts && destinationPorts.length > 0 && (
|
||||||
<div className="mt-2 bg-white border border-gray-200 rounded-md shadow-sm max-h-48 overflow-y-auto">
|
<div className="mt-2 bg-white border border-gray-200 rounded-md shadow-sm max-h-48 overflow-y-auto">
|
||||||
{destinationPorts.map((port: any) => (
|
{destinationPorts.map((port: Port) => (
|
||||||
<button
|
<button
|
||||||
key={port.code}
|
key={port.code}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchForm({ ...searchForm, destinationPort: port.code });
|
setSearchForm({ ...searchForm, destinationPort: port.code });
|
||||||
setDestinationSearch(`${port.name} (${port.code})`);
|
setDestinationSearch(port.displayName);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-2 hover:bg-gray-50 text-sm"
|
className="w-full text-left px-4 py-2 hover:bg-gray-50 text-sm"
|
||||||
>
|
>
|
||||||
<div className="font-medium">{port.name}</div>
|
<div className="font-medium">{port.name}</div>
|
||||||
<div className="text-gray-500 text-xs">
|
<div className="text-gray-500 text-xs">
|
||||||
{port.code} - {port.country}
|
{port.code} - {port.city}, {port.countryName}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
69
apps/frontend/src/lib/api/ports.ts
Normal file
69
apps/frontend/src/lib/api/ports.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
// API Base URL
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
|
export interface PortCoordinates {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Port {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
countryName: string;
|
||||||
|
coordinates: PortCoordinates;
|
||||||
|
timezone?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortSearchParams {
|
||||||
|
query: string;
|
||||||
|
limit?: number;
|
||||||
|
countryFilter?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortSearchResponse {
|
||||||
|
ports: Port[];
|
||||||
|
totalMatches: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search ports by query (autocomplete)
|
||||||
|
*/
|
||||||
|
export async function searchPorts(params: PortSearchParams): Promise<PortSearchResponse> {
|
||||||
|
// Use the same key as the rest of the app: 'access_token' with underscore
|
||||||
|
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('No access token found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
query: params.query,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (params.limit) {
|
||||||
|
queryParams.append('limit', params.limit.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.countryFilter) {
|
||||||
|
queryParams.append('countryFilter', params.countryFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/ports/search?${queryParams.toString()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message || 'Failed to search ports');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user