Compare commits

...

4 Commits

Author SHA1 Message Date
David
0cbc50e827 Merge branch 'dev' into preprod
All checks were successful
CD Preprod / Backend — Lint (push) Successful in 10m28s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 11m20s
CD Preprod / Backend — Unit Tests (push) Successful in 10m13s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m48s
CD Preprod / Backend — Integration Tests (push) Successful in 10m0s
CD Preprod / Build Backend (push) Successful in 7m46s
CD Preprod / Build Log Exporter (push) Successful in 35s
CD Preprod / Build Frontend (push) Successful in 31m1s
CD Preprod / Deploy to Preprod (push) Successful in 24s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 2s
2026-08-13 17:43:35 +02:00
David
0aa56045f7 Merge branch 'bug_reload_infini' into dev
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m29s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m26s
Dev CI / Backend — Unit Tests (push) Successful in 10m13s
Dev CI / Frontend — Unit Tests (push) Successful in 10m45s
Dev CI / Notify Failure (push) Has been skipped
2026-08-13 14:40:56 +02:00
David
08e614dd1e fix bug 2026-08-13 12:45:52 +02:00
David
af9dae72d7 fix grille tarif 2026-08-12 13:09:48 +02:00
27 changed files with 797 additions and 126 deletions

View File

@ -4,6 +4,7 @@ import {
Get, Get,
Delete, Delete,
Param, Param,
Query,
Body, Body,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@ -21,6 +22,7 @@ import {
ApiBearerAuth, ApiBearerAuth,
ApiConsumes, ApiConsumes,
ApiBody, ApiBody,
ApiQuery,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import { extname } from 'path'; import { extname } from 'path';
@ -172,12 +174,15 @@ export class CsvRatesAdminController {
} }
try { try {
// Generate final filename based on company name const direction = dto.direction ?? 'EXPORT';
// Generate final filename based on company name + direction
// (a company can have one export grid and one import grid)
const sanitizedCompanyName = dto.companyName const sanitizedCompanyName = dto.companyName
.toLowerCase() .toLowerCase()
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, ''); .replace(/[^a-z0-9-]/g, '');
const finalFilename = `${sanitizedCompanyName}.csv`; const finalFilename = `${sanitizedCompanyName}-${direction.toLowerCase()}.csv`;
// Auto-convert CSV if needed (FOB FRET → Standard format) // Auto-convert CSV if needed (FOB FRET → Standard format)
const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName); const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName);
@ -207,7 +212,8 @@ export class CsvRatesAdminController {
const rates = await this.csvLoader.loadRatesFromCsv( const rates = await this.csvLoader.loadRatesFromCsv(
filePathToValidate, filePathToValidate,
dto.companyEmail, dto.companyEmail,
dto.companyName dto.companyName,
direction
); );
const ratesCount = rates.length; const ratesCount = rates.length;
@ -241,6 +247,7 @@ export class CsvRatesAdminController {
metadata: { metadata: {
companyName: dto.companyName, companyName: dto.companyName,
companyEmail: dto.companyEmail, companyEmail: dto.companyEmail,
direction,
uploadedBy: user.email, uploadedBy: user.email,
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
}, },
@ -256,13 +263,17 @@ export class CsvRatesAdminController {
// The file is still available locally // The file is still available locally
} }
// Check if config exists for this company // Check if config exists for this company + direction
const existingConfig = await this.csvConfigRepository.findByCompanyName(dto.companyName); const existingConfig = await this.csvConfigRepository.findByCompanyName(
dto.companyName,
direction
);
if (existingConfig) { if (existingConfig) {
// Update existing configuration // Update existing configuration
await this.csvConfigRepository.update(existingConfig.id, { await this.csvConfigRepository.update(existingConfig.id, {
csvFilePath: finalFilename, csvFilePath: finalFilename,
direction,
uploadedAt: new Date(), uploadedAt: new Date(),
uploadedBy: user.id, uploadedBy: user.id,
rowCount: ratesCount, rowCount: ratesCount,
@ -285,6 +296,7 @@ export class CsvRatesAdminController {
await this.csvConfigRepository.create({ await this.csvConfigRepository.create({
companyName: dto.companyName, companyName: dto.companyName,
csvFilePath: finalFilename, csvFilePath: finalFilename,
direction,
type: 'CSV_ONLY', type: 'CSV_ONLY',
hasApi: false, hasApi: false,
apiConnector: null, apiConnector: null,
@ -410,7 +422,13 @@ export class CsvRatesAdminController {
@ApiOperation({ @ApiOperation({
summary: 'Delete CSV rate configuration (ADMIN only)', summary: 'Delete CSV rate configuration (ADMIN only)',
description: description:
'Deletes the CSV rate configuration for a company. Note: This does not delete the actual CSV file.', 'Deletes the CSV rate configuration for a company. Without a direction, both the export and import grids of that company are deleted. Note: This does not delete the actual CSV file.',
})
@ApiQuery({
name: 'direction',
required: false,
enum: ['EXPORT', 'IMPORT'],
description: 'Only delete the grid of this direction',
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.NO_CONTENT, status: HttpStatus.NO_CONTENT,
@ -422,11 +440,14 @@ export class CsvRatesAdminController {
}) })
async deleteConfig( async deleteConfig(
@Param('companyName') companyName: string, @Param('companyName') companyName: string,
@CurrentUser() user: UserPayload @CurrentUser() user: UserPayload,
@Query('direction') direction?: string
): Promise<void> { ): Promise<void> {
this.logger.warn(`[Admin: ${user.email}] Deleting CSV config for company: ${companyName}`); this.logger.warn(`[Admin: ${user.email}] Deleting CSV config for company: ${companyName}`);
await this.csvConfigRepository.delete(companyName); const upper = direction?.trim().toUpperCase();
const scoped = upper === 'EXPORT' || upper === 'IMPORT' ? upper : undefined;
await this.csvConfigRepository.delete(companyName, scoped);
this.logger.log(`Deleted CSV config for company: ${companyName}`); this.logger.log(`Deleted CSV config for company: ${companyName}`);
} }
@ -453,10 +474,12 @@ export class CsvRatesAdminController {
items: { items: {
type: 'object', type: 'object',
properties: { properties: {
filename: { type: 'string', example: 'ssc-consolidation.csv' }, filename: { type: 'string', example: 'ssc-consolidation-export.csv' },
size: { type: 'number', example: 2048 }, size: { type: 'number', example: 2048 },
uploadedAt: { type: 'string', format: 'date-time' }, uploadedAt: { type: 'string', format: 'date-time' },
rowCount: { type: 'number', example: 150 }, rowCount: { type: 'number', example: 150 },
companyName: { type: 'string', example: 'SSC Consolidation' },
direction: { type: 'string', enum: ['EXPORT', 'IMPORT'], example: 'EXPORT' },
}, },
}, },
}, },
@ -468,21 +491,34 @@ export class CsvRatesAdminController {
const configs = await this.csvConfigRepository.findAll(); const configs = await this.csvConfigRepository.findAll();
// Sizes come from MinIO, which is where the grids actually live; the local
// csv-storage copy is only a fallback and is often absent.
const sizeByKey = new Map<string, number>();
try {
const bucket = this.configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates');
const objects = await this.s3Storage.list(bucket, 'csv-rates/');
objects.forEach(o => sizeByKey.set(o.key, o.size));
} catch (error: any) {
this.logger.warn(`Could not list CSV objects from MinIO: ${error.message}`);
}
// Map configs to file info format expected by frontend // Map configs to file info format expected by frontend
const files = configs.map(config => { const files = configs.map(config => {
const minioKey = config.metadata?.minioObjectKey as string | undefined;
let fileSize = (minioKey && sizeByKey.get(minioKey)) || 0;
if (!fileSize) {
const filePath = path.join( const filePath = path.join(
process.cwd(), process.cwd(),
'apps/backend/src/infrastructure/storage/csv-storage/rates', 'apps/backend/src/infrastructure/storage/csv-storage/rates',
config.csvFilePath config.csvFilePath
); );
let fileSize = 0;
try { try {
const stats = fs.statSync(filePath); fileSize = fs.statSync(filePath).size;
fileSize = stats.size;
} catch (error) { } catch (error) {
this.logger.warn(`Could not get file size for ${config.csvFilePath}`); this.logger.warn(`Could not get file size for ${config.csvFilePath}`);
} }
}
return { return {
filename: config.csvFilePath, filename: config.csvFilePath,
@ -490,6 +526,9 @@ export class CsvRatesAdminController {
uploadedAt: config.uploadedAt.toISOString(), uploadedAt: config.uploadedAt.toISOString(),
rowCount: config.rowCount, rowCount: config.rowCount,
companyEmail: config.metadata?.companyEmail ?? null, companyEmail: config.metadata?.companyEmail ?? null,
companyName: config.companyName,
direction: config.direction ?? 'EXPORT',
isActive: config.isActive,
}; };
}); });
@ -564,10 +603,13 @@ export class CsvRatesAdminController {
} }
} }
// Delete the configuration // Delete the configuration — scoped to this grid's direction, otherwise the
await this.csvConfigRepository.delete(config.companyName); // company's other grid (export vs import) would be deleted too.
await this.csvConfigRepository.delete(config.companyName, config.direction);
this.logger.log(`Deleted CSV config and file for: ${config.companyName}`); this.logger.log(
`Deleted CSV config and file for: ${config.companyName} (${config.direction})`
);
return { return {
success: true, success: true,

View File

@ -325,7 +325,16 @@ export class AuthController {
throw new UnauthorizedException('No refresh token provided'); throw new UnauthorizedException('No refresh token provided');
} }
const result = await this.authService.refreshAccessToken(refreshToken); let result: Awaited<ReturnType<typeof this.authService.refreshAccessToken>>;
try {
result = await this.authService.refreshAccessToken(refreshToken);
} catch (error) {
// The refresh token is expired/revoked: wipe every auth cookie (including
// the readable session flag) so the client stops believing a session
// exists. Without this the frontend keeps retrying and reloading.
this.clearAuthCookies(res);
throw error;
}
this.setAuthCookies(res, result, result.rememberMe); this.setAuthCookies(res, result, result.rememberMe);

View File

@ -33,6 +33,7 @@ import {
FilterOptionsDto, FilterOptionsDto,
AvailableOriginsDto, AvailableOriginsDto,
AvailableDestinationsDto, AvailableDestinationsDto,
AvailableDirectionsDto,
RoutePortInfoDto, RoutePortInfoDto,
} from '../dto/csv-rate-upload.dto'; } from '../dto/csv-rate-upload.dto';
import { CsvRateMapper } from '../mappers/csv-rate.mapper'; import { CsvRateMapper } from '../mappers/csv-rate.mapper';
@ -173,6 +174,7 @@ export class RatesController {
weightKG: dto.weightKG, weightKG: dto.weightKG,
containerType: dto.containerType, containerType: dto.containerType,
hasDangerousGoods: dto.hasDangerousGoods ?? false, hasDangerousGoods: dto.hasDangerousGoods ?? false,
direction: dto.direction,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
}; };
@ -237,6 +239,7 @@ export class RatesController {
weightKG: dto.weightKG, weightKG: dto.weightKG,
containerType: dto.containerType, containerType: dto.containerType,
hasDangerousGoods: dto.hasDangerousGoods ?? false, hasDangerousGoods: dto.hasDangerousGoods ?? false,
direction: dto.direction,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
}; };
@ -260,6 +263,42 @@ export class RatesController {
} }
} }
/**
* Get the trade directions that currently have usable rate grids.
* Lets the booking wizard skip the import/export step when only one applies.
*/
@Get('available-routes/directions')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get available trade directions',
description:
'Returns the trade directions (EXPORT / IMPORT) for which at least one usable rate grid exists. An empty or single-entry list means the direction choice can be skipped in the UI.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'List of available trade directions',
type: AvailableDirectionsDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
async getAvailableDirections(): Promise<AvailableDirectionsDto> {
this.logger.log('Fetching available trade directions from CSV rates');
try {
const directions = await this.csvRateSearchService.getAvailableDirections();
return { directions };
} catch (error: any) {
this.logger.error(
`Failed to fetch available directions: ${error?.message || 'Unknown error'}`,
error?.stack
);
throw error;
}
}
/** /**
* Get available origin ports from CSV rates * Get available origin ports from CSV rates
* Returns only ports that have routes defined in CSV files * Returns only ports that have routes defined in CSV files
@ -272,6 +311,12 @@ export class RatesController {
description: description:
'Returns list of origin ports that have shipping routes defined in CSV rate files. Use this to populate origin port selection dropdown.', 'Returns list of origin ports that have shipping routes defined in CSV rate files. Use this to populate origin port selection dropdown.',
}) })
@ApiQuery({
name: 'direction',
required: false,
enum: ['EXPORT', 'IMPORT'],
description: 'Restrict to grids of this trade direction (EXPORT = French origins)',
})
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
description: 'List of available origin ports with details', description: 'List of available origin ports with details',
@ -281,12 +326,18 @@ export class RatesController {
status: 401, status: 401,
description: 'Unauthorized - missing or invalid token', description: 'Unauthorized - missing or invalid token',
}) })
async getAvailableOrigins(): Promise<AvailableOriginsDto> { async getAvailableOrigins(
this.logger.log('Fetching available origin ports from CSV rates'); @Query('direction') direction?: string
): Promise<AvailableOriginsDto> {
this.logger.log(
`Fetching available origin ports from CSV rates${direction ? ` (${direction})` : ''}`
);
try { try {
// Get unique origin port codes from CSV rates // Get unique origin port codes from CSV rates
const originCodes = await this.csvRateSearchService.getAvailableOrigins(); const originCodes = await this.csvRateSearchService.getAvailableOrigins(
this.parseDirection(direction)
);
// Fetch port details from database // Fetch port details from database
const ports = await this.portRepository.findByCodes(originCodes); const ports = await this.portRepository.findByCodes(originCodes);
@ -360,11 +411,18 @@ export class RatesController {
status: 401, status: 401,
description: 'Unauthorized - missing or invalid token', description: 'Unauthorized - missing or invalid token',
}) })
@ApiQuery({
name: 'direction',
required: false,
enum: ['EXPORT', 'IMPORT'],
description: 'Restrict to grids of this trade direction (EXPORT = French origins)',
})
@ApiBadRequestResponse({ @ApiBadRequestResponse({
description: 'Origin port code is required', description: 'Origin port code is required',
}) })
async getAvailableDestinations( async getAvailableDestinations(
@Query('origin') origin: string @Query('origin') origin: string,
@Query('direction') direction?: string
): Promise<AvailableDestinationsDto> { ): Promise<AvailableDestinationsDto> {
this.logger.log(`Fetching available destinations for origin: ${origin}`); this.logger.log(`Fetching available destinations for origin: ${origin}`);
@ -374,7 +432,10 @@ export class RatesController {
try { try {
// Get destination port codes for this origin from CSV rates // Get destination port codes for this origin from CSV rates
const destinationCodes = await this.csvRateSearchService.getAvailableDestinations(origin); const destinationCodes = await this.csvRateSearchService.getAvailableDestinations(
origin,
this.parseDirection(direction)
);
// Fetch port details from database // Fetch port details from database
const ports = await this.portRepository.findByCodes(destinationCodes); const ports = await this.portRepository.findByCodes(destinationCodes);
@ -494,4 +555,10 @@ export class RatesController {
throw error; throw error;
} }
} }
/** Normalize the direction query param; anything unrecognized means "no filter". */
private parseDirection(direction?: string): 'EXPORT' | 'IMPORT' | undefined {
const upper = direction?.trim().toUpperCase();
return upper === 'EXPORT' || upper === 'IMPORT' ? upper : undefined;
}
} }

View File

@ -7,6 +7,7 @@ import {
IsOptional, IsOptional,
ValidateNested, ValidateNested,
IsBoolean, IsBoolean,
IsIn,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { RateSearchFiltersDto } from './rate-search-filters.dto'; import { RateSearchFiltersDto } from './rate-search-filters.dto';
@ -44,6 +45,15 @@ export class CsvRateSearchDto {
@IsBoolean() @IsBoolean()
hasDangerousGoods?: boolean; hasDangerousGoods?: boolean;
@ApiPropertyOptional({
description: 'Trade direction: EXPORT (French origin) or IMPORT (French destination)',
enum: ['EXPORT', 'IMPORT'],
example: 'EXPORT',
})
@IsOptional()
@IsIn(['EXPORT', 'IMPORT'])
direction?: 'EXPORT' | 'IMPORT';
@ApiPropertyOptional({ description: 'Advanced filters', type: RateSearchFiltersDto }) @ApiPropertyOptional({ description: 'Advanced filters', type: RateSearchFiltersDto })
@IsOptional() @IsOptional()
@ValidateNested() @ValidateNested()

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength, IsEmail } from 'class-validator'; import { IsNotEmpty, IsString, MaxLength, IsEmail, IsOptional, IsIn } from 'class-validator';
/** /**
* CSV Rate Upload DTO * CSV Rate Upload DTO
@ -27,6 +27,16 @@ export class CsvRateUploadDto {
@MaxLength(255) @MaxLength(255)
companyEmail: string; companyEmail: string;
@ApiPropertyOptional({
description:
'Trade direction of the grid: EXPORT (French origins) or IMPORT (French destinations). Defaults to EXPORT.',
enum: ['EXPORT', 'IMPORT'],
example: 'EXPORT',
})
@IsOptional()
@IsIn(['EXPORT', 'IMPORT'])
direction?: 'EXPORT' | 'IMPORT';
@ApiProperty({ @ApiProperty({
description: 'CSV file containing shipping rates', description: 'CSV file containing shipping rates',
type: 'string', type: 'string',
@ -90,10 +100,17 @@ export class CsvRateConfigDto {
@ApiProperty({ @ApiProperty({
description: 'CSV file path', description: 'CSV file path',
example: 'ssc-consolidation.csv', example: 'ssc-consolidation-export.csv',
}) })
csvFilePath: string; csvFilePath: string;
@ApiProperty({
description: 'Trade direction of the grid',
enum: ['EXPORT', 'IMPORT'],
example: 'EXPORT',
})
direction: 'EXPORT' | 'IMPORT';
@ApiProperty({ @ApiProperty({
description: 'Integration type', description: 'Integration type',
enum: ['CSV_ONLY', 'CSV_AND_API'], enum: ['CSV_ONLY', 'CSV_AND_API'],
@ -184,6 +201,19 @@ export class AvailableCompaniesDto {
total: number; total: number;
} }
/**
* Available Directions Response DTO
*/
export class AvailableDirectionsDto {
@ApiProperty({
description: 'Trade directions that currently have usable rate grids',
type: [String],
enum: ['EXPORT', 'IMPORT'],
example: ['EXPORT'],
})
directions: ('EXPORT' | 'IMPORT')[];
}
/** /**
* Filter Options Response DTO * Filter Options Response DTO
*/ */

View File

@ -105,6 +105,7 @@ export class CsvRateMapper {
id: entity.id, id: entity.id,
companyName: entity.companyName, companyName: entity.companyName,
csvFilePath: entity.csvFilePath, csvFilePath: entity.csvFilePath,
direction: entity.direction ?? 'EXPORT',
type: entity.type, type: entity.type,
hasApi: entity.hasApi, hasApi: entity.hasApi,
apiConnector: entity.apiConnector, apiConnector: entity.apiConnector,

View File

@ -5,6 +5,8 @@ import { DateRange } from '../value-objects/date-range.vo';
export type DgSurchargeValue = number | 'ON REQUEST' | 'NOT ACCEPTED'; export type DgSurchargeValue = number | 'ON REQUEST' | 'NOT ACCEPTED';
export type HandlingUnit = 'W' | 'UP'; // W = tonne revenue (max CBM/T), UP = per CBM export type HandlingUnit = 'W' | 'UP'; // W = tonne revenue (max CBM/T), UP = per CBM
export type FrequencyType = 'Weekly' | 'Bi-Weekly' | 'Bi-Monthly' | 'Monthly'; export type FrequencyType = 'Weekly' | 'Bi-Weekly' | 'Bi-Monthly' | 'Monthly';
// EXPORT = French origin, foreign destination; IMPORT = the reverse
export type RateDirection = 'EXPORT' | 'IMPORT';
export interface FreightPricing { export interface FreightPricing {
freightCurrency: string; freightCurrency: string;
@ -65,7 +67,9 @@ export class CsvRate {
public readonly remarks: string, public readonly remarks: string,
public readonly frequency: FrequencyType, public readonly frequency: FrequencyType,
public readonly transitDays: number, public readonly transitDays: number,
public readonly validity: DateRange public readonly validity: DateRange,
// Trade direction of the grid this rate comes from (config-level property)
public readonly direction: RateDirection = 'EXPORT'
) { ) {
this.validate(); this.validate();
} }

View File

@ -1,4 +1,4 @@
import { CsvRate } from '../../entities/csv-rate.entity'; import { CsvRate, RateDirection } from '../../entities/csv-rate.entity';
import { ServiceLevel } from '../../services/rate-offer-generator.service'; import { ServiceLevel } from '../../services/rate-offer-generator.service';
import { PriceBreakdown } from '../../services/csv-rate-price-calculator.service'; import { PriceBreakdown } from '../../services/csv-rate-price-calculator.service';
@ -43,6 +43,7 @@ export interface CsvRateSearchInput {
weightKG: number; weightKG: number;
containerType?: string; containerType?: string;
hasDangerousGoods?: boolean; hasDangerousGoods?: boolean;
direction?: RateDirection; // EXPORT | IMPORT — restricts to grids of that direction
filters?: RateSearchFilters; filters?: RateSearchFilters;
} }

View File

@ -1,4 +1,4 @@
import { CsvRate } from '../../entities/csv-rate.entity'; import { CsvRate, RateDirection } from '../../entities/csv-rate.entity';
/** /**
* CSV Rate Loader Port (Output Port) * CSV Rate Loader Port (Output Port)
@ -14,13 +14,16 @@ export interface CsvRateLoaderPort {
* @param filePath - Absolute or relative path to CSV file * @param filePath - Absolute or relative path to CSV file
* @param companyEmail - Email address for the company (stored in config metadata) * @param companyEmail - Email address for the company (stored in config metadata)
* @param companyNameOverride - Optional company name to override the one in CSV file * @param companyNameOverride - Optional company name to override the one in CSV file
* @param direction - Trade direction of the grid (defaults to the config's
* direction, then to the filename suffix -export/-import, then EXPORT)
* @returns Array of CSV rates * @returns Array of CSV rates
* @throws Error if file cannot be read or parsed * @throws Error if file cannot be read or parsed
*/ */
loadRatesFromCsv( loadRatesFromCsv(
filePath: string, filePath: string,
companyEmail: string, companyEmail: string,
companyNameOverride?: string companyNameOverride?: string,
direction?: RateDirection
): Promise<CsvRate[]>; ): Promise<CsvRate[]>;
/** /**

View File

@ -1,4 +1,4 @@
import { CsvRate } from '../entities/csv-rate.entity'; import { CsvRate, RateDirection } from '../entities/csv-rate.entity';
import { PortCode } from '../value-objects/port-code.vo'; import { PortCode } from '../value-objects/port-code.vo';
import { ContainerType } from '../value-objects/container-type.vo'; import { ContainerType } from '../value-objects/container-type.vo';
import { import {
@ -15,6 +15,7 @@ import { RateOfferGeneratorService } from './rate-offer-generator.service';
interface CsvRateConfig { interface CsvRateConfig {
companyName: string; companyName: string;
csvFilePath: string; csvFilePath: string;
direction?: RateDirection;
metadata?: { metadata?: {
companyEmail?: string; companyEmail?: string;
[key: string]: any; [key: string]: any;
@ -45,6 +46,10 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
let matchingRates = this.filterByRoute(allRates, origin, destination); let matchingRates = this.filterByRoute(allRates, origin, destination);
if (input.direction) {
matchingRates = matchingRates.filter(rate => rate.direction === input.direction);
}
if (input.containerType) { if (input.containerType) {
const containerType = ContainerType.create(input.containerType); const containerType = ContainerType.create(input.containerType);
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType)); matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
@ -94,6 +99,10 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
let matchingRates = this.filterByRoute(allRates, origin, destination); let matchingRates = this.filterByRoute(allRates, origin, destination);
if (input.direction) {
matchingRates = matchingRates.filter(rate => rate.direction === input.direction);
}
if (input.containerType) { if (input.containerType) {
const containerType = ContainerType.create(input.containerType); const containerType = ContainerType.create(input.containerType);
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType)); matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
@ -164,26 +173,50 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
return [...new Set(allRates.map(r => r.containerType.getValue()))].sort(); return [...new Set(allRates.map(r => r.containerType.getValue()))].sort();
} }
async getAvailableOrigins(): Promise<string[]> { /**
* Route dropdowns must only propose routes the search can actually serve:
* same eligibility rules as executeWithOffers (valid today, freight > 0),
* otherwise expired grids fill the dropdowns but every search returns 0 results.
* When a direction is given, only rates from grids of that direction are kept.
*/
private async loadEligibleRates(direction?: RateDirection): Promise<CsvRate[]> {
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
return [...new Set(allRates.map(r => r.originCode.getValue()))].sort(); const eligible = this.offerGenerator.filterEligibleRates(allRates);
return direction ? eligible.filter(r => r.direction === direction) : eligible;
} }
async getAvailableDestinations(origin: string): Promise<string[]> { /**
const allRates = await this.loadAllRates(); * Directions actually served today. Lets the UI drop the import/export step
* entirely when only one direction has usable grids.
*/
async getAvailableDirections(): Promise<RateDirection[]> {
const eligibleRates = await this.loadEligibleRates();
const directions = new Set(eligibleRates.map(r => r.direction));
return (['EXPORT', 'IMPORT'] as RateDirection[]).filter(d => directions.has(d));
}
async getAvailableOrigins(direction?: RateDirection): Promise<string[]> {
const eligibleRates = await this.loadEligibleRates(direction);
return [...new Set(eligibleRates.map(r => r.originCode.getValue()))].sort();
}
async getAvailableDestinations(origin: string, direction?: RateDirection): Promise<string[]> {
const eligibleRates = await this.loadEligibleRates(direction);
const originCode = PortCode.create(origin); const originCode = PortCode.create(origin);
return [ return [
...new Set( ...new Set(
allRates.filter(r => r.originCode.equals(originCode)).map(r => r.destinationCode.getValue()) eligibleRates
.filter(r => r.originCode.equals(originCode))
.map(r => r.destinationCode.getValue())
), ),
].sort(); ].sort();
} }
async getAvailableRoutes(): Promise<Map<string, string[]>> { async getAvailableRoutes(direction?: RateDirection): Promise<Map<string, string[]>> {
const allRates = await this.loadAllRates(); const eligibleRates = await this.loadEligibleRates(direction);
const routeMap = new Map<string, Set<string>>(); const routeMap = new Map<string, Set<string>>();
allRates.forEach(rate => { eligibleRates.forEach(rate => {
const origin = rate.originCode.getValue(); const origin = rate.originCode.getValue();
const destination = rate.destinationCode.getValue(); const destination = rate.destinationCode.getValue();
if (!routeMap.has(origin)) routeMap.set(origin, new Set()); if (!routeMap.has(origin)) routeMap.set(origin, new Set());
@ -208,7 +241,8 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
return this.csvRateLoader.loadRatesFromCsv( return this.csvRateLoader.loadRatesFromCsv(
config.csvFilePath, config.csvFilePath,
email, email,
config.companyName config.companyName,
config.direction
); );
}) })
); );

View File

@ -12,6 +12,7 @@ import {
DgSurchargeValue, DgSurchargeValue,
HandlingUnit, HandlingUnit,
FrequencyType, FrequencyType,
RateDirection,
} from '@domain/entities/csv-rate.entity'; } from '@domain/entities/csv-rate.entity';
import { PortCode } from '@domain/value-objects/port-code.vo'; import { PortCode } from '@domain/value-objects/port-code.vo';
import { ContainerType } from '@domain/value-objects/container-type.vo'; import { ContainerType } from '@domain/value-objects/container-type.vo';
@ -107,11 +108,13 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
private readonly logger = new Logger(CsvRateLoaderAdapter.name); private readonly logger = new Logger(CsvRateLoaderAdapter.name);
private readonly csvDirectory: string; private readonly csvDirectory: string;
// Legacy fallback mapping (export grids). Files are suffixed by direction:
// <base>-export.csv / <base>-import.csv.
private readonly companyFileMapping: Map<string, string> = new Map([ private readonly companyFileMapping: Map<string, string> = new Map([
['SSC Consolidation', 'ssc-consolidation.csv'], ['SSC Consolidation', 'ssc-consolidation-export.csv'],
['ECU Worldwide', 'ecu-worldwide.csv'], ['ECU Worldwide', 'ecu-worldwide-export.csv'],
['TCC Logistics', 'tcc-logistics.csv'], ['TCC Logistics', 'tcc-logistics-export.csv'],
['NVO Consolidation', 'nvo-consolidation.csv'], ['NVO Consolidation', 'nvo-consolidation-export.csv'],
]); ]);
constructor( constructor(
@ -133,16 +136,22 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
async loadRatesFromCsv( async loadRatesFromCsv(
filePath: string, filePath: string,
companyEmail: string, companyEmail: string,
companyNameOverride?: string companyNameOverride?: string,
direction?: RateDirection
): Promise<CsvRate[]> { ): Promise<CsvRate[]> {
this.logger.log(`Loading rates from CSV: ${filePath}`); this.logger.log(`Loading rates from CSV: ${filePath}`);
try { try {
let fileContent: string; let fileContent: string;
let resolvedDirection: RateDirection | undefined = direction;
if (this.s3Storage && this.configService && this.csvConfigRepository && companyNameOverride) { if (this.s3Storage && this.configService && this.csvConfigRepository && companyNameOverride) {
try { try {
const config = await this.csvConfigRepository.findByCompanyName(companyNameOverride); const config = await this.csvConfigRepository.findByCompanyName(
companyNameOverride,
direction
);
resolvedDirection = resolvedDirection ?? config?.direction ?? undefined;
const minioObjectKey = config?.metadata?.minioObjectKey as string | undefined; const minioObjectKey = config?.metadata?.minioObjectKey as string | undefined;
if (minioObjectKey) { if (minioObjectKey) {
const bucket = this.configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates'); const bucket = this.configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates');
@ -161,6 +170,10 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
fileContent = await fs.readFile(fullPath, 'utf-8'); fileContent = await fs.readFile(fullPath, 'utf-8');
} }
// Last-resort direction: filename suffix (-import.csv), default EXPORT
resolvedDirection =
resolvedDirection ?? (/-import\.csv$/i.test(filePath) ? 'IMPORT' : 'EXPORT');
const records: CsvRow[] = parse(fileContent, { const records: CsvRow[] = parse(fileContent, {
columns: true, columns: true,
skip_empty_lines: true, skip_empty_lines: true,
@ -172,7 +185,7 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
const rates = records.map((record, index) => { const rates = records.map((record, index) => {
try { try {
return this.mapToCsvRate(record, companyEmail, companyNameOverride); return this.mapToCsvRate(record, companyEmail, companyNameOverride, resolvedDirection);
} catch (error) { } catch (error) {
const msg = error instanceof Error ? error.message : String(error); const msg = error instanceof Error ? error.message : String(error);
throw new Error(`Row ${index + 1} in ${filePath}: ${msg}`); throw new Error(`Row ${index + 1} in ${filePath}: ${msg}`);
@ -284,7 +297,12 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
} }
} }
private mapToCsvRate(r: CsvRow, companyEmail: string, companyNameOverride?: string): CsvRate { private mapToCsvRate(
r: CsvRow,
companyEmail: string,
companyNameOverride?: string,
direction: RateDirection = 'EXPORT'
): CsvRate {
const companyName = companyNameOverride || r.companyName.trim(); const companyName = companyNameOverride || r.companyName.trim();
// Admin-configured email always takes priority over the value in the CSV row // Admin-configured email always takes priority over the value in the CSV row
const email = companyEmail?.trim() || r.companyEmail?.trim(); const email = companyEmail?.trim() || r.companyEmail?.trim();
@ -341,7 +359,8 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
r.remarks?.trim() || '', r.remarks?.trim() || '',
frequency, frequency,
parseInt(r.transitDays, 10), parseInt(r.transitDays, 10),
validity validity,
direction
); );
} }
} }

View File

@ -60,6 +60,7 @@ import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/enti
return configs.map(config => ({ return configs.map(config => ({
companyName: config.companyName, companyName: config.companyName,
csvFilePath: config.csvFilePath, csvFilePath: config.csvFilePath,
direction: config.direction,
metadata: config.metadata === null ? undefined : config.metadata, metadata: config.metadata === null ? undefined : config.metadata,
})); }));
}, },

View File

@ -6,26 +6,34 @@ import {
UpdateDateColumn, UpdateDateColumn,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
Unique,
} from 'typeorm'; } from 'typeorm';
import { UserOrmEntity } from './user.orm-entity'; import { UserOrmEntity } from './user.orm-entity';
export type CsvRateDirection = 'EXPORT' | 'IMPORT';
/** /**
* CSV Rate Config ORM Entity * CSV Rate Config ORM Entity
* *
* Stores configuration for CSV-based shipping rates * Stores configuration for CSV-based shipping rates
* Maps company names to their CSV files * Maps company names to their CSV files.
* A company can have one grid per direction (export and import).
*/ */
@Entity('csv_rate_configs') @Entity('csv_rate_configs')
@Unique('uq_csv_rate_configs_company_direction', ['companyName', 'direction'])
export class CsvRateConfigOrmEntity { export class CsvRateConfigOrmEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column({ name: 'company_name', type: 'varchar', length: 255, unique: true }) @Column({ name: 'company_name', type: 'varchar', length: 255 })
companyName: string; companyName: string;
@Column({ name: 'csv_file_path', type: 'varchar', length: 500 }) @Column({ name: 'csv_file_path', type: 'varchar', length: 500 })
csvFilePath: string; csvFilePath: string;
@Column({ name: 'direction', type: 'varchar', length: 10, default: 'EXPORT' })
direction: CsvRateDirection;
@Column({ @Column({
name: 'type', name: 'type',
type: 'varchar', type: 'varchar',

View File

@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds trade direction (EXPORT | IMPORT) to CSV rate configs.
*
* A grid file covers exactly one direction; a company can now provide one
* export grid AND one import grid, so the unique constraint moves from
* (company_name) to (company_name, direction). Existing rows are export
* grids (French origins) and get the EXPORT default.
*/
export class AddDirectionToCsvRateConfigs1755000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "csv_rate_configs"
ADD COLUMN "direction" VARCHAR(10) NOT NULL DEFAULT 'EXPORT'
`);
await queryRunner.query(`
ALTER TABLE "csv_rate_configs"
ADD CONSTRAINT "chk_csv_rate_configs_direction"
CHECK ("direction" IN ('EXPORT', 'IMPORT'))
`);
await queryRunner.query(`
ALTER TABLE "csv_rate_configs"
DROP CONSTRAINT IF EXISTS "uq_csv_rate_configs_company"
`);
await queryRunner.query(`
ALTER TABLE "csv_rate_configs"
ADD CONSTRAINT "uq_csv_rate_configs_company_direction"
UNIQUE ("company_name", "direction")
`);
await queryRunner.query(`
COMMENT ON COLUMN "csv_rate_configs"."direction"
IS 'Trade direction of the grid: EXPORT (French origins) or IMPORT (French destinations)'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "csv_rate_configs"
DROP CONSTRAINT IF EXISTS "uq_csv_rate_configs_company_direction"
`);
await queryRunner.query(`
ALTER TABLE "csv_rate_configs"
ADD CONSTRAINT "uq_csv_rate_configs_company"
UNIQUE ("company_name")
`);
await queryRunner.query(`
ALTER TABLE "csv_rate_configs"
DROP CONSTRAINT IF EXISTS "chk_csv_rate_configs_direction"
`);
await queryRunner.query(`
ALTER TABLE "csv_rate_configs"
DROP COLUMN "direction"
`);
}
}

View File

@ -1,21 +1,29 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { CsvRateConfigOrmEntity } from '../entities/csv-rate-config.orm-entity'; import {
CsvRateConfigOrmEntity,
CsvRateDirection,
} from '../entities/csv-rate-config.orm-entity';
/** /**
* CSV Rate Config Repository Port * CSV Rate Config Repository Port
* *
* Interface for CSV rate configuration operations * Interface for CSV rate configuration operations.
* A company can hold one config per direction (EXPORT / IMPORT); lookups by
* company name accept an optional direction to disambiguate.
*/ */
export interface CsvRateConfigRepositoryPort { export interface CsvRateConfigRepositoryPort {
findAll(): Promise<CsvRateConfigOrmEntity[]>; findAll(): Promise<CsvRateConfigOrmEntity[]>;
findByCompanyName(companyName: string): Promise<CsvRateConfigOrmEntity | null>; findByCompanyName(
companyName: string,
direction?: CsvRateDirection
): Promise<CsvRateConfigOrmEntity | null>;
findActiveConfigs(): Promise<CsvRateConfigOrmEntity[]>; findActiveConfigs(): Promise<CsvRateConfigOrmEntity[]>;
create(config: Partial<CsvRateConfigOrmEntity>): Promise<CsvRateConfigOrmEntity>; create(config: Partial<CsvRateConfigOrmEntity>): Promise<CsvRateConfigOrmEntity>;
update(id: string, config: Partial<CsvRateConfigOrmEntity>): Promise<CsvRateConfigOrmEntity>; update(id: string, config: Partial<CsvRateConfigOrmEntity>): Promise<CsvRateConfigOrmEntity>;
delete(companyName: string): Promise<void>; delete(companyName: string, direction?: CsvRateDirection): Promise<void>;
exists(companyName: string): Promise<boolean>; exists(companyName: string, direction?: CsvRateDirection): Promise<boolean>;
} }
/** /**
@ -43,12 +51,17 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo
} }
/** /**
* Find configuration by company name * Find configuration by company name (optionally scoped to a direction)
*/ */
async findByCompanyName(companyName: string): Promise<CsvRateConfigOrmEntity | null> { async findByCompanyName(
this.logger.log(`Finding CSV rate config for company: ${companyName}`); companyName: string,
direction?: CsvRateDirection
): Promise<CsvRateConfigOrmEntity | null> {
this.logger.log(
`Finding CSV rate config for company: ${companyName}${direction ? ` (${direction})` : ''}`
);
return this.repository.findOne({ return this.repository.findOne({
where: { companyName }, where: direction ? { companyName, direction } : { companyName },
}); });
} }
@ -69,10 +82,15 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo
async create(config: Partial<CsvRateConfigOrmEntity>): Promise<CsvRateConfigOrmEntity> { async create(config: Partial<CsvRateConfigOrmEntity>): Promise<CsvRateConfigOrmEntity> {
this.logger.log(`Creating CSV rate config for company: ${config.companyName}`); this.logger.log(`Creating CSV rate config for company: ${config.companyName}`);
// Check if company already exists // Check if company already exists for this direction
const existing = await this.findByCompanyName(config.companyName!); const existing = await this.findByCompanyName(
config.companyName!,
config.direction ?? 'EXPORT'
);
if (existing) { if (existing) {
throw new Error(`CSV rate config already exists for company: ${config.companyName}`); throw new Error(
`CSV rate config already exists for company: ${config.companyName} (${config.direction ?? 'EXPORT'})`
);
} }
const entity = this.repository.create({ const entity = this.repository.create({
@ -107,12 +125,16 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo
} }
/** /**
* Delete CSV rate configuration by company name * Delete CSV rate configuration by company name (optionally one direction only)
*/ */
async delete(companyName: string): Promise<void> { async delete(companyName: string, direction?: CsvRateDirection): Promise<void> {
this.logger.log(`Deleting CSV rate config for company: ${companyName}`); this.logger.log(
`Deleting CSV rate config for company: ${companyName}${direction ? ` (${direction})` : ''}`
);
const result = await this.repository.delete({ companyName }); const result = await this.repository.delete(
direction ? { companyName, direction } : { companyName }
);
if (result.affected === 0) { if (result.affected === 0) {
throw new Error(`CSV rate config not found for company: ${companyName}`); throw new Error(`CSV rate config not found for company: ${companyName}`);
@ -122,11 +144,11 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo
} }
/** /**
* Check if configuration exists for company * Check if configuration exists for company (optionally for a direction)
*/ */
async exists(companyName: string): Promise<boolean> { async exists(companyName: string, direction?: CsvRateDirection): Promise<boolean> {
const count = await this.repository.count({ const count = await this.repository.count({
where: { companyName }, where: direction ? { companyName, direction } : { companyName },
}); });
return count > 0; return count > 0;
} }

View File

@ -39,6 +39,10 @@ export class TypeOrmPortRepository implements PortRepository {
} }
async findByCodes(codes: string[]): Promise<Port[]> { async findByCodes(codes: string[]): Promise<Port[]> {
// An empty list would render as `IN ()`, which TypeORM rejects — happens
// whenever a route lookup yields no codes (e.g. a direction with no grid).
if (codes.length === 0) return [];
const upperCodes = codes.map(c => c.toUpperCase()); const upperCodes = codes.map(c => c.toUpperCase());
const orms = await this.repository const orms = await this.repository
.createQueryBuilder('port') .createQueryBuilder('port')

View File

@ -27,6 +27,11 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
const DIRECTION_COLORS: Record<'EXPORT' | 'IMPORT', string> = {
EXPORT: 'bg-blue-100 text-blue-800',
IMPORT: 'bg-purple-100 text-purple-800',
};
export default function AdminCsvRatesPage() { export default function AdminCsvRatesPage() {
const t = useTranslations('dashboard.admin.csvRates'); const t = useTranslations('dashboard.admin.csvRates');
const locale = useLocale(); const locale = useLocale();
@ -118,6 +123,7 @@ export default function AdminCsvRatesPage() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t('table.filename')}</TableHead> <TableHead>{t('table.filename')}</TableHead>
<TableHead>{t('table.direction')}</TableHead>
<TableHead>{t('table.size')}</TableHead> <TableHead>{t('table.size')}</TableHead>
<TableHead>{t('table.rows')}</TableHead> <TableHead>{t('table.rows')}</TableHead>
<TableHead>{t('table.uploadedAt')}</TableHead> <TableHead>{t('table.uploadedAt')}</TableHead>
@ -131,6 +137,15 @@ export default function AdminCsvRatesPage() {
<TableCell className="font-medium font-mono text-xs"> <TableCell className="font-medium font-mono text-xs">
{file.filename} {file.filename}
</TableCell> </TableCell>
<TableCell>
<span
className={`px-2 py-1 rounded-full text-xs font-medium w-fit ${
DIRECTION_COLORS[file.direction ?? 'EXPORT']
}`}
>
{file.direction === 'IMPORT' ? 'Import' : 'Export'}
</span>
</TableCell>
<TableCell>{(file.size / 1024).toFixed(2)} KB</TableCell> <TableCell>{(file.size / 1024).toFixed(2)} KB</TableCell>
<TableCell> <TableCell>
{file.rowCount ? ( {file.rowCount ? (

View File

@ -3,10 +3,15 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from '@/i18n/navigation'; import { useRouter } from '@/i18n/navigation';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Search, Loader2, Lock, Sparkles } from 'lucide-react'; import { Search, Loader2, Lock, Sparkles, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { getAvailableOrigins, getAvailableDestinations, RoutePortInfo } from '@/lib/api/rates'; import {
getAvailableOrigins,
getAvailableDestinations,
getAvailableDirections,
RoutePortInfo,
} from '@/lib/api/rates';
import { useReservationQuota } from '@/hooks/useReservationQuota'; import { useReservationQuota } from '@/hooks/useReservationQuota';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
@ -25,6 +30,8 @@ const PortRouteMap = dynamic(() => import('@/components/PortRouteMap'), {
loading: PortRouteMapLoader, loading: PortRouteMapLoader,
}); });
export type TradeDirection = 'EXPORT' | 'IMPORT';
interface Package { interface Package {
type: 'caisse' | 'colis' | 'palette' | 'autre'; type: 'caisse' | 'colis' | 'palette' | 'autre';
quantity: number; quantity: number;
@ -104,7 +111,11 @@ export default function AdvancedSearchPage() {
t1Document: false, t1Document: false,
}); });
const [currentStep, setCurrentStep] = useState(1); // Step 0 = import/export choice, 1 = ports, 2 = cargo, 3 = options
const [currentStep, setCurrentStep] = useState(0);
// Trade direction must be chosen before the origin/destination step:
// EXPORT = French origins, IMPORT = French destinations.
const [direction, setDirection] = useState<TradeDirection | null>(null);
const [originSearch, setOriginSearch] = useState(''); const [originSearch, setOriginSearch] = useState('');
const [destinationSearch, setDestinationSearch] = useState(''); const [destinationSearch, setDestinationSearch] = useState('');
const [showOriginDropdown, setShowOriginDropdown] = useState(false); const [showOriginDropdown, setShowOriginDropdown] = useState(false);
@ -114,15 +125,40 @@ export default function AdvancedSearchPage() {
null null
); );
const { data: directionsData, isLoading: isLoadingDirections } = useQuery({
queryKey: ['available-directions'],
queryFn: getAvailableDirections,
});
const availableDirections = directionsData?.directions ?? [];
// Asking "import or export?" is only meaningful when both are actually
// served; with a single direction the step is dropped and auto-selected.
const showDirectionStep = availableDirections.length > 1;
const wizardSteps = showDirectionStep ? [0, 1, 2, 3] : [1, 2, 3];
const firstStep = wizardSteps[0];
const stepNumber = (step: number) => wizardSteps.indexOf(step) + 1;
useEffect(() => {
if (!directionsData) return;
if (availableDirections.length === 1) {
setDirection(prev => prev ?? availableDirections[0]);
}
if (!showDirectionStep) {
setCurrentStep(prev => (prev === 0 ? 1 : prev));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [directionsData]);
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({ const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
queryKey: ['available-origins'], queryKey: ['available-origins', direction],
queryFn: getAvailableOrigins, queryFn: () => getAvailableOrigins(direction ?? undefined),
enabled: !isLoadingDirections && (!!direction || !showDirectionStep),
}); });
const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({ const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({
queryKey: ['available-destinations', searchForm.origin], queryKey: ['available-destinations', searchForm.origin, direction],
queryFn: () => getAvailableDestinations(searchForm.origin), queryFn: () => getAvailableDestinations(searchForm.origin, direction ?? undefined),
enabled: !!searchForm.origin, enabled: !!searchForm.origin && !isLoadingDirections,
}); });
const filteredOrigins = (originsData?.origins || []).filter(port => { const filteredOrigins = (originsData?.origins || []).filter(port => {
@ -166,8 +202,20 @@ export default function AdvancedSearchPage() {
const originParam = searchParams.get('origin'); const originParam = searchParams.get('origin');
const destinationParam = searchParams.get('destination'); const destinationParam = searchParams.get('destination');
const editId = searchParams.get('editBookingId'); const editId = searchParams.get('editBookingId');
const directionParam = searchParams.get('direction');
if (directionParam === 'EXPORT' || directionParam === 'IMPORT') {
setDirection(directionParam);
}
if (!originParam && !destinationParam && !editId) return; if (!originParam && !destinationParam && !editId) return;
// Deep links and booking edits may carry no direction (bookings don't store
// it): infer it from which end of the lane is French.
if (directionParam !== 'EXPORT' && directionParam !== 'IMPORT') {
setDirection(destinationParam?.startsWith('FR') && !originParam?.startsWith('FR')
? 'IMPORT'
: 'EXPORT');
}
if (editId) setEditBookingId(editId); if (editId) setEditBookingId(editId);
const vol = parseFloat(searchParams.get('volumeCBM') || '0'); const vol = parseFloat(searchParams.get('volumeCBM') || '0');
@ -295,6 +343,10 @@ export default function AdvancedSearchPage() {
requiresAppointment: searchForm.appointment.toString(), requiresAppointment: searchForm.appointment.toString(),
}); });
if (direction) {
params.set('direction', direction);
}
if (editBookingId) { if (editBookingId) {
params.set('editBookingId', editBookingId); params.set('editBookingId', editBookingId);
} }
@ -337,9 +389,84 @@ export default function AdvancedSearchPage() {
setSearchForm({ ...searchForm, packages: newPackages }); setSearchForm({ ...searchForm, packages: newPackages });
}; };
const selectDirection = (value: TradeDirection) => {
// Switching direction invalidates the picked ports (an export origin is
// never a valid import origin); re-picking the same one keeps them.
if (value !== direction) {
setSearchForm({ ...searchForm, origin: '', destination: '' });
setSelectedOriginPort(null);
setSelectedDestinationPort(null);
setOriginSearch('');
setDestinationSearch('');
}
setDirection(value);
setCurrentStep(1);
};
const renderStep0 = () => (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-gray-900">
{stepNumber(0)}. {t('step0.title')}
</h2>
<p className="text-sm text-gray-500 mt-1">{t('step0.subtitle')}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{(
[
{ value: 'EXPORT' as const, Icon: ArrowUpFromLine },
{ value: 'IMPORT' as const, Icon: ArrowDownToLine },
] as const
)
.filter(({ value }) => availableDirections.includes(value))
.map(({ value, Icon }) => {
const isSelected = direction === value;
const key = value.toLowerCase() as 'export' | 'import';
return (
<button
key={value}
type="button"
onClick={() => selectDirection(value)}
className={`text-left p-6 rounded-xl border-2 transition-colors ${
isSelected
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
}`}
>
<div
className={`w-12 h-12 rounded-full flex items-center justify-center mb-4 ${
isSelected ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600'
}`}
>
<Icon className="h-6 w-6" />
</div>
<div className="font-semibold text-gray-900">{t(`step0.${key}.title`)}</div>
<div className="text-sm text-gray-600 mt-1">{t(`step0.${key}.route`)}</div>
<div className="text-xs text-gray-500 mt-2">{t(`step0.${key}.description`)}</div>
</button>
);
})}
</div>
</div>
);
const renderStep1 = () => ( const renderStep1 = () => (
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">{t('step1.title')}</h2> <div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">
{stepNumber(1)}. {t('step1.title')}
</h2>
{direction && showDirectionStep && (
<button
type="button"
onClick={() => setCurrentStep(0)}
className="text-xs font-medium px-3 py-1 rounded-full bg-blue-100 text-blue-800 hover:bg-blue-200"
>
{t(`step0.${direction.toLowerCase() as 'export' | 'import'}.title`)} · {t('step0.change')}
</button>
)}
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="relative"> <div className="relative">
@ -541,7 +668,9 @@ export default function AdvancedSearchPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">{t('step2.title')}</h2> <h2 className="text-xl font-semibold text-gray-900">
{stepNumber(2)}. {t('step2.title')}
</h2>
<button <button
type="button" type="button"
onClick={addPackage} onClick={addPackage}
@ -679,7 +808,9 @@ export default function AdvancedSearchPage() {
const renderStep3 = () => ( const renderStep3 = () => (
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">{t('step3.title')}</h2> <h2 className="text-xl font-semibold text-gray-900">
{stepNumber(3)}. {t('step3.title')}
</h2>
<div className="space-y-4"> <div className="space-y-4">
<div className="border-b pb-4"> <div className="border-b pb-4">
@ -854,6 +985,16 @@ export default function AdvancedSearchPage() {
); );
} }
// The wizard shape depends on how many directions are served, so wait for
// that answer rather than flashing a step that may not exist.
if (isLoadingDirections) {
return (
<div className="max-w-7xl mx-auto py-20 flex justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
);
}
return ( return (
<div className="max-w-7xl mx-auto space-y-6"> <div className="max-w-7xl mx-auto space-y-6">
<div> <div>
@ -862,16 +1003,16 @@ export default function AdvancedSearchPage() {
</div> </div>
<div className="flex items-center justify-center space-x-4"> <div className="flex items-center justify-center space-x-4">
{[1, 2, 3].map(step => ( {wizardSteps.map((step, index) => (
<div key={step} className="flex items-center"> <div key={step} className="flex items-center">
<div <div
className={`flex items-center justify-center w-10 h-10 rounded-full ${ className={`flex items-center justify-center w-10 h-10 rounded-full ${
currentStep >= step ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600' currentStep >= step ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'
}`} }`}
> >
{step} {index + 1}
</div> </div>
{step < 3 && ( {index < wizardSteps.length - 1 && (
<div <div
className={`w-20 h-1 mx-2 ${currentStep > step ? 'bg-blue-600' : 'bg-gray-200'}`} className={`w-20 h-1 mx-2 ${currentStep > step ? 'bg-blue-600' : 'bg-gray-200'}`}
/> />
@ -881,6 +1022,7 @@ export default function AdvancedSearchPage() {
</div> </div>
<div className="bg-white rounded-lg shadow p-8"> <div className="bg-white rounded-lg shadow p-8">
{currentStep === 0 && renderStep0()}
{currentStep === 1 && renderStep1()} {currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()} {currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()} {currentStep === 3 && renderStep3()}
@ -888,8 +1030,8 @@ export default function AdvancedSearchPage() {
<div className="mt-8 flex items-center justify-between pt-6 border-t"> <div className="mt-8 flex items-center justify-between pt-6 border-t">
<button <button
type="button" type="button"
onClick={() => setCurrentStep(Math.max(1, currentStep - 1))} onClick={() => setCurrentStep(Math.max(firstStep, currentStep - 1))}
disabled={currentStep === 1} disabled={currentStep === firstStep}
className="px-6 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed" className="px-6 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{t('navigation.previous')} {t('navigation.previous')}
@ -899,7 +1041,11 @@ export default function AdvancedSearchPage() {
<button <button
type="button" type="button"
onClick={() => setCurrentStep(currentStep + 1)} onClick={() => setCurrentStep(currentStep + 1)}
disabled={!searchForm.origin || !searchForm.destination} disabled={
currentStep === 0
? !direction
: !searchForm.origin || !searchForm.destination
}
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed" className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{t('navigation.next')} {t('navigation.next')}
@ -908,7 +1054,7 @@ export default function AdvancedSearchPage() {
<button <button
type="button" type="button"
onClick={handleSearch} onClick={handleSearch}
disabled={!searchForm.origin || !searchForm.destination} disabled={!direction || !searchForm.origin || !searchForm.destination}
className="px-6 py-3 text-base font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center" className="px-6 py-3 text-base font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
> >
<Search className="h-5 w-5 mr-2" /> {t('navigation.search')} <Search className="h-5 w-5 mr-2" /> {t('navigation.search')}

View File

@ -42,6 +42,9 @@ export default function SearchResultsPage() {
// When present, we are re-selecting a rate for an existing unpaid booking: // When present, we are re-selecting a rate for an existing unpaid booking:
// update it in place and go straight to payment (skip the new-booking form). // update it in place and go straight to payment (skip the new-booking form).
const editBookingId = searchParams.get('editBookingId'); const editBookingId = searchParams.get('editBookingId');
const directionParam = searchParams.get('direction');
const direction =
directionParam === 'EXPORT' || directionParam === 'IMPORT' ? directionParam : undefined;
const optionsParam = searchParams.get('options') || ''; const optionsParam = searchParams.get('options') || '';
const optionsObj: Record<string, boolean> = optionsParam const optionsObj: Record<string, boolean> = optionsParam
? optionsParam ? optionsParam
@ -114,6 +117,7 @@ export default function SearchResultsPage() {
volumeCBM, volumeCBM,
weightKG, weightKG,
hasDangerousGoods: searchParams.get('hasDangerousGoods') === 'true', hasDangerousGoods: searchParams.get('hasDangerousGoods') === 'true',
direction,
}); });
setResults(response.results); setResults(response.results);
@ -123,7 +127,7 @@ export default function SearchResultsPage() {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [origin, destination, volumeCBM, weightKG, palletCount, searchParams, t]); }, [origin, destination, volumeCBM, weightKG, palletCount, direction, searchParams, t]);
useEffect(() => { useEffect(() => {
if (!origin || !destination || !volumeCBM || !weightKG) { if (!origin || !destination || !volumeCBM || !weightKG) {
@ -199,7 +203,11 @@ export default function SearchResultsPage() {
<h3 className="text-xl font-bold text-red-900 mb-2">{t('errorTitle')}</h3> <h3 className="text-xl font-bold text-red-900 mb-2">{t('errorTitle')}</h3>
<p className="text-red-700 mb-4">{error}</p> <p className="text-red-700 mb-4">{error}</p>
<button <button
onClick={() => router.push('/dashboard/search-advanced')} onClick={() =>
router.push(
`/dashboard/search-advanced${direction ? `?direction=${direction}` : ''}`
)
}
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors" className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
> >
{t('backToSearch')} {t('backToSearch')}

View File

@ -514,8 +514,23 @@
"title": "Advanced Rate Search", "title": "Advanced Rate Search",
"subtitle": "Complete form with all transport options", "subtitle": "Complete form with all transport options",
"mapLoading": "Loading map...", "mapLoading": "Loading map...",
"step0": {
"title": "Import or Export",
"subtitle": "Select your shipment direction to display the available routes",
"change": "Change",
"export": {
"title": "Export",
"route": "France → International",
"description": "Your cargo ships from a French warehouse to a foreign destination"
},
"import": {
"title": "Import",
"route": "International → France",
"description": "Your cargo arrives from abroad to a French warehouse"
}
},
"step1": { "step1": {
"title": "1. General Information", "title": "General Information",
"originLabel": "Origin port *", "originLabel": "Origin port *",
"destinationLabel": "Destination port *", "destinationLabel": "Destination port *",
"selected": "✓ Selected", "selected": "✓ Selected",
@ -530,7 +545,7 @@
"routeDescription": "Approximate distance and route visualization" "routeDescription": "Approximate distance and route visualization"
}, },
"step2": { "step2": {
"title": "2. Packaging", "title": "Packaging",
"addPackage": "+ Add a package", "addPackage": "+ Add a package",
"packageNumber": "Package #{number}", "packageNumber": "Package #{number}",
"remove": "Remove", "remove": "Remove",
@ -555,7 +570,7 @@
} }
}, },
"step3": { "step3": {
"title": "3. Options & Services", "title": "Options & Services",
"customs": { "customs": {
"title": "Customs Import / Export", "title": "Customs Import / Export",
"eurDocument": "EUR 1", "eurDocument": "EUR 1",
@ -1099,7 +1114,9 @@
"uploadedAt": "Upload date", "uploadedAt": "Upload date",
"email": "Email", "email": "Email",
"actions": "Actions", "actions": "Actions",
"rowCount": "{count} rows" "rowCount": "{count} rows",
"company": "Company",
"direction": "Direction"
}, },
"infoTitle": "Information", "infoTitle": "Information",
"info": { "info": {
@ -3146,6 +3163,10 @@
"companyName": "Company name", "companyName": "Company name",
"companyNamePlaceholder": "Ex: SSC Consolidation", "companyNamePlaceholder": "Ex: SSC Consolidation",
"companyNameHint": "Exact name of the maritime company (must match the CSV data)", "companyNameHint": "Exact name of the maritime company (must match the CSV data)",
"direction": "Grid direction",
"directionExport": "Export (French origins)",
"directionImport": "Import (French destinations)",
"directionHint": "A company can have separate export and import grids",
"companyEmail": "Company email", "companyEmail": "Company email",
"companyEmailPlaceholder": "Ex: bookings@sscconsolidation.com", "companyEmailPlaceholder": "Ex: bookings@sscconsolidation.com",
"companyEmailHint": "Email used for booking requests sent to this company", "companyEmailHint": "Email used for booking requests sent to this company",

View File

@ -514,8 +514,23 @@
"title": "Recherche Avancée de Tarifs", "title": "Recherche Avancée de Tarifs",
"subtitle": "Formulaire complet avec toutes les options de transport", "subtitle": "Formulaire complet avec toutes les options de transport",
"mapLoading": "Chargement de la carte...", "mapLoading": "Chargement de la carte...",
"step0": {
"title": "Import ou Export",
"subtitle": "Sélectionnez le sens de votre expédition pour afficher les routes disponibles",
"change": "Modifier",
"export": {
"title": "Export",
"route": "France → International",
"description": "Votre marchandise part d'un entrepôt français vers l'étranger"
},
"import": {
"title": "Import",
"route": "International → France",
"description": "Votre marchandise arrive de l'étranger vers un entrepôt français"
}
},
"step1": { "step1": {
"title": "1. Informations Générales", "title": "Informations Générales",
"originLabel": "Port d'origine *", "originLabel": "Port d'origine *",
"destinationLabel": "Port de destination *", "destinationLabel": "Port de destination *",
"selected": "✓ Sélectionné", "selected": "✓ Sélectionné",
@ -530,7 +545,7 @@
"routeDescription": "Distance approximative et visualisation de la route" "routeDescription": "Distance approximative et visualisation de la route"
}, },
"step2": { "step2": {
"title": "2. Conditionnement", "title": "Conditionnement",
"addPackage": "+ Ajouter un colis", "addPackage": "+ Ajouter un colis",
"packageNumber": "Colis #{number}", "packageNumber": "Colis #{number}",
"remove": "Supprimer", "remove": "Supprimer",
@ -555,7 +570,7 @@
} }
}, },
"step3": { "step3": {
"title": "3. Options & Services", "title": "Options & Services",
"customs": { "customs": {
"title": "Douane Import / Export", "title": "Douane Import / Export",
"eurDocument": "EUR 1", "eurDocument": "EUR 1",
@ -1099,7 +1114,9 @@
"uploadedAt": "Date d'upload", "uploadedAt": "Date d'upload",
"email": "Email", "email": "Email",
"actions": "Actions", "actions": "Actions",
"rowCount": "{count} lignes" "rowCount": "{count} lignes",
"company": "Compagnie",
"direction": "Sens"
}, },
"infoTitle": "Informations", "infoTitle": "Informations",
"info": { "info": {
@ -3152,6 +3169,10 @@
"companyName": "Nom de la compagnie", "companyName": "Nom de la compagnie",
"companyNamePlaceholder": "Ex: SSC Consolidation", "companyNamePlaceholder": "Ex: SSC Consolidation",
"companyNameHint": "Nom exact de la compagnie maritime (doit correspondre aux données CSV)", "companyNameHint": "Nom exact de la compagnie maritime (doit correspondre aux données CSV)",
"direction": "Sens de la grille",
"directionExport": "Export (origines françaises)",
"directionImport": "Import (destinations françaises)",
"directionHint": "Une compagnie peut avoir une grille export et une grille import distinctes",
"companyEmail": "Email de la compagnie", "companyEmail": "Email de la compagnie",
"companyEmailPlaceholder": "Ex: bookings@sscconsolidation.com", "companyEmailPlaceholder": "Ex: bookings@sscconsolidation.com",
"companyEmailHint": "Email pour les demandes de réservation auprès de cette compagnie", "companyEmailHint": "Email pour les demandes de réservation auprès de cette compagnie",

View File

@ -16,13 +16,16 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Upload, CheckCircle, XCircle, Loader2 } from 'lucide-react'; import { Upload, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { uploadCsvRates } from '@/lib/api/admin/csv-rates'; import { uploadCsvRates, type CsvRateDirection } from '@/lib/api/admin/csv-rates';
export function CsvUpload() { export function CsvUpload() {
const t = useTranslations('components.csvUpload'); const t = useTranslations('components.csvUpload');
const router = useRouter(); const router = useRouter();
const [companyName, setCompanyName] = useState(''); const [companyName, setCompanyName] = useState('');
const [companyEmail, setCompanyEmail] = useState(''); const [companyEmail, setCompanyEmail] = useState('');
// A company can have one export grid and one import grid; the direction
// decides the stored filename and how the grid is filtered when searching.
const [direction, setDirection] = useState<CsvRateDirection>('EXPORT');
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
@ -62,12 +65,14 @@ export function CsvUpload() {
formData.append('file', file); formData.append('file', file);
formData.append('companyName', companyName); formData.append('companyName', companyName);
formData.append('companyEmail', companyEmail); formData.append('companyEmail', companyEmail);
formData.append('direction', direction);
const result = await uploadCsvRates(formData); const result = await uploadCsvRates(formData);
setSuccess(t('successMessage', { count: result.rowCount, companyName: result.companyName })); setSuccess(t('successMessage', { count: result.rowCount, companyName: result.companyName }));
setCompanyName(''); setCompanyName('');
setCompanyEmail(''); setCompanyEmail('');
setDirection('EXPORT');
setFile(null); setFile(null);
const fileInput = document.getElementById('file-input') as HTMLInputElement; const fileInput = document.getElementById('file-input') as HTMLInputElement;
@ -88,6 +93,7 @@ export function CsvUpload() {
const handleReset = () => { const handleReset = () => {
setCompanyName(''); setCompanyName('');
setCompanyEmail(''); setCompanyEmail('');
setDirection('EXPORT');
setFile(null); setFile(null);
setError(null); setError(null);
setSuccess(null); setSuccess(null);
@ -143,6 +149,36 @@ export function CsvUpload() {
<p className="text-xs text-muted-foreground">{t('form.companyEmailHint')}</p> <p className="text-xs text-muted-foreground">{t('form.companyEmailHint')}</p>
</div> </div>
{/* Trade direction */}
<div className="space-y-2">
<Label>
{t('form.direction')} <span className="text-red-500">*</span>
</Label>
<div className="grid grid-cols-2 gap-3">
{(
[
{ value: 'EXPORT' as const, label: t('form.directionExport') },
{ value: 'IMPORT' as const, label: t('form.directionImport') },
] as const
).map(option => (
<button
key={option.value}
type="button"
onClick={() => setDirection(option.value)}
disabled={loading}
className={`px-4 py-2 text-sm rounded-md border transition-colors disabled:opacity-50 ${
direction === option.value
? 'border-blue-600 bg-blue-50 text-blue-900 font-medium'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
{option.label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">{t('form.directionHint')}</p>
</div>
{/* File Input */} {/* File Input */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="file-input"> <Label htmlFor="file-input">

View File

@ -7,6 +7,9 @@
import { get, post, del, upload } from '../client'; import { get, post, del, upload } from '../client';
import type { CsvRateUploadResponse, SuccessResponse } from '@/types/api'; import type { CsvRateUploadResponse, SuccessResponse } from '@/types/api';
/** EXPORT = French origins, IMPORT = French destinations */
export type CsvRateDirection = 'EXPORT' | 'IMPORT';
// TODO: These types should be moved to @/types/api.ts // TODO: These types should be moved to @/types/api.ts
export interface CsvFileInfo { export interface CsvFileInfo {
filename: string; filename: string;
@ -14,6 +17,9 @@ export interface CsvFileInfo {
uploadedAt: string; uploadedAt: string;
rowCount?: number; rowCount?: number;
companyEmail?: string | null; companyEmail?: string | null;
companyName?: string;
direction?: CsvRateDirection;
isActive?: boolean;
} }
export interface CsvFileListResponse { export interface CsvFileListResponse {

View File

@ -27,11 +27,46 @@ export function hasSession(): boolean {
return document.cookie.split('; ').some(cookie => cookie.startsWith(`${SESSION_FLAG_COOKIE}=`)); return document.cookie.split('; ').some(cookie => cookie.startsWith(`${SESSION_FLAG_COOKIE}=`));
} }
/**
* Every domain scope a cookie could have been set on for the current host.
*
* The backend sets its cookies with `COOKIE_DOMAIN` (e.g. `.preprod.xpeditis.com`),
* so deleting them from JS without the matching `domain` attribute silently
* fails — the browser scopes the deletion to the exact host and the original
* domain-scoped cookie survives. Trying every parent domain guarantees removal
* whatever COOKIE_DOMAIN is set to.
*/
function cookieDomainScopes(): Array<string | null> {
const scopes: Array<string | null> = [null]; // host-only cookie
const host = window.location.hostname;
// No domain cookies on IPs or on `localhost`
if (host === 'localhost' || /^[\d.]+$/.test(host) || host.includes(':')) return scopes;
const parts = host.split('.');
for (let i = 0; i < parts.length - 1; i++) {
const domain = parts.slice(i).join('.');
if (domain.includes('.')) {
scopes.push(domain);
scopes.push(`.${domain}`);
}
}
return scopes;
}
function deleteCookie(name: string): void {
const expiry = 'expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
for (const domain of cookieDomainScopes()) {
const domainPart = domain ? `; domain=${domain}` : '';
document.cookie = `${name}=; path=/${domainPart}; ${expiry}`;
}
}
/** /**
* Clear client-side auth state. * Clear client-side auth state.
* Token cookies are httpOnly and are cleared by the backend on logout; * Token cookies are httpOnly and are cleared by the backend on logout;
* this removes the user cache and any tokens left over from the legacy * this removes the user cache, any tokens left over from the legacy
* localStorage-based auth. * localStorage-based auth, and the readable session flag.
*/ */
export function clearAuthTokens(): void { export function clearAuthTokens(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@ -43,8 +78,50 @@ export function clearAuthTokens(): void {
sessionStorage.removeItem('user'); sessionStorage.removeItem('user');
// Expire the legacy middleware cookie and the session flag (best effort — // Expire the legacy middleware cookie and the session flag (best effort —
// the backend clears the authoritative httpOnly cookies) // the backend clears the authoritative httpOnly cookies)
document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'; deleteCookie('accessToken');
document.cookie = `${SESSION_FLAG_COOKIE}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`; deleteCookie(SESSION_FLAG_COOKIE);
}
/**
* Pages that must never be redirected away from on an auth failure: they ARE
* the destination. Redirecting from here caused an infinite reload loop when a
* stale session cookie survived (`/login` → 401 → refresh fails → reload
* `/login` → …). Matched after stripping the locale prefix.
*/
const AUTH_ENTRY_PATHS = [
'/login',
'/admin/login',
'/register',
'/forgot-password',
'/reset-password',
'/verify-email',
'/carrier',
];
// Kept in sync with i18n/routing.ts (imported statically here to avoid pulling
// next-intl into the API client bundle).
const LOCALES = ['fr', 'en'];
function currentLocalePrefix(): string {
const segment = window.location.pathname.split('/')[1];
return LOCALES.includes(segment) ? `/${segment}` : '';
}
function isOnAuthEntryPage(): boolean {
const path = window.location.pathname.slice(currentLocalePrefix().length) || '/';
return AUTH_ENTRY_PATHS.some(p => path === p || path.startsWith(`${p}/`));
}
/**
* Send the user back to login after an unrecoverable auth failure.
* No-op when already on a login/registration page, so a stale session can never
* turn into a reload loop. The locale prefix is preserved to avoid a needless
* round-trip through the i18n middleware.
*/
export function redirectToLogin(): void {
if (typeof window === 'undefined') return;
if (isOnAuthEntryPage()) return;
window.location.href = `${currentLocalePrefix()}/login`;
} }
/** /**
@ -79,10 +156,7 @@ async function refreshSession(): Promise<void> {
if (!response.ok) { if (!response.ok) {
// Refresh token invalid or expired, clear everything // Refresh token invalid or expired, clear everything
clearAuthTokens(); clearAuthTokens();
// Redirect to login redirectToLogin();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('Failed to refresh session'); throw new Error('Failed to refresh session');
} }
} }
@ -146,9 +220,7 @@ export async function apiRequest<T>(
if (!hasSession()) { if (!hasSession()) {
// No session, redirect to login // No session, redirect to login
clearAuthTokens(); clearAuthTokens();
if (typeof window !== 'undefined') { redirectToLogin();
window.location.href = '/login';
}
throw new ApiError('Session expired', 401); throw new ApiError('Session expired', 401);
} }
@ -281,9 +353,7 @@ export async function upload<T>(
return retryResponse.json(); return retryResponse.json();
} catch (refreshError) { } catch (refreshError) {
clearAuthTokens(); clearAuthTokens();
if (typeof window !== 'undefined') { redirectToLogin();
window.location.href = '/login';
}
throw refreshError; throw refreshError;
} }
} }
@ -343,9 +413,7 @@ export async function download(
return; return;
} catch (refreshError) { } catch (refreshError) {
clearAuthTokens(); clearAuthTokens();
if (typeof window !== 'undefined') { redirectToLogin();
window.location.href = '/login';
}
throw refreshError; throw refreshError;
} }
} }

View File

@ -14,6 +14,12 @@ import type {
FilterOptionsResponse, FilterOptionsResponse,
} from '@/types/api'; } from '@/types/api';
/**
* Trade direction of a rate grid.
* EXPORT = French origins, IMPORT = French destinations.
*/
export type TradeDirection = 'EXPORT' | 'IMPORT';
/** /**
* Route Port Info - port details with coordinates * Route Port Info - port details with coordinates
*/ */
@ -45,6 +51,13 @@ export interface AvailableDestinationsResponse {
total: number; total: number;
} }
/**
* Available Directions Response
*/
export interface AvailableDirectionsResponse {
directions: TradeDirection[];
}
/** /**
* Search shipping rates (API-based) * Search shipping rates (API-based)
* POST /api/v1/rates/search * POST /api/v1/rates/search
@ -99,8 +112,21 @@ export async function getFilterOptions(): Promise<FilterOptionsResponse> {
* Returns only ports that have shipping routes defined in CSV rate files. * Returns only ports that have shipping routes defined in CSV rate files.
* Use this to populate origin port selection dropdown. * Use this to populate origin port selection dropdown.
*/ */
export async function getAvailableOrigins(): Promise<AvailableOriginsResponse> { /**
return get<AvailableOriginsResponse>('/api/v1/rates/available-routes/origins'); * Get the trade directions that currently have usable rate grids
* GET /api/v1/rates/available-routes/directions
*
* A single-entry list means the booking wizard can skip the direction step.
*/
export async function getAvailableDirections(): Promise<AvailableDirectionsResponse> {
return get<AvailableDirectionsResponse>('/api/v1/rates/available-routes/directions');
}
export async function getAvailableOrigins(
direction?: TradeDirection
): Promise<AvailableOriginsResponse> {
const query = direction ? `?direction=${direction}` : '';
return get<AvailableOriginsResponse>(`/api/v1/rates/available-routes/origins${query}`);
} }
/** /**
@ -111,9 +137,11 @@ export async function getAvailableOrigins(): Promise<AvailableOriginsResponse> {
* Use this to populate destination port selection after origin is selected. * Use this to populate destination port selection after origin is selected.
*/ */
export async function getAvailableDestinations( export async function getAvailableDestinations(
origin: string origin: string,
direction?: TradeDirection
): Promise<AvailableDestinationsResponse> { ): Promise<AvailableDestinationsResponse> {
const query = direction ? `&direction=${direction}` : '';
return get<AvailableDestinationsResponse>( return get<AvailableDestinationsResponse>(
`/api/v1/rates/available-routes/destinations?origin=${encodeURIComponent(origin)}` `/api/v1/rates/available-routes/destinations?origin=${encodeURIComponent(origin)}${query}`
); );
} }

View File

@ -465,6 +465,8 @@ export interface CsvRateSearchRequest {
weightKG: number; weightKG: number;
containerType?: string; containerType?: string;
hasDangerousGoods?: boolean; hasDangerousGoods?: boolean;
/** EXPORT = French origins, IMPORT = French destinations */
direction?: 'EXPORT' | 'IMPORT';
filters?: RateSearchFilters; filters?: RateSearchFilters;
} }

View File

@ -43,6 +43,8 @@ export interface CsvRateSearchRequest {
weightKG: number; weightKG: number;
containerType?: string; containerType?: string;
hasDangerousGoods?: boolean; hasDangerousGoods?: boolean;
/** EXPORT = French origins, IMPORT = French destinations */
direction?: 'EXPORT' | 'IMPORT';
filters?: RateSearchFilters; filters?: RateSearchFilters;
} }