Compare commits
4 Commits
dcd459ee90
...
0cbc50e827
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cbc50e827 | ||
|
|
0aa56045f7 | ||
|
|
08e614dd1e | ||
|
|
af9dae72d7 |
@ -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,20 +491,33 @@ 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 filePath = path.join(
|
||||
process.cwd(),
|
||||
'apps/backend/src/infrastructure/storage/csv-storage/rates',
|
||||
config.csvFilePath
|
||||
);
|
||||
const minioKey = config.metadata?.minioObjectKey as string | undefined;
|
||||
let fileSize = (minioKey && sizeByKey.get(minioKey)) || 0;
|
||||
|
||||
let fileSize = 0;
|
||||
try {
|
||||
const stats = fs.statSync(filePath);
|
||||
fileSize = stats.size;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Could not get file size for ${config.csvFilePath}`);
|
||||
if (!fileSize) {
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
'apps/backend/src/infrastructure/storage/csv-storage/rates',
|
||||
config.csvFilePath
|
||||
);
|
||||
try {
|
||||
fileSize = fs.statSync(filePath).size;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Could not get file size for ${config.csvFilePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@ -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,
|
||||
|
||||
@ -325,7 +325,16 @@ export class AuthController {
|
||||
throw new UnauthorizedException('No refresh token provided');
|
||||
}
|
||||
|
||||
const result = await this.authService.refreshAccessToken(refreshToken);
|
||||
let result: Awaited<ReturnType<typeof this.authService.refreshAccessToken>>;
|
||||
try {
|
||||
result = await this.authService.refreshAccessToken(refreshToken);
|
||||
} catch (error) {
|
||||
// The refresh token is expired/revoked: wipe every auth cookie (including
|
||||
// the readable session flag) so the client stops believing a session
|
||||
// exists. Without this the frontend keeps retrying and reloading.
|
||||
this.clearAuthCookies(res);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.setAuthCookies(res, result, result.rememberMe);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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[]>;
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
},
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 { 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;
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -27,11 +27,46 @@ export function hasSession(): boolean {
|
||||
return document.cookie.split('; ').some(cookie => cookie.startsWith(`${SESSION_FLAG_COOKIE}=`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every domain scope a cookie could have been set on for the current host.
|
||||
*
|
||||
* The backend sets its cookies with `COOKIE_DOMAIN` (e.g. `.preprod.xpeditis.com`),
|
||||
* so deleting them from JS without the matching `domain` attribute silently
|
||||
* fails — the browser scopes the deletion to the exact host and the original
|
||||
* domain-scoped cookie survives. Trying every parent domain guarantees removal
|
||||
* whatever COOKIE_DOMAIN is set to.
|
||||
*/
|
||||
function cookieDomainScopes(): Array<string | null> {
|
||||
const scopes: Array<string | null> = [null]; // host-only cookie
|
||||
const host = window.location.hostname;
|
||||
|
||||
// No domain cookies on IPs or on `localhost`
|
||||
if (host === 'localhost' || /^[\d.]+$/.test(host) || host.includes(':')) return scopes;
|
||||
|
||||
const parts = host.split('.');
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const domain = parts.slice(i).join('.');
|
||||
if (domain.includes('.')) {
|
||||
scopes.push(domain);
|
||||
scopes.push(`.${domain}`);
|
||||
}
|
||||
}
|
||||
return scopes;
|
||||
}
|
||||
|
||||
function deleteCookie(name: string): void {
|
||||
const expiry = 'expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
|
||||
for (const domain of cookieDomainScopes()) {
|
||||
const domainPart = domain ? `; domain=${domain}` : '';
|
||||
document.cookie = `${name}=; path=/${domainPart}; ${expiry}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear client-side auth state.
|
||||
* Token cookies are httpOnly and are cleared by the backend on logout;
|
||||
* this removes the user cache and any tokens left over from the legacy
|
||||
* localStorage-based auth.
|
||||
* this removes the user cache, any tokens left over from the legacy
|
||||
* localStorage-based auth, and the readable session flag.
|
||||
*/
|
||||
export function clearAuthTokens(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
@ -43,8 +78,50 @@ export function clearAuthTokens(): void {
|
||||
sessionStorage.removeItem('user');
|
||||
// Expire the legacy middleware cookie and the session flag (best effort —
|
||||
// the backend clears the authoritative httpOnly cookies)
|
||||
document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
|
||||
document.cookie = `${SESSION_FLAG_COOKIE}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`;
|
||||
deleteCookie('accessToken');
|
||||
deleteCookie(SESSION_FLAG_COOKIE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages that must never be redirected away from on an auth failure: they ARE
|
||||
* the destination. Redirecting from here caused an infinite reload loop when a
|
||||
* stale session cookie survived (`/login` → 401 → refresh fails → reload
|
||||
* `/login` → …). Matched after stripping the locale prefix.
|
||||
*/
|
||||
const AUTH_ENTRY_PATHS = [
|
||||
'/login',
|
||||
'/admin/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/reset-password',
|
||||
'/verify-email',
|
||||
'/carrier',
|
||||
];
|
||||
|
||||
// Kept in sync with i18n/routing.ts (imported statically here to avoid pulling
|
||||
// next-intl into the API client bundle).
|
||||
const LOCALES = ['fr', 'en'];
|
||||
|
||||
function currentLocalePrefix(): string {
|
||||
const segment = window.location.pathname.split('/')[1];
|
||||
return LOCALES.includes(segment) ? `/${segment}` : '';
|
||||
}
|
||||
|
||||
function isOnAuthEntryPage(): boolean {
|
||||
const path = window.location.pathname.slice(currentLocalePrefix().length) || '/';
|
||||
return AUTH_ENTRY_PATHS.some(p => path === p || path.startsWith(`${p}/`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the user back to login after an unrecoverable auth failure.
|
||||
* No-op when already on a login/registration page, so a stale session can never
|
||||
* turn into a reload loop. The locale prefix is preserved to avoid a needless
|
||||
* round-trip through the i18n middleware.
|
||||
*/
|
||||
export function redirectToLogin(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (isOnAuthEntryPage()) return;
|
||||
window.location.href = `${currentLocalePrefix()}/login`;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -79,10 +156,7 @@ async function refreshSession(): Promise<void> {
|
||||
if (!response.ok) {
|
||||
// Refresh token invalid or expired, clear everything
|
||||
clearAuthTokens();
|
||||
// Redirect to login
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
redirectToLogin();
|
||||
throw new Error('Failed to refresh session');
|
||||
}
|
||||
}
|
||||
@ -146,9 +220,7 @@ export async function apiRequest<T>(
|
||||
if (!hasSession()) {
|
||||
// No session, redirect to login
|
||||
clearAuthTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
redirectToLogin();
|
||||
throw new ApiError('Session expired', 401);
|
||||
}
|
||||
|
||||
@ -281,9 +353,7 @@ export async function upload<T>(
|
||||
return retryResponse.json();
|
||||
} catch (refreshError) {
|
||||
clearAuthTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
redirectToLogin();
|
||||
throw refreshError;
|
||||
}
|
||||
}
|
||||
@ -343,9 +413,7 @@ export async function download(
|
||||
return;
|
||||
} catch (refreshError) {
|
||||
clearAuthTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
redirectToLogin();
|
||||
throw refreshError;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}`
|
||||
);
|
||||
}
|
||||
|
||||
@ -465,6 +465,8 @@ export interface CsvRateSearchRequest {
|
||||
weightKG: number;
|
||||
containerType?: string;
|
||||
hasDangerousGoods?: boolean;
|
||||
/** EXPORT = French origins, IMPORT = French destinations */
|
||||
direction?: 'EXPORT' | 'IMPORT';
|
||||
filters?: RateSearchFilters;
|
||||
}
|
||||
|
||||
|
||||
@ -43,6 +43,8 @@ export interface CsvRateSearchRequest {
|
||||
weightKG: number;
|
||||
containerType?: string;
|
||||
hasDangerousGoods?: boolean;
|
||||
/** EXPORT = French origins, IMPORT = French destinations */
|
||||
direction?: 'EXPORT' | 'IMPORT';
|
||||
filters?: RateSearchFilters;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user