fix grille tarif
This commit is contained in:
parent
a2a06c008b
commit
af9dae72d7
@ -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,20 +491,33 @@ 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 filePath = path.join(
|
const minioKey = config.metadata?.minioObjectKey as string | undefined;
|
||||||
process.cwd(),
|
let fileSize = (minioKey && sizeByKey.get(minioKey)) || 0;
|
||||||
'apps/backend/src/infrastructure/storage/csv-storage/rates',
|
|
||||||
config.csvFilePath
|
|
||||||
);
|
|
||||||
|
|
||||||
let fileSize = 0;
|
if (!fileSize) {
|
||||||
try {
|
const filePath = path.join(
|
||||||
const stats = fs.statSync(filePath);
|
process.cwd(),
|
||||||
fileSize = stats.size;
|
'apps/backend/src/infrastructure/storage/csv-storage/rates',
|
||||||
} catch (error) {
|
config.csvFilePath
|
||||||
this.logger.warn(`Could not get file size for ${config.csvFilePath}`);
|
);
|
||||||
|
try {
|
||||||
|
fileSize = fs.statSync(filePath).size;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Could not get file size for ${config.csvFilePath}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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')}
|
||||||
|
|||||||
@ -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')}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user