fix grille tarif

This commit is contained in:
David 2026-08-12 13:09:48 +02:00
parent a2a06c008b
commit af9dae72d7
25 changed files with 702 additions and 108 deletions

View File

@ -4,6 +4,7 @@ import {
Get,
Delete,
Param,
Query,
Body,
UseGuards,
UseInterceptors,
@ -21,6 +22,7 @@ import {
ApiBearerAuth,
ApiConsumes,
ApiBody,
ApiQuery,
} from '@nestjs/swagger';
import { diskStorage } from 'multer';
import { extname } from 'path';
@ -172,12 +174,15 @@ export class CsvRatesAdminController {
}
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
.toLowerCase()
.replace(/\s+/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)
const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName);
@ -207,7 +212,8 @@ export class CsvRatesAdminController {
const rates = await this.csvLoader.loadRatesFromCsv(
filePathToValidate,
dto.companyEmail,
dto.companyName
dto.companyName,
direction
);
const ratesCount = rates.length;
@ -241,6 +247,7 @@ export class CsvRatesAdminController {
metadata: {
companyName: dto.companyName,
companyEmail: dto.companyEmail,
direction,
uploadedBy: user.email,
uploadedAt: new Date().toISOString(),
},
@ -256,13 +263,17 @@ export class CsvRatesAdminController {
// The file is still available locally
}
// Check if config exists for this company
const existingConfig = await this.csvConfigRepository.findByCompanyName(dto.companyName);
// Check if config exists for this company + direction
const existingConfig = await this.csvConfigRepository.findByCompanyName(
dto.companyName,
direction
);
if (existingConfig) {
// Update existing configuration
await this.csvConfigRepository.update(existingConfig.id, {
csvFilePath: finalFilename,
direction,
uploadedAt: new Date(),
uploadedBy: user.id,
rowCount: ratesCount,
@ -285,6 +296,7 @@ export class CsvRatesAdminController {
await this.csvConfigRepository.create({
companyName: dto.companyName,
csvFilePath: finalFilename,
direction,
type: 'CSV_ONLY',
hasApi: false,
apiConnector: null,
@ -410,7 +422,13 @@ export class CsvRatesAdminController {
@ApiOperation({
summary: 'Delete CSV rate configuration (ADMIN only)',
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({
status: HttpStatus.NO_CONTENT,
@ -422,11 +440,14 @@ export class CsvRatesAdminController {
})
async deleteConfig(
@Param('companyName') companyName: string,
@CurrentUser() user: UserPayload
@CurrentUser() user: UserPayload,
@Query('direction') direction?: string
): Promise<void> {
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}`);
}
@ -453,10 +474,12 @@ export class CsvRatesAdminController {
items: {
type: 'object',
properties: {
filename: { type: 'string', example: 'ssc-consolidation.csv' },
filename: { type: 'string', example: 'ssc-consolidation-export.csv' },
size: { type: 'number', example: 2048 },
uploadedAt: { type: 'string', format: 'date-time' },
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();
// 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
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(
process.cwd(),
'apps/backend/src/infrastructure/storage/csv-storage/rates',
config.csvFilePath
);
let fileSize = 0;
try {
const stats = fs.statSync(filePath);
fileSize = stats.size;
fileSize = fs.statSync(filePath).size;
} catch (error) {
this.logger.warn(`Could not get file size for ${config.csvFilePath}`);
}
}
return {
filename: config.csvFilePath,
@ -490,6 +526,9 @@ export class CsvRatesAdminController {
uploadedAt: config.uploadedAt.toISOString(),
rowCount: config.rowCount,
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
await this.csvConfigRepository.delete(config.companyName);
// Delete the configuration — scoped to this grid's direction, otherwise the
// 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 {
success: true,

View File

@ -33,6 +33,7 @@ import {
FilterOptionsDto,
AvailableOriginsDto,
AvailableDestinationsDto,
AvailableDirectionsDto,
RoutePortInfoDto,
} from '../dto/csv-rate-upload.dto';
import { CsvRateMapper } from '../mappers/csv-rate.mapper';
@ -173,6 +174,7 @@ export class RatesController {
weightKG: dto.weightKG,
containerType: dto.containerType,
hasDangerousGoods: dto.hasDangerousGoods ?? false,
direction: dto.direction,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
};
@ -237,6 +239,7 @@ export class RatesController {
weightKG: dto.weightKG,
containerType: dto.containerType,
hasDangerousGoods: dto.hasDangerousGoods ?? false,
direction: dto.direction,
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
* Returns only ports that have routes defined in CSV files
@ -272,6 +311,12 @@ export class RatesController {
description:
'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({
status: HttpStatus.OK,
description: 'List of available origin ports with details',
@ -281,12 +326,18 @@ export class RatesController {
status: 401,
description: 'Unauthorized - missing or invalid token',
})
async getAvailableOrigins(): Promise<AvailableOriginsDto> {
this.logger.log('Fetching available origin ports from CSV rates');
async getAvailableOrigins(
@Query('direction') direction?: string
): Promise<AvailableOriginsDto> {
this.logger.log(
`Fetching available origin ports from CSV rates${direction ? ` (${direction})` : ''}`
);
try {
// 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
const ports = await this.portRepository.findByCodes(originCodes);
@ -360,11 +411,18 @@ export class RatesController {
status: 401,
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({
description: 'Origin port code is required',
})
async getAvailableDestinations(
@Query('origin') origin: string
@Query('origin') origin: string,
@Query('direction') direction?: string
): Promise<AvailableDestinationsDto> {
this.logger.log(`Fetching available destinations for origin: ${origin}`);
@ -374,7 +432,10 @@ export class RatesController {
try {
// 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
const ports = await this.portRepository.findByCodes(destinationCodes);
@ -494,4 +555,10 @@ export class RatesController {
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,
ValidateNested,
IsBoolean,
IsIn,
} from 'class-validator';
import { Type } from 'class-transformer';
import { RateSearchFiltersDto } from './rate-search-filters.dto';
@ -44,6 +45,15 @@ export class CsvRateSearchDto {
@IsBoolean()
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 })
@IsOptional()
@ValidateNested()

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength, IsEmail } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength, IsEmail, IsOptional, IsIn } from 'class-validator';
/**
* CSV Rate Upload DTO
@ -27,6 +27,16 @@ export class CsvRateUploadDto {
@MaxLength(255)
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({
description: 'CSV file containing shipping rates',
type: 'string',
@ -90,10 +100,17 @@ export class CsvRateConfigDto {
@ApiProperty({
description: 'CSV file path',
example: 'ssc-consolidation.csv',
example: 'ssc-consolidation-export.csv',
})
csvFilePath: string;
@ApiProperty({
description: 'Trade direction of the grid',
enum: ['EXPORT', 'IMPORT'],
example: 'EXPORT',
})
direction: 'EXPORT' | 'IMPORT';
@ApiProperty({
description: 'Integration type',
enum: ['CSV_ONLY', 'CSV_AND_API'],
@ -184,6 +201,19 @@ export class AvailableCompaniesDto {
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
*/

View File

@ -105,6 +105,7 @@ export class CsvRateMapper {
id: entity.id,
companyName: entity.companyName,
csvFilePath: entity.csvFilePath,
direction: entity.direction ?? 'EXPORT',
type: entity.type,
hasApi: entity.hasApi,
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 HandlingUnit = 'W' | 'UP'; // W = tonne revenue (max CBM/T), UP = per CBM
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 {
freightCurrency: string;
@ -65,7 +67,9 @@ export class CsvRate {
public readonly remarks: string,
public readonly frequency: FrequencyType,
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();
}

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 { PriceBreakdown } from '../../services/csv-rate-price-calculator.service';
@ -43,6 +43,7 @@ export interface CsvRateSearchInput {
weightKG: number;
containerType?: string;
hasDangerousGoods?: boolean;
direction?: RateDirection; // EXPORT | IMPORT — restricts to grids of that direction
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)
@ -14,13 +14,16 @@ export interface CsvRateLoaderPort {
* @param filePath - Absolute or relative path to CSV file
* @param companyEmail - Email address for the company (stored in config metadata)
* @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
* @throws Error if file cannot be read or parsed
*/
loadRatesFromCsv(
filePath: string,
companyEmail: string,
companyNameOverride?: string
companyNameOverride?: string,
direction?: RateDirection
): 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 { ContainerType } from '../value-objects/container-type.vo';
import {
@ -15,6 +15,7 @@ import { RateOfferGeneratorService } from './rate-offer-generator.service';
interface CsvRateConfig {
companyName: string;
csvFilePath: string;
direction?: RateDirection;
metadata?: {
companyEmail?: string;
[key: string]: any;
@ -45,6 +46,10 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
const allRates = await this.loadAllRates();
let matchingRates = this.filterByRoute(allRates, origin, destination);
if (input.direction) {
matchingRates = matchingRates.filter(rate => rate.direction === input.direction);
}
if (input.containerType) {
const containerType = ContainerType.create(input.containerType);
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
@ -94,6 +99,10 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
const allRates = await this.loadAllRates();
let matchingRates = this.filterByRoute(allRates, origin, destination);
if (input.direction) {
matchingRates = matchingRates.filter(rate => rate.direction === input.direction);
}
if (input.containerType) {
const containerType = ContainerType.create(input.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();
}
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();
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);
return [
...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();
}
async getAvailableRoutes(): Promise<Map<string, string[]>> {
const allRates = await this.loadAllRates();
async getAvailableRoutes(direction?: RateDirection): Promise<Map<string, string[]>> {
const eligibleRates = await this.loadEligibleRates(direction);
const routeMap = new Map<string, Set<string>>();
allRates.forEach(rate => {
eligibleRates.forEach(rate => {
const origin = rate.originCode.getValue();
const destination = rate.destinationCode.getValue();
if (!routeMap.has(origin)) routeMap.set(origin, new Set());
@ -208,7 +241,8 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
return this.csvRateLoader.loadRatesFromCsv(
config.csvFilePath,
email,
config.companyName
config.companyName,
config.direction
);
})
);

View File

@ -12,6 +12,7 @@ import {
DgSurchargeValue,
HandlingUnit,
FrequencyType,
RateDirection,
} from '@domain/entities/csv-rate.entity';
import { PortCode } from '@domain/value-objects/port-code.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 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([
['SSC Consolidation', 'ssc-consolidation.csv'],
['ECU Worldwide', 'ecu-worldwide.csv'],
['TCC Logistics', 'tcc-logistics.csv'],
['NVO Consolidation', 'nvo-consolidation.csv'],
['SSC Consolidation', 'ssc-consolidation-export.csv'],
['ECU Worldwide', 'ecu-worldwide-export.csv'],
['TCC Logistics', 'tcc-logistics-export.csv'],
['NVO Consolidation', 'nvo-consolidation-export.csv'],
]);
constructor(
@ -133,16 +136,22 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
async loadRatesFromCsv(
filePath: string,
companyEmail: string,
companyNameOverride?: string
companyNameOverride?: string,
direction?: RateDirection
): Promise<CsvRate[]> {
this.logger.log(`Loading rates from CSV: ${filePath}`);
try {
let fileContent: string;
let resolvedDirection: RateDirection | undefined = direction;
if (this.s3Storage && this.configService && this.csvConfigRepository && companyNameOverride) {
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;
if (minioObjectKey) {
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');
}
// Last-resort direction: filename suffix (-import.csv), default EXPORT
resolvedDirection =
resolvedDirection ?? (/-import\.csv$/i.test(filePath) ? 'IMPORT' : 'EXPORT');
const records: CsvRow[] = parse(fileContent, {
columns: true,
skip_empty_lines: true,
@ -172,7 +185,7 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
const rates = records.map((record, index) => {
try {
return this.mapToCsvRate(record, companyEmail, companyNameOverride);
return this.mapToCsvRate(record, companyEmail, companyNameOverride, resolvedDirection);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
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();
// Admin-configured email always takes priority over the value in the CSV row
const email = companyEmail?.trim() || r.companyEmail?.trim();
@ -341,7 +359,8 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
r.remarks?.trim() || '',
frequency,
parseInt(r.transitDays, 10),
validity
validity,
direction
);
}
}

View File

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

View File

@ -6,26 +6,34 @@ import {
UpdateDateColumn,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { UserOrmEntity } from './user.orm-entity';
export type CsvRateDirection = 'EXPORT' | 'IMPORT';
/**
* CSV Rate Config ORM Entity
*
* 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')
@Unique('uq_csv_rate_configs_company_direction', ['companyName', 'direction'])
export class CsvRateConfigOrmEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'company_name', type: 'varchar', length: 255, unique: true })
@Column({ name: 'company_name', type: 'varchar', length: 255 })
companyName: string;
@Column({ name: 'csv_file_path', type: 'varchar', length: 500 })
csvFilePath: string;
@Column({ name: 'direction', type: 'varchar', length: 10, default: 'EXPORT' })
direction: CsvRateDirection;
@Column({
name: 'type',
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 { InjectRepository } from '@nestjs/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
*
* 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 {
findAll(): Promise<CsvRateConfigOrmEntity[]>;
findByCompanyName(companyName: string): Promise<CsvRateConfigOrmEntity | null>;
findByCompanyName(
companyName: string,
direction?: CsvRateDirection
): Promise<CsvRateConfigOrmEntity | null>;
findActiveConfigs(): Promise<CsvRateConfigOrmEntity[]>;
create(config: Partial<CsvRateConfigOrmEntity>): Promise<CsvRateConfigOrmEntity>;
update(id: string, config: Partial<CsvRateConfigOrmEntity>): Promise<CsvRateConfigOrmEntity>;
delete(companyName: string): Promise<void>;
exists(companyName: string): Promise<boolean>;
delete(companyName: string, direction?: CsvRateDirection): Promise<void>;
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> {
this.logger.log(`Finding CSV rate config for company: ${companyName}`);
async findByCompanyName(
companyName: string,
direction?: CsvRateDirection
): Promise<CsvRateConfigOrmEntity | null> {
this.logger.log(
`Finding CSV rate config for company: ${companyName}${direction ? ` (${direction})` : ''}`
);
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> {
this.logger.log(`Creating CSV rate config for company: ${config.companyName}`);
// Check if company already exists
const existing = await this.findByCompanyName(config.companyName!);
// Check if company already exists for this direction
const existing = await this.findByCompanyName(
config.companyName!,
config.direction ?? 'EXPORT'
);
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({
@ -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> {
this.logger.log(`Deleting CSV rate config for company: ${companyName}`);
async delete(companyName: string, direction?: CsvRateDirection): Promise<void> {
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) {
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({
where: { companyName },
where: direction ? { companyName, direction } : { companyName },
});
return count > 0;
}

View File

@ -39,6 +39,10 @@ export class TypeOrmPortRepository implements PortRepository {
}
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 orms = await this.repository
.createQueryBuilder('port')

View File

@ -27,6 +27,11 @@ import {
TableRow,
} 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() {
const t = useTranslations('dashboard.admin.csvRates');
const locale = useLocale();
@ -118,6 +123,7 @@ export default function AdminCsvRatesPage() {
<TableHeader>
<TableRow>
<TableHead>{t('table.filename')}</TableHead>
<TableHead>{t('table.direction')}</TableHead>
<TableHead>{t('table.size')}</TableHead>
<TableHead>{t('table.rows')}</TableHead>
<TableHead>{t('table.uploadedAt')}</TableHead>
@ -131,6 +137,15 @@ export default function AdminCsvRatesPage() {
<TableCell className="font-medium font-mono text-xs">
{file.filename}
</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.rowCount ? (

View File

@ -3,10 +3,15 @@
import { useState, useEffect } from 'react';
import { useRouter } from '@/i18n/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 { 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 { Link } from '@/i18n/navigation';
import dynamic from 'next/dynamic';
@ -25,6 +30,8 @@ const PortRouteMap = dynamic(() => import('@/components/PortRouteMap'), {
loading: PortRouteMapLoader,
});
export type TradeDirection = 'EXPORT' | 'IMPORT';
interface Package {
type: 'caisse' | 'colis' | 'palette' | 'autre';
quantity: number;
@ -104,7 +111,11 @@ export default function AdvancedSearchPage() {
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 [destinationSearch, setDestinationSearch] = useState('');
const [showOriginDropdown, setShowOriginDropdown] = useState(false);
@ -114,15 +125,40 @@ export default function AdvancedSearchPage() {
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({
queryKey: ['available-origins'],
queryFn: getAvailableOrigins,
queryKey: ['available-origins', direction],
queryFn: () => getAvailableOrigins(direction ?? undefined),
enabled: !isLoadingDirections && (!!direction || !showDirectionStep),
});
const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({
queryKey: ['available-destinations', searchForm.origin],
queryFn: () => getAvailableDestinations(searchForm.origin),
enabled: !!searchForm.origin,
queryKey: ['available-destinations', searchForm.origin, direction],
queryFn: () => getAvailableDestinations(searchForm.origin, direction ?? undefined),
enabled: !!searchForm.origin && !isLoadingDirections,
});
const filteredOrigins = (originsData?.origins || []).filter(port => {
@ -166,8 +202,20 @@ export default function AdvancedSearchPage() {
const originParam = searchParams.get('origin');
const destinationParam = searchParams.get('destination');
const editId = searchParams.get('editBookingId');
const directionParam = searchParams.get('direction');
if (directionParam === 'EXPORT' || directionParam === 'IMPORT') {
setDirection(directionParam);
}
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);
const vol = parseFloat(searchParams.get('volumeCBM') || '0');
@ -295,6 +343,10 @@ export default function AdvancedSearchPage() {
requiresAppointment: searchForm.appointment.toString(),
});
if (direction) {
params.set('direction', direction);
}
if (editBookingId) {
params.set('editBookingId', editBookingId);
}
@ -337,9 +389,84 @@ export default function AdvancedSearchPage() {
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 = () => (
<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="relative">
@ -541,7 +668,9 @@ export default function AdvancedSearchPage() {
return (
<div className="space-y-6">
<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
type="button"
onClick={addPackage}
@ -679,7 +808,9 @@ export default function AdvancedSearchPage() {
const renderStep3 = () => (
<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="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 (
<div className="max-w-7xl mx-auto space-y-6">
<div>
@ -862,16 +1003,16 @@ export default function AdvancedSearchPage() {
</div>
<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
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'
}`}
>
{step}
{index + 1}
</div>
{step < 3 && (
{index < wizardSteps.length - 1 && (
<div
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 className="bg-white rounded-lg shadow p-8">
{currentStep === 0 && renderStep0()}
{currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
@ -888,8 +1030,8 @@ export default function AdvancedSearchPage() {
<div className="mt-8 flex items-center justify-between pt-6 border-t">
<button
type="button"
onClick={() => setCurrentStep(Math.max(1, currentStep - 1))}
disabled={currentStep === 1}
onClick={() => setCurrentStep(Math.max(firstStep, 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"
>
{t('navigation.previous')}
@ -899,7 +1041,11 @@ export default function AdvancedSearchPage() {
<button
type="button"
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"
>
{t('navigation.next')}
@ -908,7 +1054,7 @@ export default function AdvancedSearchPage() {
<button
type="button"
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"
>
<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:
// update it in place and go straight to payment (skip the new-booking form).
const editBookingId = searchParams.get('editBookingId');
const directionParam = searchParams.get('direction');
const direction =
directionParam === 'EXPORT' || directionParam === 'IMPORT' ? directionParam : undefined;
const optionsParam = searchParams.get('options') || '';
const optionsObj: Record<string, boolean> = optionsParam
? optionsParam
@ -114,6 +117,7 @@ export default function SearchResultsPage() {
volumeCBM,
weightKG,
hasDangerousGoods: searchParams.get('hasDangerousGoods') === 'true',
direction,
});
setResults(response.results);
@ -123,7 +127,7 @@ export default function SearchResultsPage() {
} finally {
setIsLoading(false);
}
}, [origin, destination, volumeCBM, weightKG, palletCount, searchParams, t]);
}, [origin, destination, volumeCBM, weightKG, palletCount, direction, searchParams, t]);
useEffect(() => {
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>
<p className="text-red-700 mb-4">{error}</p>
<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"
>
{t('backToSearch')}

View File

@ -514,8 +514,23 @@
"title": "Advanced Rate Search",
"subtitle": "Complete form with all transport options",
"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": {
"title": "1. General Information",
"title": "General Information",
"originLabel": "Origin port *",
"destinationLabel": "Destination port *",
"selected": "✓ Selected",
@ -530,7 +545,7 @@
"routeDescription": "Approximate distance and route visualization"
},
"step2": {
"title": "2. Packaging",
"title": "Packaging",
"addPackage": "+ Add a package",
"packageNumber": "Package #{number}",
"remove": "Remove",
@ -555,7 +570,7 @@
}
},
"step3": {
"title": "3. Options & Services",
"title": "Options & Services",
"customs": {
"title": "Customs Import / Export",
"eurDocument": "EUR 1",
@ -1099,7 +1114,9 @@
"uploadedAt": "Upload date",
"email": "Email",
"actions": "Actions",
"rowCount": "{count} rows"
"rowCount": "{count} rows",
"company": "Company",
"direction": "Direction"
},
"infoTitle": "Information",
"info": {
@ -3146,6 +3163,10 @@
"companyName": "Company name",
"companyNamePlaceholder": "Ex: SSC Consolidation",
"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",
"companyEmailPlaceholder": "Ex: bookings@sscconsolidation.com",
"companyEmailHint": "Email used for booking requests sent to this company",

View File

@ -514,8 +514,23 @@
"title": "Recherche Avancée de Tarifs",
"subtitle": "Formulaire complet avec toutes les options de transport",
"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": {
"title": "1. Informations Générales",
"title": "Informations Générales",
"originLabel": "Port d'origine *",
"destinationLabel": "Port de destination *",
"selected": "✓ Sélectionné",
@ -530,7 +545,7 @@
"routeDescription": "Distance approximative et visualisation de la route"
},
"step2": {
"title": "2. Conditionnement",
"title": "Conditionnement",
"addPackage": "+ Ajouter un colis",
"packageNumber": "Colis #{number}",
"remove": "Supprimer",
@ -555,7 +570,7 @@
}
},
"step3": {
"title": "3. Options & Services",
"title": "Options & Services",
"customs": {
"title": "Douane Import / Export",
"eurDocument": "EUR 1",
@ -1099,7 +1114,9 @@
"uploadedAt": "Date d'upload",
"email": "Email",
"actions": "Actions",
"rowCount": "{count} lignes"
"rowCount": "{count} lignes",
"company": "Compagnie",
"direction": "Sens"
},
"infoTitle": "Informations",
"info": {
@ -3152,6 +3169,10 @@
"companyName": "Nom de la compagnie",
"companyNamePlaceholder": "Ex: SSC Consolidation",
"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",
"companyEmailPlaceholder": "Ex: bookings@sscconsolidation.com",
"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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
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() {
const t = useTranslations('components.csvUpload');
const router = useRouter();
const [companyName, setCompanyName] = 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 [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | null>(null);
@ -62,12 +65,14 @@ export function CsvUpload() {
formData.append('file', file);
formData.append('companyName', companyName);
formData.append('companyEmail', companyEmail);
formData.append('direction', direction);
const result = await uploadCsvRates(formData);
setSuccess(t('successMessage', { count: result.rowCount, companyName: result.companyName }));
setCompanyName('');
setCompanyEmail('');
setDirection('EXPORT');
setFile(null);
const fileInput = document.getElementById('file-input') as HTMLInputElement;
@ -88,6 +93,7 @@ export function CsvUpload() {
const handleReset = () => {
setCompanyName('');
setCompanyEmail('');
setDirection('EXPORT');
setFile(null);
setError(null);
setSuccess(null);
@ -143,6 +149,36 @@ export function CsvUpload() {
<p className="text-xs text-muted-foreground">{t('form.companyEmailHint')}</p>
</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 */}
<div className="space-y-2">
<Label htmlFor="file-input">

View File

@ -7,6 +7,9 @@
import { get, post, del, upload } from '../client';
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
export interface CsvFileInfo {
filename: string;
@ -14,6 +17,9 @@ export interface CsvFileInfo {
uploadedAt: string;
rowCount?: number;
companyEmail?: string | null;
companyName?: string;
direction?: CsvRateDirection;
isActive?: boolean;
}
export interface CsvFileListResponse {

View File

@ -14,6 +14,12 @@ import type {
FilterOptionsResponse,
} 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
*/
@ -45,6 +51,13 @@ export interface AvailableDestinationsResponse {
total: number;
}
/**
* Available Directions Response
*/
export interface AvailableDirectionsResponse {
directions: TradeDirection[];
}
/**
* Search shipping rates (API-based)
* 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.
* 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.
*/
export async function getAvailableDestinations(
origin: string
origin: string,
direction?: TradeDirection
): Promise<AvailableDestinationsResponse> {
const query = direction ? `&direction=${direction}` : '';
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;
containerType?: string;
hasDangerousGoods?: boolean;
/** EXPORT = French origins, IMPORT = French destinations */
direction?: 'EXPORT' | 'IMPORT';
filters?: RateSearchFilters;
}

View File

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