diff --git a/apps/backend/scripts/generate-ports-seed.ts b/apps/backend/scripts/generate-ports-seed.ts new file mode 100644 index 0000000..d3f770c --- /dev/null +++ b/apps/backend/scripts/generate-ports-seed.ts @@ -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 { + console.log('Seeding ${ports.length} maritime ports...'); + +${sqlInserts} + + console.log('✅ Successfully seeded ${ports.length} ports'); + } + + public async down(queryRunner: QueryRunner): Promise { + 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); diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index ca4fa42..2c827bb 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -8,6 +8,7 @@ import * as Joi from 'joi'; // Import feature modules import { AuthModule } from './application/auth/auth.module'; import { RatesModule } from './application/rates/rates.module'; +import { PortsModule } from './application/ports/ports.module'; import { BookingsModule } from './application/bookings/bookings.module'; import { OrganizationsModule } from './application/organizations/organizations.module'; import { UsersModule } from './application/users/users.module'; @@ -95,6 +96,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; // Feature modules AuthModule, RatesModule, + PortsModule, BookingsModule, CsvBookingsModule, OrganizationsModule, diff --git a/apps/backend/src/application/controllers/ports.controller.ts b/apps/backend/src/application/controllers/ports.controller.ts new file mode 100644 index 0000000..9234f06 --- /dev/null +++ b/apps/backend/src/application/controllers/ports.controller.ts @@ -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 { + 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; + } + } +} diff --git a/apps/backend/src/application/dto/index.ts b/apps/backend/src/application/dto/index.ts index 5340fdf..4b326d3 100644 --- a/apps/backend/src/application/dto/index.ts +++ b/apps/backend/src/application/dto/index.ts @@ -7,3 +7,6 @@ export * from './create-booking-request.dto'; export * from './booking-response.dto'; export * from './booking-filter.dto'; export * from './booking-export.dto'; + +// Port DTOs +export * from './port.dto'; diff --git a/apps/backend/src/application/dto/port.dto.ts b/apps/backend/src/application/dto/port.dto.ts new file mode 100644 index 0000000..84cbb98 --- /dev/null +++ b/apps/backend/src/application/dto/port.dto.ts @@ -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; +} diff --git a/apps/backend/src/application/mappers/index.ts b/apps/backend/src/application/mappers/index.ts index 1d63164..930a103 100644 --- a/apps/backend/src/application/mappers/index.ts +++ b/apps/backend/src/application/mappers/index.ts @@ -1,2 +1,3 @@ export * from './rate-quote.mapper'; export * from './booking.mapper'; +export * from './port.mapper'; diff --git a/apps/backend/src/application/mappers/port.mapper.ts b/apps/backend/src/application/mappers/port.mapper.ts new file mode 100644 index 0000000..653083a --- /dev/null +++ b/apps/backend/src/application/mappers/port.mapper.ts @@ -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, + }; + } +} diff --git a/apps/backend/src/application/ports/ports.module.ts b/apps/backend/src/application/ports/ports.module.ts new file mode 100644 index 0000000..b92a61c --- /dev/null +++ b/apps/backend/src/application/ports/ports.module.ts @@ -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 {} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1733184000000-SeedMajorPorts.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1733184000000-SeedMajorPorts.ts new file mode 100644 index 0000000..826234a --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1733184000000-SeedMajorPorts.ts @@ -0,0 +1,225 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeedMajorPorts1733184000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`DELETE FROM ports`); + } +} diff --git a/apps/frontend/app/dashboard/search-advanced/page.tsx b/apps/frontend/app/dashboard/search-advanced/page.tsx index 165de14..8c4fcb3 100644 --- a/apps/frontend/app/dashboard/search-advanced/page.tsx +++ b/apps/frontend/app/dashboard/search-advanced/page.tsx @@ -8,6 +8,8 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { searchPorts, Port } from '@/lib/api/ports'; interface Package { type: 'caisse' | 'colis' | 'palette' | 'autre'; @@ -79,6 +81,26 @@ export default function AdvancedSearchPage() { }); 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 const calculateTotals = () => { @@ -157,32 +179,92 @@ export default function AdvancedSearchPage() {

1. Informations Générales

-
+ {/* Origin Port with Autocomplete */} +
setSearchForm({ ...searchForm, origin: e.target.value.toUpperCase() })} - placeholder="ex: FRPAR" - className="w-full px-3 py-2 border border-gray-300 rounded-md" + value={originSearch} + onChange={e => { + setOriginSearch(e.target.value); + setShowOriginDropdown(true); + if (e.target.value.length < 2) { + setSearchForm({ ...searchForm, origin: '' }); + } + }} + onFocus={() => setShowOriginDropdown(true)} + placeholder="ex: Rotterdam, Paris, FRPAR" + className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${ + searchForm.origin ? 'border-green-500 bg-green-50' : 'border-gray-300' + }`} /> + {showOriginDropdown && originPorts && originPorts.length > 0 && ( +
+ {originPorts.map((port: Port) => ( + + ))} +
+ )}
-
+ {/* Destination Port with Autocomplete */} +
- setSearchForm({ ...searchForm, destination: e.target.value.toUpperCase() }) - } - placeholder="ex: CNSHA" - className="w-full px-3 py-2 border border-gray-300 rounded-md" + value={destinationSearch} + onChange={e => { + setDestinationSearch(e.target.value); + setShowDestinationDropdown(true); + if (e.target.value.length < 2) { + setSearchForm({ ...searchForm, destination: '' }); + } + }} + onFocus={() => setShowDestinationDropdown(true)} + placeholder="ex: Shanghai, New York, CNSHA" + className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${ + searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300' + }`} /> + {showDestinationDropdown && destinationPorts && destinationPorts.length > 0 && ( +
+ {destinationPorts.map((port: Port) => ( + + ))} +
+ )}
diff --git a/apps/frontend/app/dashboard/search/page.tsx b/apps/frontend/app/dashboard/search/page.tsx index 65482e5..f3de1af 100644 --- a/apps/frontend/app/dashboard/search/page.tsx +++ b/apps/frontend/app/dashboard/search/page.tsx @@ -9,6 +9,7 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { searchRates } from '@/lib/api'; +import { searchPorts, Port } from '@/lib/api/ports'; type ContainerType = '20GP' | '40GP' | '40HC' | '45HC' | '20RF' | '40RF'; type Mode = 'SEA' | 'AIR' | 'ROAD' | 'RAIL'; @@ -43,19 +44,21 @@ export default function RateSearchPage() { const [sortBy, setSortBy] = useState<'price' | 'transitTime' | 'co2'>('price'); // Port autocomplete - // TODO: Implement searchPorts API endpoint - const { data: originPorts } = useQuery({ + const { data: originPortsData } = useQuery({ queryKey: ['ports', originSearch], - queryFn: async () => [], - enabled: false, // Disabled until API is implemented + queryFn: () => searchPorts({ query: originSearch, limit: 10 }), + enabled: originSearch.length >= 2, // Only search after 2 characters }); - const { data: destinationPorts } = useQuery({ + const { data: destinationPortsData } = useQuery({ queryKey: ['ports', destinationSearch], - queryFn: async () => [], - enabled: false, // Disabled until API is implemented + queryFn: () => searchPorts({ query: destinationSearch, limit: 10 }), + enabled: destinationSearch.length >= 2, // Only search after 2 characters }); + const originPorts = originPortsData?.ports || []; + const destinationPorts = destinationPortsData?.ports || []; + // Rate search const { data: rateQuotes, @@ -147,19 +150,19 @@ export default function RateSearchPage() { /> {originPorts && originPorts.length > 0 && (
- {originPorts.map((port: any) => ( + {originPorts.map((port: Port) => ( ))} @@ -187,19 +190,19 @@ export default function RateSearchPage() { /> {destinationPorts && destinationPorts.length > 0 && (
- {destinationPorts.map((port: any) => ( + {destinationPorts.map((port: Port) => ( ))} diff --git a/apps/frontend/src/lib/api/ports.ts b/apps/frontend/src/lib/api/ports.ts new file mode 100644 index 0000000..ba0ab82 --- /dev/null +++ b/apps/frontend/src/lib/api/ports.ts @@ -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 { + // 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(); +}