Compare commits

...

13 Commits

Author SHA1 Message Date
David
902438b6ce Merge branch 'dev' into preprod
All checks were successful
CD Preprod / Backend — Integration Tests (push) Successful in 10m5s
CD Preprod / Build Backend (push) Successful in 8m4s
CD Preprod / Build Log Exporter (push) Successful in 27s
CD Preprod / Build Frontend (push) Successful in 21m17s
CD Preprod / Deploy to Preprod (push) Successful in 24s
CD Preprod / Notify Success (push) Successful in 2s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Backend — Lint (push) Successful in 10m21s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 10m58s
CD Preprod / Backend — Unit Tests (push) Successful in 10m17s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m37s
2026-05-12 21:26:37 +02:00
David
f5eaa4e083 Merge branch 'update_search_price_booking' into dev
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m25s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m2s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Notify Failure (push) Has been skipped
Dev CI / Frontend — Unit Tests (push) Successful in 10m41s
2026-05-12 01:24:01 +02:00
David
9acabb6859 fix api key 2026-05-12 01:23:47 +02:00
David
71d131f4cb fix search rates 2026-05-12 01:11:04 +02:00
David
8bd2a60749 Merge branch 'dev' into preprod
All checks were successful
CD Preprod / Deploy to Preprod (push) Successful in 23s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 3s
CD Preprod / Backend — Lint (push) Successful in 10m27s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 11m3s
CD Preprod / Backend — Unit Tests (push) Successful in 10m16s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m41s
CD Preprod / Backend — Integration Tests (push) Successful in 10m3s
CD Preprod / Build Backend (push) Successful in 55s
CD Preprod / Build Log Exporter (push) Successful in 29s
CD Preprod / Build Frontend (push) Successful in 21m31s
2026-05-05 16:34:04 +02:00
David
84790e0c68 Merge branch 'about_text_change' into dev
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m29s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m5s
Dev CI / Backend — Unit Tests (push) Successful in 10m16s
Dev CI / Frontend — Unit Tests (push) Successful in 10m41s
Dev CI / Notify Failure (push) Has been skipped
2026-05-05 16:03:48 +02:00
David
96963b05f0 fix a propos text 2026-05-05 16:03:35 +02:00
David
8ae3d600ea Merge branch 'dev' into preprod
All checks were successful
CD Preprod / Backend — Lint (push) Successful in 10m23s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 10m59s
CD Preprod / Backend — Unit Tests (push) Successful in 10m16s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m37s
CD Preprod / Backend — Integration Tests (push) Successful in 9m57s
CD Preprod / Build Backend (push) Successful in 16m33s
CD Preprod / Build Log Exporter (push) Successful in 1m25s
CD Preprod / Build Frontend (push) Successful in 38m43s
CD Preprod / Deploy to Preprod (push) Successful in 26s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 2s
2026-04-21 19:16:29 +02:00
David
ec0173483a fix language
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m23s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m3s
Dev CI / Frontend — Unit Tests (push) Successful in 10m33s
Dev CI / Notify Failure (push) Has been skipped
2026-04-21 18:04:02 +02:00
David
b352d1d9a9 Merge branch 'dev' into preprod
All checks were successful
CD Preprod / Backend — Lint (push) Successful in 10m24s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 10m54s
CD Preprod / Backend — Unit Tests (push) Successful in 10m12s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m33s
CD Preprod / Backend — Integration Tests (push) Successful in 10m0s
CD Preprod / Build Backend (push) Successful in 57s
CD Preprod / Build Log Exporter (push) Successful in 1m7s
CD Preprod / Build Frontend (push) Successful in 19m38s
CD Preprod / Deploy to Preprod (push) Successful in 25s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 2s
2026-04-13 11:53:43 +02:00
David
8649b8a13c Merge branch 'mobile_app' into dev
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m26s
Dev CI / Frontend — Lint & Type-check (push) Successful in 10m57s
Dev CI / Backend — Unit Tests (push) Successful in 10m12s
Dev CI / Frontend — Unit Tests (push) Successful in 10m37s
Dev CI / Notify Failure (push) Has been skipped
2026-04-09 17:55:05 +02:00
David
982c893952 fix mobile version 2026-04-09 17:54:48 +02:00
David
be1de882c3 chore: sync dev with preprod
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m23s
Dev CI / Frontend — Lint & Type-check (push) Successful in 10m55s
Dev CI / Backend — Unit Tests (push) Successful in 10m10s
Dev CI / Frontend — Unit Tests (push) Successful in 10m30s
Dev CI / Notify Failure (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:16:16 +02:00
182 changed files with 22160 additions and 17417 deletions

View File

@ -6,6 +6,8 @@
"deleteOutDir": true,
"builder": "tsc",
"tsConfigPath": "tsconfig.build.json",
"plugins": ["@nestjs/swagger"]
"plugins": ["@nestjs/swagger"],
"assets": [{ "include": "i18n/**/*.json", "outDir": "dist" }],
"watchAssets": true
}
}

View File

@ -43,6 +43,7 @@
"joi": "^17.11.0",
"leaflet": "^1.9.4",
"mjml": "^4.16.1",
"nestjs-i18n": "^10.6.5",
"nestjs-pino": "^4.4.1",
"nodemailer": "^7.0.9",
"opossum": "^8.1.3",
@ -5761,6 +5762,12 @@
"node": ">=6.5"
}
},
"node_modules/accept-language-parser": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz",
"integrity": "sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw==",
"license": "MIT"
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -12169,6 +12176,34 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/nestjs-i18n": {
"version": "10.6.5",
"resolved": "https://registry.npmjs.org/nestjs-i18n/-/nestjs-i18n-10.6.5.tgz",
"integrity": "sha512-jqbZ+H7LMEfAVYqS1FM0YfZjzPDwZQq97NE4BBIfPpxzAhlfnPzaQDGpNkPE/5Ft+rawtNJOuuuaWMpDhSLwaA==",
"license": "MIT",
"dependencies": {
"accept-language-parser": "^1.5.0",
"chokidar": "^3.6.0",
"cookie": "^0.7.0",
"iterare": "^1.2.1",
"js-yaml": "^4.1.0",
"string-format": "^2.0.0"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"@nestjs/common": "*",
"@nestjs/core": "*",
"class-validator": "*",
"rxjs": "*"
},
"peerDependenciesMeta": {
"class-validator": {
"optional": true
}
}
},
"node_modules/nestjs-pino": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.4.1.tgz",
@ -14472,6 +14507,12 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-format": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/string-format/-/string-format-2.0.0.tgz",
"integrity": "sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==",
"license": "WTFPL OR MIT"
},
"node_modules/string-length": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",

View File

@ -59,6 +59,7 @@
"joi": "^17.11.0",
"leaflet": "^1.9.4",
"mjml": "^4.16.1",
"nestjs-i18n": "^10.6.5",
"nestjs-pino": "^4.4.1",
"nodemailer": "^7.0.9",
"opossum": "^8.1.3",

View File

@ -3,7 +3,16 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggerModule } from 'nestjs-pino';
import { APP_GUARD } from '@nestjs/core';
import {
AcceptLanguageResolver,
CookieResolver,
HeaderResolver,
I18nModule,
QueryResolver,
} from 'nestjs-i18n';
import * as path from 'path';
import * as Joi from 'joi';
import { UserPreferenceResolver } from './infrastructure/i18n/user-preference.resolver';
// Import feature modules
import { AuthModule } from './application/auth/auth.module';
@ -110,6 +119,29 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
inject: [ConfigService],
}),
// Internationalization (FR / EN)
// Resolver chain (highest priority first):
// 1. UserPreferenceResolver — authenticated user's preferredLanguage
// 2. CookieResolver (NEXT_LOCALE) — set by frontend switcher
// 3. HeaderResolver (x-lang / x-locale)
// 4. QueryResolver (?lang=xx)
// 5. AcceptLanguageResolver
// 6. fallback → 'fr'
I18nModule.forRoot({
fallbackLanguage: 'fr',
loaderOptions: {
path: path.join(__dirname, '/i18n/'),
watch: true,
},
resolvers: [
UserPreferenceResolver,
new CookieResolver(['NEXT_LOCALE', 'lang']),
new HeaderResolver(['x-lang', 'x-locale']),
new QueryResolver(['lang', 'locale']),
AcceptLanguageResolver,
],
}),
// Database
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => ({

View File

@ -10,13 +10,7 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../decorators/current-user.decorator';
import { RequiresFeature } from '../decorators/requires-feature.decorator';
@ -38,7 +32,7 @@ export class ApiKeysController {
@ApiOperation({
summary: 'Générer une nouvelle clé API',
description:
"Crée une clé API pour accès programmatique. La clé complète est retournée **une seule fois** — conservez-la immédiatement. Réservé aux abonnements Gold et Platinium.",
'Crée une clé API pour accès programmatique. La clé complète est retournée **une seule fois** — conservez-la immédiatement. Réservé aux abonnements Gold et Platinium.',
})
@ApiResponse({
status: 201,

View File

@ -23,10 +23,7 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
@Module({
imports: [
TypeOrmModule.forFeature([ApiKeyOrmEntity, UserOrmEntity]),
SubscriptionsModule,
],
imports: [TypeOrmModule.forFeature([ApiKeyOrmEntity, UserOrmEntity]), SubscriptionsModule],
controllers: [ApiKeysController],
providers: [
ApiKeysService,

View File

@ -8,13 +8,7 @@
* - Validation for inbound API key authentication
*/
import {
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';

View File

@ -41,7 +41,12 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
}),
// 👇 Add this to register TypeORM repositories
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity, PasswordResetTokenOrmEntity]),
TypeOrmModule.forFeature([
UserOrmEntity,
OrganizationOrmEntity,
InvitationTokenOrmEntity,
PasswordResetTokenOrmEntity,
]),
// Email module for sending invitations
EmailModule,

View File

@ -265,7 +265,9 @@ export class AuthService {
}
if (resetToken.expiresAt < new Date()) {
throw new BadRequestException('Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.');
throw new BadRequestException(
'Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.'
);
}
const user = await this.userRepository.findById(resetToken.userId);
@ -286,10 +288,7 @@ export class AuthService {
await this.userRepository.save(user);
// Mark token as used
await this.passwordResetTokenRepository.update(
{ id: resetToken.id },
{ usedAt: new Date() }
);
await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() });
this.logger.log(`Password reset successfully for user: ${user.email}`);
}

View File

@ -744,10 +744,7 @@ export class AdminController {
})
@ApiResponse({ status: 200, description: 'Email sent successfully' })
@ApiResponse({ status: 400, description: 'SMTP error — check the message field' })
async sendTestEmail(
@Body() body: { to: string },
@CurrentUser() user: UserPayload
) {
async sendTestEmail(@Body() body: { to: string }, @CurrentUser() user: UserPayload) {
if (!body?.to) {
throw new BadRequestException('Field "to" is required');
}
@ -880,7 +877,9 @@ export class AdminController {
@Param('documentId', ParseUUIDPipe) documentId: string,
@CurrentUser() user: UserPayload
): Promise<{ success: boolean; message: string }> {
this.logger.log(`[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}`);
this.logger.log(
`[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}`
);
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
@ -894,7 +893,9 @@ export class AdminController {
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId } });
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
if (ormBooking) {
ormBooking.documents = updatedDocuments.map(doc => ({
id: doc.id,

View File

@ -289,7 +289,9 @@ export class AuthController {
});
} catch (error) {
this.logger.error(`Failed to send contact email: ${error}`);
throw new InternalServerErrorException("Erreur lors de l'envoi du message. Veuillez réessayer.");
throw new InternalServerErrorException(
"Erreur lors de l'envoi du message. Veuillez réessayer."
);
}
return { message: 'Message envoyé avec succès.' };

View File

@ -153,10 +153,7 @@ export class InvitationsController {
@ApiResponse({ status: 204, description: 'Invitation cancelled' })
@ApiResponse({ status: 404, description: 'Invitation not found' })
@ApiResponse({ status: 400, description: 'Invitation already used' })
async cancelInvitation(
@Param('id') id: string,
@CurrentUser() user: UserPayload
): Promise<void> {
async cancelInvitation(@Param('id') id: string, @CurrentUser() user: UserPayload): Promise<void> {
this.logger.log(`[User: ${user.email}] Cancelling invitation: ${id}`);
await this.invitationService.cancelInvitation(id, user.organizationId);
}

View File

@ -166,27 +166,16 @@ export class RatesController {
);
try {
// Map DTO to domain input
const searchInput = {
origin: dto.origin,
destination: dto.destination,
volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG,
palletCount: dto.palletCount ?? 0,
containerType: dto.containerType,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
// Service requirements for detailed pricing
hasDangerousGoods: dto.hasDangerousGoods ?? false,
requiresSpecialHandling: dto.requiresSpecialHandling ?? false,
requiresTailgate: dto.requiresTailgate ?? false,
requiresStraps: dto.requiresStraps ?? false,
requiresThermalCover: dto.requiresThermalCover ?? false,
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
requiresAppointment: dto.requiresAppointment ?? false,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
};
// Execute CSV rate search
const result = await this.csvRateSearchService.execute(searchInput);
// Map domain output to response DTO
@ -241,27 +230,16 @@ export class RatesController {
);
try {
// Map DTO to domain input
const searchInput = {
origin: dto.origin,
destination: dto.destination,
volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG,
palletCount: dto.palletCount ?? 0,
containerType: dto.containerType,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
// Service requirements for detailed pricing
hasDangerousGoods: dto.hasDangerousGoods ?? false,
requiresSpecialHandling: dto.requiresSpecialHandling ?? false,
requiresTailgate: dto.requiresTailgate ?? false,
requiresStraps: dto.requiresStraps ?? false,
requiresThermalCover: dto.requiresThermalCover ?? false,
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
requiresAppointment: dto.requiresAppointment ?? false,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
};
// Execute CSV rate search WITH OFFERS GENERATION
const result = await this.csvRateSearchService.executeWithOffers(searchInput);
// Map domain output to response DTO

View File

@ -1,4 +1,5 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Locale } from '@domain/value-objects/locale.vo';
/**
* User payload interface extracted from JWT
@ -10,6 +11,7 @@ export interface UserPayload {
organizationId: string;
firstName: string;
lastName: string;
preferredLanguage?: Locale;
}
/**

View File

@ -11,384 +11,192 @@ import {
import { Type } from 'class-transformer';
import { RateSearchFiltersDto } from './rate-search-filters.dto';
/**
* CSV Rate Search Request DTO
*
* Request body for searching rates in CSV-based system
* Includes basic search parameters + optional advanced filters
*/
export class CsvRateSearchDto {
@ApiProperty({
description: 'Origin port code (UN/LOCODE format)',
example: 'NLRTM',
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
})
@ApiProperty({ description: 'Origin UN/LOCODE', example: 'FRFOS' })
@IsNotEmpty()
@IsString()
origin: string;
@ApiProperty({
description: 'Destination port code (UN/LOCODE format)',
example: 'USNYC',
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
})
@ApiProperty({ description: 'Destination UN/LOCODE', example: 'CNSHA' })
@IsNotEmpty()
@IsString()
destination: string;
@ApiProperty({
description: 'Volume in cubic meters (CBM)',
minimum: 0.01,
example: 25.5,
})
@ApiProperty({ description: 'Volume in cubic meters (CBM)', minimum: 0.01, example: 10.5 })
@IsNotEmpty()
@IsNumber()
@Min(0.01)
volumeCBM: number;
@ApiProperty({
description: 'Weight in kilograms',
minimum: 1,
example: 3500,
})
@ApiProperty({ description: 'Weight in kilograms', minimum: 1, example: 2500 })
@IsNotEmpty()
@IsNumber()
@Min(1)
weightKG: number;
@ApiPropertyOptional({
description: 'Number of pallets (0 if no pallets)',
minimum: 0,
example: 10,
default: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
palletCount?: number;
@ApiPropertyOptional({
description: 'Container type filter (e.g., LCL, 20DRY, 40HC)',
example: 'LCL',
})
@ApiPropertyOptional({ description: 'Container type filter', example: 'LCL' })
@IsOptional()
@IsString()
containerType?: string;
@ApiPropertyOptional({
description: 'Advanced filters for narrowing results',
type: RateSearchFiltersDto,
})
@IsOptional()
@ValidateNested()
@Type(() => RateSearchFiltersDto)
filters?: RateSearchFiltersDto;
// Service requirements for detailed price calculation
@ApiPropertyOptional({
description: 'Cargo contains dangerous goods (DG)',
example: true,
default: false,
})
@ApiPropertyOptional({ description: 'Cargo contains dangerous goods', example: false })
@IsOptional()
@IsBoolean()
hasDangerousGoods?: boolean;
@ApiPropertyOptional({
description: 'Requires special handling',
example: true,
default: false,
})
@ApiPropertyOptional({ description: 'Advanced filters', type: RateSearchFiltersDto })
@IsOptional()
@IsBoolean()
requiresSpecialHandling?: boolean;
@ApiPropertyOptional({
description: 'Requires tailgate lift',
example: false,
default: false,
})
@IsOptional()
@IsBoolean()
requiresTailgate?: boolean;
@ApiPropertyOptional({
description: 'Requires securing straps',
example: true,
default: false,
})
@IsOptional()
@IsBoolean()
requiresStraps?: boolean;
@ApiPropertyOptional({
description: 'Requires thermal protection cover',
example: false,
default: false,
})
@IsOptional()
@IsBoolean()
requiresThermalCover?: boolean;
@ApiPropertyOptional({
description: 'Contains regulated products requiring special documentation',
example: false,
default: false,
})
@IsOptional()
@IsBoolean()
hasRegulatedProducts?: boolean;
@ApiPropertyOptional({
description: 'Requires delivery appointment',
example: true,
default: false,
})
@IsOptional()
@IsBoolean()
requiresAppointment?: boolean;
@ValidateNested()
@Type(() => RateSearchFiltersDto)
filters?: RateSearchFiltersDto;
}
/**
* CSV Rate Search Response DTO
*
* Response containing matching rates with calculated prices
*/
export class CsvRateSearchResponseDto {
@ApiProperty({
description: 'Array of matching rate results',
type: [Object], // Will be replaced with RateResultDto
})
@ApiProperty({ description: 'Array of matching rate results', type: [Object] })
results: CsvRateResultDto[];
@ApiProperty({
description: 'Total number of results found',
example: 15,
})
@ApiProperty({ description: 'Total number of results', example: 12 })
totalResults: number;
@ApiProperty({
description: 'CSV files that were searched',
type: [String],
example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'],
})
@ApiProperty({ description: 'CSV files searched', type: [String] })
searchedFiles: string[];
@ApiProperty({
description: 'Timestamp when search was executed',
example: '2025-10-23T10:30:00Z',
})
@ApiProperty({ description: 'Timestamp of search', example: '2026-05-11T10:30:00Z' })
searchedAt: Date;
@ApiProperty({
description: 'Filters that were applied to the search',
type: RateSearchFiltersDto,
})
@ApiProperty({ description: 'Applied filters' })
appliedFilters: RateSearchFiltersDto;
}
/**
* Surcharge Item DTO
*/
export class SurchargeItemDto {
@ApiProperty({
description: 'Surcharge code',
example: 'DG_FEE',
})
code: string;
@ApiProperty({
description: 'Surcharge description',
example: 'Dangerous goods fee',
})
description: string;
@ApiProperty({
description: 'Surcharge amount in currency',
example: 65.0,
})
amount: number;
@ApiProperty({
description: 'Type of surcharge calculation',
enum: ['FIXED', 'PER_UNIT', 'PERCENTAGE'],
example: 'FIXED',
})
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
export class FobBreakdownDto {
documentation: number;
isps: number;
handling: number;
solas: number;
customs: number;
ams_aci: number;
isf5: number;
dgAdmin: number;
}
/**
* Price Breakdown DTO
*/
export class PriceBreakdownDto {
@ApiProperty({
description: 'Base price before any charges',
example: 0,
@ApiProperty({ description: 'Freight charge', example: 420.0 })
freightCharge: number;
@ApiProperty({ description: 'Freight currency', example: 'USD' })
freightCurrency: string;
@ApiProperty({ description: 'Fixed FOB charges (doc+ISPS+solas+customs+AMS+ISF5)', example: 185 })
fobFixed: number;
@ApiProperty({ description: 'FOB handling charge', example: 60 })
fobHandling: number;
@ApiProperty({ description: 'DG admin fee (FOB currency, 0 if non-DG)', example: 0 })
fobDG: number;
@ApiProperty({ description: 'FOB currency', example: 'EUR' })
fobCurrency: string;
@ApiProperty({ description: 'Itemized FOB breakdown', type: FobBreakdownDto })
fobBreakdown: FobBreakdownDto;
@ApiPropertyOptional({
description: 'DG surcharge amount (null if on_request/not_accepted)',
example: null,
})
basePrice: number;
dgSurchargeAmount: number | null;
@ApiProperty({ description: 'DG surcharge currency', example: 'EUR' })
dgSurchargeCurrency: string;
@ApiProperty({
description: 'Charge based on volume (CBM)',
example: 150.0,
description: 'DG surcharge status',
enum: ['computed', 'on_request', 'not_accepted'],
example: 'computed',
})
volumeCharge: number;
dgSurchargeStatus: string;
@ApiProperty({
description: 'Charge based on weight (KG)',
example: 25.0,
})
weightCharge: number;
@ApiProperty({ description: 'Total freight in freightCurrency', example: 420.0 })
totalFreight: number;
@ApiProperty({
description: 'Charge for pallets',
example: 125.0,
})
palletCharge: number;
@ApiProperty({ description: 'Total FOB in fobCurrency', example: 245 })
totalFob: number;
@ApiProperty({
description: 'List of all surcharges',
type: [SurchargeItemDto],
})
surcharges: SurchargeItemDto[];
@ApiProperty({ description: 'Sum for sorting (currency-naive)', example: 665.0 })
totalPriceForSorting: number;
@ApiProperty({
description: 'Total of all surcharges',
example: 242.0,
})
totalSurcharges: number;
@ApiProperty({
description: 'Total price including all charges',
example: 542.0,
})
totalPrice: number;
@ApiProperty({
description: 'Currency of the pricing',
enum: ['USD', 'EUR'],
example: 'USD',
})
currency: string;
@ApiProperty({ description: 'Primary currency', example: 'USD' })
primaryCurrency: string;
}
/**
* Single CSV Rate Result DTO
*/
export class CsvRateResultDto {
@ApiProperty({
description: 'Company name',
example: 'SSC Consolidation',
})
@ApiProperty({ example: 'SSC Consolidation' })
companyName: string;
@ApiProperty({
description: 'Company email for booking requests',
example: 'bookings@sscconsolidation.com',
})
@ApiProperty({ example: 'bookings@ssc.com' })
companyEmail: string;
@ApiProperty({
description: 'Origin port code',
example: 'NLRTM',
})
@ApiProperty({ description: 'Origin CFS name', example: 'Fos Sur Mer' })
originCFS: string;
@ApiProperty({ description: 'Origin UN/LOCODE', example: 'FRFOS' })
origin: string;
@ApiProperty({
description: 'Destination port code',
example: 'USNYC',
})
@ApiProperty({ description: 'Port of loading', example: 'FOS SUR MER' })
portOfLoading: string;
@ApiProperty({ description: 'Routing type', example: 'Direct' })
routing: string;
@ApiProperty({ description: 'Destination CFS name', example: 'Shanghai' })
destinationCFS: string;
@ApiProperty({ description: 'Destination UN/LOCODE', example: 'CNSHA' })
destination: string;
@ApiProperty({
description: 'Container type',
example: 'LCL',
})
@ApiProperty({ description: 'Destination country', example: 'China' })
destinationCountry: string;
@ApiProperty({ example: 'LCL' })
containerType: string;
@ApiProperty({
description: 'Calculated price in USD',
example: 1850.5,
})
priceUSD: number;
@ApiProperty({
description: 'Calculated price in EUR',
example: 1665.45,
})
priceEUR: number;
@ApiProperty({
description: 'Primary currency of the rate',
enum: ['USD', 'EUR'],
example: 'USD',
})
primaryCurrency: string;
@ApiProperty({
description: 'Detailed price breakdown with all charges',
type: PriceBreakdownDto,
})
@ApiProperty({ description: 'Detailed price breakdown', type: PriceBreakdownDto })
priceBreakdown: PriceBreakdownDto;
@ApiProperty({
description: 'Whether this rate has separate surcharges',
example: true,
})
hasSurcharges: boolean;
@ApiProperty({ description: 'Departure frequency', example: 'Weekly' })
frequency: string;
@ApiProperty({
description: 'Details of surcharges if any',
example: 'BAF+CAF included',
nullable: true,
})
surchargeDetails: string | null;
@ApiProperty({
description: 'Transit time in days',
example: 28,
})
@ApiProperty({ description: 'Transit time (adjusted if service level)', example: 28 })
transitDays: number;
@ApiProperty({
description: 'Rate validity end date',
example: '2025-12-31',
})
@ApiProperty({ description: 'Rate validity end date', example: '2026-12-31' })
validUntil: string;
@ApiProperty({
description: 'Source of the rate',
enum: ['CSV', 'API'],
example: 'CSV',
})
source: 'CSV' | 'API';
@ApiProperty({ description: 'Whether DG cargo is accepted', example: true })
dgAccepted: boolean;
@ApiProperty({
description: 'Match score (0-100) indicating how well this rate matches the search',
minimum: 0,
maximum: 100,
example: 95,
})
@ApiProperty({ description: 'DG surcharge status', example: 'computed' })
dgSurchargeStatus: string;
@ApiProperty({ description: 'Internal remarks', example: 'GR1/GR2' })
remarks: string;
@ApiProperty({ example: 'CSV' })
source: 'CSV';
@ApiProperty({ description: 'Match score 0-100', example: 95 })
matchScore: number;
@ApiPropertyOptional({
description: 'Service level (only present when using search-csv-offers endpoint)',
enum: ['RAPID', 'STANDARD', 'ECONOMIC'],
example: 'RAPID',
})
@ApiPropertyOptional({ enum: ['RAPID', 'STANDARD', 'ECONOMIC'] })
serviceLevel?: string;
@ApiPropertyOptional({
description: 'Original price before service level adjustment',
example: { usd: 1500.0, eur: 1350.0 },
})
originalPrice?: {
usd: number;
eur: number;
};
@ApiPropertyOptional({ description: 'Price multiplier for service level', example: 1.0 })
priceMultiplier?: number;
@ApiPropertyOptional({
description: 'Original transit days before service level adjustment',
example: 20,
example: 28,
})
originalTransitDays?: number;
}

View File

@ -10,15 +10,9 @@ import {
IsString,
} from 'class-validator';
/**
* Rate Search Filters DTO
*
* Advanced filters for narrowing down rate search results
* All filters are optional
*/
export class RateSearchFiltersDto {
@ApiPropertyOptional({
description: 'List of company names to include in search',
description: 'List of company names to include',
type: [String],
example: ['SSC Consolidation', 'ECU Worldwide'],
})
@ -28,59 +22,25 @@ export class RateSearchFiltersDto {
companies?: string[];
@ApiPropertyOptional({
description: 'Minimum volume in CBM (cubic meters)',
minimum: 0,
example: 1,
description: 'Only show "Direct" routing (exclude transhipment)',
example: false,
})
@IsOptional()
@IsNumber()
@Min(0)
minVolumeCBM?: number;
@IsBoolean()
onlyDirect?: boolean;
@ApiPropertyOptional({
description: 'Maximum volume in CBM (cubic meters)',
minimum: 0,
example: 100,
description: 'Exclude routes where DG is not accepted',
example: false,
})
@IsOptional()
@IsNumber()
@Min(0)
maxVolumeCBM?: number;
@IsBoolean()
excludeNonDgRoutes?: boolean;
@ApiPropertyOptional({
description: 'Minimum weight in kilograms',
description: 'Minimum price (totalPriceForSorting)',
minimum: 0,
example: 100,
})
@IsOptional()
@IsNumber()
@Min(0)
minWeightKG?: number;
@ApiPropertyOptional({
description: 'Maximum weight in kilograms',
minimum: 0,
example: 15000,
})
@IsOptional()
@IsNumber()
@Min(0)
maxWeightKG?: number;
@ApiPropertyOptional({
description: 'Exact number of pallets (0 means any)',
minimum: 0,
example: 10,
})
@IsOptional()
@IsNumber()
@Min(0)
palletCount?: number;
@ApiPropertyOptional({
description: 'Minimum price in selected currency',
minimum: 0,
example: 1000,
example: 500,
})
@IsOptional()
@IsNumber()
@ -88,9 +48,9 @@ export class RateSearchFiltersDto {
minPrice?: number;
@ApiPropertyOptional({
description: 'Maximum price in selected currency',
description: 'Maximum price (totalPriceForSorting)',
minimum: 0,
example: 5000,
example: 3000,
})
@IsOptional()
@IsNumber()
@ -110,7 +70,7 @@ export class RateSearchFiltersDto {
@ApiPropertyOptional({
description: 'Maximum transit time in days',
minimum: 0,
example: 40,
example: 45,
})
@IsOptional()
@IsNumber()
@ -120,7 +80,7 @@ export class RateSearchFiltersDto {
@ApiPropertyOptional({
description: 'Container types to filter by',
type: [String],
example: ['LCL', '20DRY', '40HC'],
example: ['LCL'],
})
@IsOptional()
@IsArray()
@ -128,7 +88,7 @@ export class RateSearchFiltersDto {
containerTypes?: string[];
@ApiPropertyOptional({
description: 'Preferred currency for price filtering',
description: 'Preferred currency for price display',
enum: ['USD', 'EUR'],
example: 'USD',
})
@ -136,17 +96,9 @@ export class RateSearchFiltersDto {
@IsEnum(['USD', 'EUR'])
currency?: 'USD' | 'EUR';
@ApiPropertyOptional({
description: 'Only show all-in prices (without separate surcharges)',
example: false,
})
@IsOptional()
@IsBoolean()
onlyAllInPrices?: boolean;
@ApiPropertyOptional({
description: 'Departure date to check rate validity (ISO 8601)',
example: '2025-06-15',
example: '2026-06-15',
})
@IsOptional()
@IsDateString()

View File

@ -0,0 +1,44 @@
/**
* DomainExceptionFilter
*
* Catches any DomainException bubbling up to the HTTP boundary, translates its
* i18nKey/i18nArgs into the caller's locale (resolved by nestjs-i18n) and
* returns a structured JSON error response.
*
* Non-domain errors fall through to NestJS's default handler.
*/
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import { I18nService, I18nContext } from 'nestjs-i18n';
import { Response, Request } from 'express';
import { DomainException } from '@domain/exceptions/domain.exception';
import { DEFAULT_LOCALE, Locale, toLocale } from '@domain/value-objects/locale.vo';
@Catch(DomainException)
export class DomainExceptionFilter implements ExceptionFilter {
constructor(private readonly i18n: I18nService<Record<string, unknown>>) {}
catch(exception: DomainException, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const lang: Locale = toLocale(I18nContext.current()?.lang, DEFAULT_LOCALE) ?? DEFAULT_LOCALE;
const translated = this.i18n.translate(exception.i18nKey, {
lang,
args: exception.i18nArgs,
defaultValue: exception.message,
});
const status = exception.status || HttpStatus.BAD_REQUEST;
response.status(status).json({
statusCode: status,
error: exception.name,
message: typeof translated === 'string' ? translated : exception.message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View File

@ -1,12 +1,4 @@
import {
Controller,
Get,
Query,
Res,
UseGuards,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Controller, Get, Query, Res, UseGuards, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
@ -22,7 +14,7 @@ export class LogsController {
constructor(private readonly configService: ConfigService) {
this.logExporterUrl = this.configService.get<string>(
'LOG_EXPORTER_URL',
'http://xpeditis-log-exporter:3200',
'http://xpeditis-log-exporter:3200'
);
}
@ -39,10 +31,7 @@ export class LogsController {
if (!res.ok) throw new Error(`log-exporter error: ${res.status}`);
return res.json();
} catch (err: any) {
throw new HttpException(
{ error: err.message },
HttpStatus.BAD_GATEWAY,
);
throw new HttpException({ error: err.message }, HttpStatus.BAD_GATEWAY);
}
}
@ -59,7 +48,7 @@ export class LogsController {
@Query('end') end: string,
@Query('limit') limit: string,
@Query('format') format: string = 'json',
@Res() res: Response,
@Res() res: Response
) {
try {
const params = new URLSearchParams();
@ -71,10 +60,9 @@ export class LogsController {
if (limit) params.set('limit', limit);
params.set('format', format);
const upstream = await fetch(
`${this.logExporterUrl}/api/logs/export?${params}`,
{ signal: AbortSignal.timeout(30000) },
);
const upstream = await fetch(`${this.logExporterUrl}/api/logs/export?${params}`, {
signal: AbortSignal.timeout(30000),
});
if (!upstream.ok) {
const body = await upstream.json().catch(() => ({}));

View File

@ -1,5 +1,10 @@
import { Injectable } from '@nestjs/common';
import { CsvRateResultDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
import {
CsvRateResultDto,
CsvRateSearchResponseDto,
PriceBreakdownDto,
FobBreakdownDto,
} from '../dto/csv-rate-search.dto';
import {
CsvRateSearchOutput,
CsvRateSearchResult,
@ -9,100 +14,92 @@ import { RateSearchFiltersDto } from '../dto/rate-search-filters.dto';
import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto';
import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity';
/**
* CSV Rate Mapper
*
* Maps between domain entities and DTOs
* Follows hexagonal architecture principles
*/
@Injectable()
export class CsvRateMapper {
/**
* Map DTO filters to domain filters
*/
mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined {
if (!dto) {
return undefined;
}
if (!dto) return undefined;
return {
companies: dto.companies,
minVolumeCBM: dto.minVolumeCBM,
maxVolumeCBM: dto.maxVolumeCBM,
minWeightKG: dto.minWeightKG,
maxWeightKG: dto.maxWeightKG,
palletCount: dto.palletCount,
onlyDirect: dto.onlyDirect,
excludeNonDgRoutes: dto.excludeNonDgRoutes,
minPrice: dto.minPrice,
maxPrice: dto.maxPrice,
currency: dto.currency,
minTransitDays: dto.minTransitDays,
maxTransitDays: dto.maxTransitDays,
containerTypes: dto.containerTypes,
onlyAllInPrices: dto.onlyAllInPrices,
departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined,
};
}
/**
* Map domain search result to DTO
*/
mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto {
const rate = result.rate;
const bd = result.priceBreakdown;
const fobBreakdown: FobBreakdownDto = {
documentation: bd.fobBreakdown.documentation,
isps: bd.fobBreakdown.isps,
handling: bd.fobBreakdown.handling,
solas: bd.fobBreakdown.solas,
customs: bd.fobBreakdown.customs,
ams_aci: bd.fobBreakdown.ams_aci,
isf5: bd.fobBreakdown.isf5,
dgAdmin: bd.fobBreakdown.dgAdmin,
};
const priceBreakdown: PriceBreakdownDto = {
freightCharge: bd.freightCharge,
freightCurrency: bd.freightCurrency,
fobFixed: bd.fobFixed,
fobHandling: bd.fobHandling,
fobDG: bd.fobDG,
fobCurrency: bd.fobCurrency,
fobBreakdown,
dgSurchargeAmount: bd.dgSurchargeAmount,
dgSurchargeCurrency: bd.dgSurchargeCurrency,
dgSurchargeStatus: bd.dgSurchargeStatus,
totalFreight: bd.totalFreight,
totalFob: bd.totalFob,
totalPriceForSorting: bd.totalPriceForSorting,
primaryCurrency: bd.primaryCurrency,
};
return {
companyName: rate.companyName,
companyEmail: rate.companyEmail,
origin: rate.origin.getValue(),
destination: rate.destination.getValue(),
originCFS: rate.originCFS,
origin: rate.originCode.getValue(),
portOfLoading: rate.portOfLoading,
routing: rate.routing,
destinationCFS: rate.destinationCFS,
destination: rate.destinationCode.getValue(),
destinationCountry: rate.destinationCountry,
containerType: rate.containerType.getValue(),
priceUSD: result.calculatedPrice.usd,
priceEUR: result.calculatedPrice.eur,
primaryCurrency: result.calculatedPrice.primaryCurrency,
priceBreakdown: {
basePrice: result.priceBreakdown.basePrice,
volumeCharge: result.priceBreakdown.volumeCharge,
weightCharge: result.priceBreakdown.weightCharge,
palletCharge: result.priceBreakdown.palletCharge,
surcharges: result.priceBreakdown.surcharges.map(s => ({
code: s.code,
description: s.description,
amount: s.amount,
type: s.type,
})),
totalSurcharges: result.priceBreakdown.totalSurcharges,
totalPrice: result.priceBreakdown.totalPrice,
currency: result.priceBreakdown.currency,
},
hasSurcharges: rate.hasSurcharges(),
surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null,
// Use adjusted transit days if available (service level offers), otherwise use original
priceBreakdown,
frequency: rate.frequency,
transitDays: result.adjustedTransitDays ?? rate.transitDays,
validUntil: rate.validity.getEndDate().toISOString().split('T')[0],
dgAccepted: rate.isDgAccepted(),
dgSurchargeStatus: bd.dgSurchargeStatus,
remarks: rate.remarks,
source: result.source,
matchScore: result.matchScore,
// Include service level fields if present
serviceLevel: result.serviceLevel,
originalPrice: result.originalPrice,
priceMultiplier: result.priceMultiplier,
originalTransitDays: result.originalTransitDays,
};
}
/**
* Map domain search output to response DTO
*/
mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto {
return {
results: output.results.map(result => this.mapSearchResultToDto(result)),
results: output.results.map(r => this.mapSearchResultToDto(r)),
totalResults: output.totalResults,
searchedFiles: output.searchedFiles,
searchedAt: output.searchedAt,
appliedFilters: output.appliedFilters as any, // Already matches DTO structure
appliedFilters: output.appliedFilters as any,
};
}
/**
* Map ORM entity to DTO
*/
mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto {
return {
id: entity.id,
@ -118,10 +115,7 @@ export class CsvRateMapper {
};
}
/**
* Map multiple config entities to DTOs
*/
mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] {
return entities.map(entity => this.mapConfigEntityToDto(entity));
return entities.map(e => this.mapConfigEntityToDto(e));
}
}

View File

@ -374,18 +374,20 @@ export class CsvBookingService {
booking.markBankTransferDeclared();
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER`);
this.logger.log(
`Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER`
);
// Send email to all ADMIN users
try {
const allUsers = await this.userRepository.findAll();
const adminEmails = allUsers
.filter(u => u.role === 'ADMIN' && u.isActive)
.map(u => u.email);
const adminEmails = allUsers.filter(u => u.role === 'ADMIN' && u.isActive).map(u => u.email);
if (adminEmails.length > 0) {
const commissionAmount = booking.commissionAmountEur
? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(booking.commissionAmountEur)
? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(
booking.commissionAmountEur
)
: 'N/A';
await this.emailAdapter.send({
@ -488,7 +490,9 @@ export class CsvBookingService {
notes: booking.notes,
});
this.logger.log(`[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}`);
this.logger.log(
`[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}`
);
}
/**
@ -544,7 +548,9 @@ export class CsvBookingService {
confirmationToken: booking.confirmationToken,
notes: booking.notes,
});
this.logger.log(`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`);
this.logger.log(
`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`
);
} catch (error: any) {
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
}

View File

@ -70,7 +70,10 @@ export class InvitationService {
}
// Check if licenses are available for this organization
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId, inviterRole);
const canInviteResult = await this.subscriptionService.canInviteUser(
organizationId,
inviterRole
);
if (!canInviteResult.canInvite) {
this.logger.warn(
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`

View File

@ -1,60 +1,69 @@
import { PortCode } from '../value-objects/port-code.vo';
import { ContainerType } from '../value-objects/container-type.vo';
import { Money } from '../value-objects/money.vo';
import { Volume } from '../value-objects/volume.vo';
import { SurchargeCollection } from '../value-objects/surcharge.vo';
import { DateRange } from '../value-objects/date-range.vo';
/**
* Volume Range - Valid range for CBM
*/
export interface VolumeRange {
minCBM: number;
maxCBM: number;
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 interface FreightPricing {
freightCurrency: string;
freightRatePerCBM: number; // 0.0 = included/to negotiate
freightMinimum: number;
}
export interface FobCharges {
fobCurrency: string;
fobDocumentation: number;
fobISPS: number;
fobHandling: number;
fobHandlingUnit: HandlingUnit;
fobHandlingMinimum: number;
fobSolas: number;
fobCustoms: number;
fobAMS_ACI: number;
fobISF5: number;
fobDGAdmin: number; // Only if DG shipment
}
export interface DgSurchargeInfo {
dgSurchargeCurrency: string;
dgSurchargeRate: DgSurchargeValue;
dgSurchargeUnit: 'UP' | 'LS' | '%'; // per CBM, lump sum, or percentage
dgSurchargeMin: DgSurchargeValue;
}
/**
* Weight Range - Valid range for KG
*/
export interface WeightRange {
minKG: number;
maxKG: number;
}
/**
* Rate Pricing - Pricing structure for CSV rates
*/
export interface RatePricing {
pricePerCBM: number;
pricePerKG: number;
basePriceUSD: Money;
basePriceEUR: Money;
}
/**
* CSV Rate Entity
*
* Represents a shipping rate loaded from CSV file.
* Contains all information needed to calculate freight costs.
* CsvRate Shipping rate from a consolidator CSV file.
*
* Business Rules:
* - Price is calculated as: max(volumeCBM * pricePerCBM, weightKG * pricePerKG) + surcharges
* - Rate must be valid (within validity period) to be used
* - Volume and weight must be within specified ranges
* - Route matching uses originCode + destinationCode (UN/LOCODE)
* - Price = max(freightRatePerCBM×V, freightMinimum) + FOB fixed + handling
* - FOB and freight may be in different currencies
* - DG surcharge applies only when hasDangerousGoods = true
*/
export class CsvRate {
constructor(
// Supplier identity
public readonly companyName: string,
public readonly companyEmail: string,
public readonly origin: PortCode,
public readonly destination: PortCode,
// Route geography
public readonly originCFS: string,
public readonly originCode: PortCode,
public readonly portOfLoading: string,
public readonly routing: string,
public readonly destinationCFS: string,
public readonly destinationCode: PortCode,
public readonly destinationCountry: string,
// Container
public readonly containerType: ContainerType,
public readonly volumeRange: VolumeRange,
public readonly weightRange: WeightRange,
public readonly palletCount: number,
public readonly pricing: RatePricing,
public readonly currency: string, // Primary currency (USD or EUR)
public readonly surcharges: SurchargeCollection,
// Pricing
public readonly freight: FreightPricing,
public readonly fob: FobCharges,
public readonly dgSurcharge: DgSurchargeInfo,
// Metadata
public readonly remarks: string,
public readonly frequency: FrequencyType,
public readonly transitDays: number,
public readonly validity: DateRange
) {
@ -62,178 +71,56 @@ export class CsvRate {
}
private validate(): void {
if (!this.companyName || this.companyName.trim().length === 0) {
throw new Error('Company name is required');
if (!this.companyName?.trim()) throw new Error('Company name is required');
if (!this.companyEmail?.trim()) throw new Error('Company email is required');
if (this.transitDays <= 0) throw new Error('Transit days must be positive');
if (this.freight.freightMinimum < 0) throw new Error('Freight minimum cannot be negative');
if (this.fob.fobHandling < 0) throw new Error('FOB handling cannot be negative');
}
if (!this.companyEmail || this.companyEmail.trim().length === 0) {
throw new Error('Company email is required');
}
if (this.volumeRange.minCBM < 0 || this.volumeRange.maxCBM < 0) {
throw new Error('Volume range cannot be negative');
}
if (this.volumeRange.minCBM > this.volumeRange.maxCBM) {
throw new Error('Min volume cannot be greater than max volume');
}
if (this.weightRange.minKG < 0 || this.weightRange.maxKG < 0) {
throw new Error('Weight range cannot be negative');
}
if (this.weightRange.minKG > this.weightRange.maxKG) {
throw new Error('Min weight cannot be greater than max weight');
}
if (this.palletCount < 0) {
throw new Error('Pallet count cannot be negative');
}
if (this.pricing.pricePerCBM < 0 || this.pricing.pricePerKG < 0) {
throw new Error('Prices cannot be negative');
}
if (this.transitDays <= 0) {
throw new Error('Transit days must be positive');
}
if (this.currency !== 'USD' && this.currency !== 'EUR') {
throw new Error('Currency must be USD or EUR');
}
}
/**
* Calculate total price for given volume and weight
*
* Business Logic:
* 1. Calculate volume-based price: volumeCBM * pricePerCBM
* 2. Calculate weight-based price: weightKG * pricePerKG
* 3. Take the maximum (freight class rule)
* 4. Add surcharges
*/
calculatePrice(volume: Volume): Money {
// Freight class rule: max(volume price, weight price)
const freightPrice = volume.calculateFreightPrice(
this.pricing.pricePerCBM,
this.pricing.pricePerKG
);
// Create Money object in the rate's currency
let totalPrice = Money.create(freightPrice, this.currency);
// Add surcharges in the same currency
const surchargeTotal = this.surcharges.getTotalAmount(this.currency);
totalPrice = totalPrice.add(surchargeTotal);
return totalPrice;
}
/**
* Get price in specific currency (USD or EUR)
*/
getPriceInCurrency(volume: Volume, targetCurrency: 'USD' | 'EUR'): Money {
const price = this.calculatePrice(volume);
// If already in target currency, return as-is
if (price.getCurrency() === targetCurrency) {
return price;
}
// Otherwise, use the pre-calculated base price in target currency
// and recalculate proportionally
const basePriceInPrimaryCurrency =
this.currency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
const basePriceInTargetCurrency =
targetCurrency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
// Calculate conversion ratio
const ratio = basePriceInTargetCurrency.getAmount() / basePriceInPrimaryCurrency.getAmount();
// Apply ratio to calculated price
const convertedAmount = price.getAmount() * ratio;
return Money.create(convertedAmount, targetCurrency);
}
/**
* Check if rate is valid for a specific date
*/
isValidForDate(date: Date): boolean {
return this.validity.contains(date);
}
/**
* Check if rate is currently valid (today is within validity period)
*/
isCurrentlyValid(): boolean {
return this.validity.isCurrentRange();
}
/**
* Check if volume and weight match this rate's range
*/
matchesVolume(volume: Volume): boolean {
return volume.isWithinRange(
this.volumeRange.minCBM,
this.volumeRange.maxCBM,
this.weightRange.minKG,
this.weightRange.maxKG
);
}
/**
* Check if pallet count matches
* 0 means "any pallet count" (flexible)
* Otherwise must match exactly or be within range
*/
matchesPalletCount(palletCount: number): boolean {
// If rate has 0 pallets, it's flexible
if (this.palletCount === 0) {
return true;
}
// Otherwise must match exactly
return this.palletCount === palletCount;
}
/**
* Check if rate matches a specific route
*/
matchesRoute(origin: PortCode, destination: PortCode): boolean {
return this.origin.equals(origin) && this.destination.equals(destination);
return this.originCode.equals(origin) && this.destinationCode.equals(destination);
}
/**
* Check if rate has separate surcharges
*/
hasSurcharges(): boolean {
return !this.surcharges.isEmpty();
isDgAccepted(): boolean {
return this.dgSurcharge.dgSurchargeRate !== 'NOT ACCEPTED';
}
/**
* Get surcharge details as formatted string
*/
getSurchargeDetails(): string {
return this.surcharges.getDetails();
isDgOnRequest(): boolean {
return this.dgSurcharge.dgSurchargeRate === 'ON REQUEST';
}
/**
* Check if this is an "all-in" rate (no separate surcharges)
*/
isAllInPrice(): boolean {
return this.surcharges.isEmpty();
isDirectRoute(): boolean {
return this.routing.trim().toLowerCase() === 'direct';
}
getFrequencyScore(): number {
switch (this.frequency) {
case 'Weekly':
return 4;
case 'Bi-Weekly':
return 3;
case 'Bi-Monthly':
return 2;
case 'Monthly':
return 1;
default:
return 2;
}
}
/**
* Get route description
*/
getRouteDescription(): string {
return `${this.origin.getValue()}${this.destination.getValue()}`;
return `${this.originCode.getValue()}${this.destinationCode.getValue()}`;
}
/**
* Get company and route summary
*/
getSummary(): string {
return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`;
}

View File

@ -10,6 +10,8 @@
* - Type must be valid (FREIGHT_FORWARDER, CARRIER, SHIPPER)
*/
import { DEFAULT_LOCALE, Locale } from '../value-objects/locale.vo';
export enum OrganizationType {
FREIGHT_FORWARDER = 'FREIGHT_FORWARDER',
CARRIER = 'CARRIER',
@ -47,6 +49,7 @@ export interface OrganizationProps {
siret?: string;
siretVerified: boolean;
statusBadge: 'none' | 'silver' | 'gold' | 'platinium';
defaultLanguage: Locale;
createdAt: Date;
updatedAt: Date;
isActive: boolean;
@ -63,9 +66,13 @@ export class Organization {
* Factory method to create a new Organization
*/
static create(
props: Omit<OrganizationProps, 'createdAt' | 'updatedAt' | 'siretVerified' | 'statusBadge'> & {
props: Omit<
OrganizationProps,
'createdAt' | 'updatedAt' | 'siretVerified' | 'statusBadge' | 'defaultLanguage'
> & {
siretVerified?: boolean;
statusBadge?: 'none' | 'silver' | 'gold' | 'platinium';
defaultLanguage?: Locale;
}
): Organization {
const now = new Date();
@ -94,6 +101,7 @@ export class Organization {
...props,
siretVerified: props.siretVerified ?? false,
statusBadge: props.statusBadge ?? 'none',
defaultLanguage: props.defaultLanguage ?? DEFAULT_LOCALE,
createdAt: now,
updatedAt: now,
});
@ -188,6 +196,15 @@ export class Organization {
return this.props.isActive;
}
get defaultLanguage(): Locale {
return this.props.defaultLanguage;
}
updateDefaultLanguage(locale: Locale): void {
this.props.defaultLanguage = locale;
this.props.updatedAt = new Date();
}
// Business methods
isCarrier(): boolean {
return this.props.type === OrganizationType.CARRIER;

View File

@ -10,6 +10,8 @@
* - Role-based access control (Admin, Manager, User, Viewer)
*/
import { DEFAULT_LOCALE, Locale } from '../value-objects/locale.vo';
export enum UserRole {
ADMIN = 'ADMIN', // Full system access
MANAGER = 'MANAGER', // Manage bookings and users within organization
@ -30,6 +32,7 @@ export interface UserProps {
isEmailVerified: boolean;
isActive: boolean;
lastLoginAt?: Date;
preferredLanguage: Locale;
createdAt: Date;
updatedAt: Date;
}
@ -47,8 +50,13 @@ export class User {
static create(
props: Omit<
UserProps,
'createdAt' | 'updatedAt' | 'isEmailVerified' | 'isActive' | 'lastLoginAt'
>
| 'createdAt'
| 'updatedAt'
| 'isEmailVerified'
| 'isActive'
| 'lastLoginAt'
| 'preferredLanguage'
> & { preferredLanguage?: Locale }
): User {
const now = new Date();
@ -59,6 +67,7 @@ export class User {
return new User({
...props,
preferredLanguage: props.preferredLanguage ?? DEFAULT_LOCALE,
isEmailVerified: false,
isActive: true,
createdAt: now,
@ -142,6 +151,15 @@ export class User {
return this.props.updatedAt;
}
get preferredLanguage(): Locale {
return this.props.preferredLanguage;
}
updatePreferredLanguage(locale: Locale): void {
this.props.preferredLanguage = locale;
this.props.updatedAt = new Date();
}
// Business methods
has2FAEnabled(): boolean {
return !!this.props.totpSecret;

View File

@ -0,0 +1,30 @@
/**
* DomainException (Base)
*
* Base class for all translatable domain exceptions.
* Exceptions carry an i18n key + optional args so the application-layer
* exception filter can translate them into the caller's locale at the HTTP
* response boundary.
*
* Subclasses should:
* - Pass an i18nKey (e.g. 'error.PORT_NOT_FOUND')
* - Pass i18nArgs for interpolation (e.g. { portCode })
* - Optionally override `status` (HTTP status, default 400)
*/
export type I18nArgs = Record<string, string | number | boolean | undefined | null>;
export abstract class DomainException extends Error {
public readonly i18nKey: string;
public readonly i18nArgs: I18nArgs;
public readonly status: number;
constructor(i18nKey: string, i18nArgs: I18nArgs = {}, fallbackMessage?: string, status = 400) {
super(fallbackMessage ?? i18nKey);
this.i18nKey = i18nKey;
this.i18nArgs = i18nArgs;
this.status = status;
this.name = this.constructor.name;
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@ -4,6 +4,7 @@
* All domain exceptions for the Xpeditis platform
*/
export * from './domain.exception';
export * from './invalid-port-code.exception';
export * from './invalid-rate-quote.exception';
export * from './carrier-timeout.exception';

View File

@ -1,13 +1,13 @@
/**
* PortNotFoundException
*
* Thrown when a port is not found in the database
* Thrown when a port is not found in the database.
*/
export class PortNotFoundException extends Error {
import { DomainException } from './domain.exception';
export class PortNotFoundException extends DomainException {
constructor(public readonly portCode: string) {
super(`Port not found: ${portCode}`);
this.name = 'PortNotFoundException';
Object.setPrototypeOf(this, PortNotFoundException.prototype);
super('error.PORT_NOT_FOUND', { portCode }, `Port not found: ${portCode}`, 404);
}
}

View File

@ -1,160 +1,73 @@
import { CsvRate } from '../../entities/csv-rate.entity';
import { ServiceLevel } from '../../services/rate-offer-generator.service';
import { PriceBreakdown } from '../../services/csv-rate-price-calculator.service';
export { PriceBreakdown };
/**
* Advanced Rate Search Filters
*
* Filters for narrowing down rate search results
* Filters for narrowing CSV rate search results.
* Volume/weight range filters removed new schema has no per-rate volume limits.
*/
export interface RateSearchFilters {
// Company filters
companies?: string[]; // List of company names to include
companies?: string[];
// Volume/Weight filters
minVolumeCBM?: number;
maxVolumeCBM?: number;
minWeightKG?: number;
maxWeightKG?: number;
palletCount?: number; // Exact pallet count (0 = any)
// Price filters
// Price filter (applied to totalPriceForSorting)
minPrice?: number;
maxPrice?: number;
currency?: 'USD' | 'EUR'; // Preferred currency for filtering
currency?: 'USD' | 'EUR';
// Transit filters
// Transit filter
minTransitDays?: number;
maxTransitDays?: number;
// Container type filters
containerTypes?: string[]; // e.g., ['LCL', '20DRY', '40HC']
// Route filter
onlyDirect?: boolean; // Only show "Direct" routing
// Surcharge filters
onlyAllInPrices?: boolean; // Only show rates without separate surcharges
// Container type filter
containerTypes?: string[];
// Date filters
departureDate?: Date; // Filter by validity for specific date
// Date filter
departureDate?: Date;
// Service level filter
serviceLevels?: ServiceLevel[]; // Filter by service level (RAPID, STANDARD, ECONOMIC)
// Service level filter (for offers endpoint)
serviceLevels?: ServiceLevel[];
// DG filter
excludeNonDgRoutes?: boolean; // Only show DG-accepted routes
}
/**
* CSV Rate Search Input
*
* Parameters for searching rates in CSV system
*/
export interface CsvRateSearchInput {
origin: string; // Port code (UN/LOCODE)
destination: string; // Port code (UN/LOCODE)
volumeCBM: number; // Volume in cubic meters
weightKG: number; // Weight in kilograms
palletCount?: number; // Number of pallets (0 if none)
containerType?: string; // Optional container type filter
filters?: RateSearchFilters; // Advanced filters
// Service requirements for price calculation
origin: string; // UN/LOCODE
destination: string; // UN/LOCODE
volumeCBM: number;
weightKG: number;
containerType?: string;
hasDangerousGoods?: boolean;
requiresSpecialHandling?: boolean;
requiresTailgate?: boolean;
requiresStraps?: boolean;
requiresThermalCover?: boolean;
hasRegulatedProducts?: boolean;
requiresAppointment?: boolean;
filters?: RateSearchFilters;
}
/**
* Surcharge Item - Individual fee or charge
*/
export interface SurchargeItem {
code: string;
description: string;
amount: number;
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
}
/**
* Price Breakdown - Detailed pricing calculation
*/
export interface PriceBreakdown {
basePrice: number;
volumeCharge: number;
weightCharge: number;
palletCharge: number;
surcharges: SurchargeItem[];
totalSurcharges: number;
totalPrice: number;
currency: string;
}
/**
* CSV Rate Search Result
*
* Single rate result with calculated price
*/
export interface CsvRateSearchResult {
rate: CsvRate;
calculatedPrice: {
usd: number;
eur: number;
primaryCurrency: string;
};
priceBreakdown: PriceBreakdown; // Detailed price calculation
priceBreakdown: PriceBreakdown;
source: 'CSV';
matchScore: number; // 0-100, how well it matches filters
serviceLevel?: ServiceLevel; // Service level (RAPID, STANDARD, ECONOMIC) if offers are generated
originalPrice?: {
usd: number;
eur: number;
}; // Original price before service level adjustment
originalTransitDays?: number; // Original transit days before service level adjustment
adjustedTransitDays?: number; // Adjusted transit days (for service level offers)
matchScore: number;
serviceLevel?: ServiceLevel;
priceMultiplier?: number;
originalTransitDays?: number;
adjustedTransitDays?: number;
}
/**
* CSV Rate Search Output
*
* Results from CSV rate search
*/
export interface CsvRateSearchOutput {
results: CsvRateSearchResult[];
totalResults: number;
searchedFiles: string[]; // CSV files searched
searchedFiles: string[];
searchedAt: Date;
appliedFilters: RateSearchFilters;
}
/**
* Search CSV Rates Port (Input Port)
*
* Use case for searching rates in CSV-based system
* Supports advanced filters for precise rate matching
*/
export interface SearchCsvRatesPort {
/**
* Execute CSV rate search with filters
* @param input - Search parameters and filters
* @returns Matching rates with calculated prices
*/
execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>;
/**
* Execute CSV rate search with service level offers generation
* Generates 3 offers (RAPID, STANDARD, ECONOMIC) for each matching rate
* @param input - Search parameters and filters
* @returns Matching rates with 3 service level variants each
*/
executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>;
/**
* Get available companies in CSV system
* @returns List of company names that have CSV rates
*/
getAvailableCompanies(): Promise<string[]>;
/**
* Get available container types in CSV system
* @returns List of container types available
*/
getAvailableContainerTypes(): Promise<string[]>;
}

View File

@ -3,217 +3,152 @@ import { CsvRate } from '../entities/csv-rate.entity';
export interface PriceCalculationParams {
volumeCBM: number;
weightKG: number;
palletCount: number;
hasDangerousGoods: boolean;
requiresSpecialHandling: boolean;
requiresTailgate: boolean;
requiresStraps: boolean;
requiresThermalCover: boolean;
hasRegulatedProducts: boolean;
requiresAppointment: boolean;
hasDangerousGoods?: boolean;
}
export interface FobBreakdown {
documentation: number;
isps: number;
handling: number;
solas: number;
customs: number;
ams_aci: number;
isf5: number;
dgAdmin: number;
}
export type DgSurchargeStatus = 'computed' | 'on_request' | 'not_accepted';
export interface PriceBreakdown {
basePrice: number;
volumeCharge: number;
weightCharge: number;
palletCharge: number;
surcharges: SurchargeItem[];
totalSurcharges: number;
totalPrice: number;
currency: string;
}
// Freight (in freightCurrency)
freightCharge: number;
freightCurrency: string;
export interface SurchargeItem {
code: string;
description: string;
amount: number;
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
// FOB charges (in fobCurrency)
fobFixed: number; // doc + ISPS + solas + customs + AMS_ACI + ISF5
fobHandling: number;
fobDG: number; // fobDGAdmin only if DG
fobCurrency: string;
fobBreakdown: FobBreakdown;
// DG surcharge (fobCurrency or dgSurchargeCurrency)
dgSurchargeAmount: number | null; // null when on_request or not_accepted
dgSurchargeCurrency: string;
dgSurchargeStatus: DgSurchargeStatus;
// Totals (each in their own currency)
totalFreight: number; // = freightCharge in freightCurrency
totalFob: number; // = fobFixed + fobHandling + fobDG + dgSurcharge in fobCurrency
// Used for sorting/comparison only — naive sum treating both currencies as equal
// Callers should be aware of potential currency mismatch
totalPriceForSorting: number;
primaryCurrency: string;
}
/**
* Service de calcul de prix pour les tarifs CSV
* Calcule le prix total basé sur le volume, poids, palettes et services additionnels
* Calculates price for a CSV rate given volume and weight.
*
* Formula:
* Fret = max(freightRatePerCBM × V, freightMinimum)
* Handling = max(fobHandling × max(V, W_tonnes), fobHandlingMinimum) [if unit=W]
* = max(fobHandling × V, fobHandlingMinimum) [if unit=UP]
* FOB fixed = doc + ISPS + solas + customs + AMS_ACI + ISF5
* Total = Fret (freightCurrency) + FOB_fixed + Handling (fobCurrency)
*/
export class CsvRatePriceCalculatorService {
/**
* Calcule le prix total pour un tarif CSV donné
*/
calculatePrice(rate: CsvRate, params: PriceCalculationParams): PriceBreakdown {
// 1. Prix de base
const basePrice = rate.pricing.basePriceUSD.getAmount();
const V = params.volumeCBM;
const W = params.weightKG / 1000; // convert KG → tonnes for W unit
const isDG = params.hasDangerousGoods ?? false;
// 2. Frais au volume (USD par CBM)
const volumeCharge = rate.pricing.pricePerCBM * params.volumeCBM;
// 1. Freight charge
const freightCharge =
rate.freight.freightRatePerCBM > 0
? Math.max(rate.freight.freightRatePerCBM * V, rate.freight.freightMinimum)
: rate.freight.freightMinimum;
// 3. Frais au poids (USD par KG)
const weightCharge = rate.pricing.pricePerKG * params.weightKG;
// 2. Handling — "W" = tonne revenue (max of CBM and tonnes), "UP" = per CBM
const handlingBase = rate.fob.fobHandlingUnit === 'W' ? Math.max(V, W) : V;
const fobHandling = Math.max(rate.fob.fobHandling * handlingBase, rate.fob.fobHandlingMinimum);
// 4. Frais de palettes (25 USD par palette)
const palletCharge = params.palletCount * 25;
// 3. FOB fixed charges
const fobFixed =
rate.fob.fobDocumentation +
rate.fob.fobISPS +
rate.fob.fobSolas +
rate.fob.fobCustoms +
rate.fob.fobAMS_ACI +
rate.fob.fobISF5;
// 5. Surcharges standard du CSV
const standardSurcharges = this.parseStandardSurcharges(rate.getSurchargeDetails(), params);
// 4. DG admin (FOB currency, only if DG)
const fobDG = isDG ? rate.fob.fobDGAdmin : 0;
// 6. Surcharges additionnelles basées sur les services
const additionalSurcharges = this.calculateAdditionalSurcharges(params);
// 5. DG surcharge (own currency, only if DG)
let dgSurchargeAmount: number | null = null;
let dgSurchargeStatus: DgSurchargeStatus = 'computed';
// 7. Total des surcharges
const allSurcharges = [...standardSurcharges, ...additionalSurcharges];
const totalSurcharges = allSurcharges.reduce((sum, s) => sum + s.amount, 0);
if (isDG) {
const dgRate = rate.dgSurcharge.dgSurchargeRate;
if (dgRate === 'NOT ACCEPTED') {
dgSurchargeStatus = 'not_accepted';
} else if (dgRate === 'ON REQUEST') {
dgSurchargeStatus = 'on_request';
} else {
dgSurchargeStatus = 'computed';
const dgNum = typeof dgRate === 'number' ? dgRate : parseFloat(String(dgRate));
let rawDG = 0;
switch (rate.dgSurcharge.dgSurchargeUnit) {
case 'UP':
rawDG = dgNum * V;
break;
case 'LS':
rawDG = dgNum;
break;
case '%':
rawDG = freightCharge * (dgNum / 100);
break;
}
const dgMin =
typeof rate.dgSurcharge.dgSurchargeMin === 'number' ? rate.dgSurcharge.dgSurchargeMin : 0;
dgSurchargeAmount = Math.max(rawDG, dgMin);
}
}
// 8. Prix total
const totalPrice = basePrice + volumeCharge + weightCharge + palletCharge + totalSurcharges;
// 6. Total FOB (in fobCurrency)
const totalFob = fobFixed + fobHandling + fobDG + (dgSurchargeAmount ?? 0);
// 7. Naive sum for sorting (ignores currency differences)
const totalPriceForSorting = freightCharge + totalFob;
return {
basePrice,
volumeCharge,
weightCharge,
palletCharge,
surcharges: allSurcharges,
totalSurcharges,
totalPrice: Math.round(totalPrice * 100) / 100, // Arrondi à 2 décimales
currency: rate.currency || 'USD',
freightCharge: round2(freightCharge),
freightCurrency: rate.freight.freightCurrency,
fobFixed: round2(fobFixed),
fobHandling: round2(fobHandling),
fobDG: round2(fobDG),
fobCurrency: rate.fob.fobCurrency,
fobBreakdown: {
documentation: rate.fob.fobDocumentation,
isps: rate.fob.fobISPS,
handling: round2(fobHandling),
solas: rate.fob.fobSolas,
customs: rate.fob.fobCustoms,
ams_aci: rate.fob.fobAMS_ACI,
isf5: rate.fob.fobISF5,
dgAdmin: isDG ? rate.fob.fobDGAdmin : 0,
},
dgSurchargeAmount: dgSurchargeAmount !== null ? round2(dgSurchargeAmount) : null,
dgSurchargeCurrency: rate.dgSurcharge.dgSurchargeCurrency,
dgSurchargeStatus,
totalFreight: round2(freightCharge),
totalFob: round2(totalFob),
totalPriceForSorting: round2(totalPriceForSorting),
primaryCurrency: rate.freight.freightCurrency,
};
}
/**
* Parse les surcharges standard du format CSV
* Format: "DOC:10 | ISPS:7 | HANDLING:20 W | DG_FEE:65"
*/
private parseStandardSurcharges(
surchargeDetails: string | null,
params: PriceCalculationParams
): SurchargeItem[] {
if (!surchargeDetails) {
return [];
}
const surcharges: SurchargeItem[] = [];
const items = surchargeDetails.split('|').map(s => s.trim());
for (const item of items) {
const match = item.match(/^([A-Z_]+):(\d+(?:\.\d+)?)\s*([WP%]?)$/);
if (!match) continue;
const [, code, amountStr, type] = match;
let amount = parseFloat(amountStr);
let surchargeType: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE' = 'FIXED';
// Calcul selon le type
if (type === 'W') {
// Par poids (W = Weight)
amount = amount * params.weightKG;
surchargeType = 'PER_UNIT';
} else if (type === 'P') {
// Par palette
amount = amount * params.palletCount;
surchargeType = 'PER_UNIT';
} else if (type === '%') {
// Pourcentage (sera appliqué sur le total)
surchargeType = 'PERCENTAGE';
}
// Certaines surcharges ne s'appliquent que si certaines conditions sont remplies
if (code === 'DG_FEE' && !params.hasDangerousGoods) {
continue; // Skip DG fee si pas de marchandises dangereuses
}
surcharges.push({
code,
description: this.getSurchargeDescription(code),
amount: Math.round(amount * 100) / 100,
type: surchargeType,
});
}
return surcharges;
}
/**
* Calcule les surcharges additionnelles basées sur les services demandés
*/
private calculateAdditionalSurcharges(params: PriceCalculationParams): SurchargeItem[] {
const surcharges: SurchargeItem[] = [];
if (params.requiresSpecialHandling) {
surcharges.push({
code: 'SPECIAL_HANDLING',
description: 'Manutention particulière',
amount: 75,
type: 'FIXED',
});
}
if (params.requiresTailgate) {
surcharges.push({
code: 'TAILGATE',
description: 'Hayon élévateur',
amount: 50,
type: 'FIXED',
});
}
if (params.requiresStraps) {
surcharges.push({
code: 'STRAPS',
description: 'Sangles de sécurité',
amount: 30,
type: 'FIXED',
});
}
if (params.requiresThermalCover) {
surcharges.push({
code: 'THERMAL_COVER',
description: 'Couverture thermique',
amount: 100,
type: 'FIXED',
});
}
if (params.hasRegulatedProducts) {
surcharges.push({
code: 'REGULATED_PRODUCTS',
description: 'Produits réglementés',
amount: 80,
type: 'FIXED',
});
}
if (params.requiresAppointment) {
surcharges.push({
code: 'APPOINTMENT',
description: 'Livraison sur rendez-vous',
amount: 40,
type: 'FIXED',
});
}
return surcharges;
}
/**
* Retourne la description d'un code de surcharge standard
*/
private getSurchargeDescription(code: string): string {
const descriptions: Record<string, string> = {
DOC: 'Documentation fee',
ISPS: 'ISPS Security',
HANDLING: 'Handling charges',
SOLAS: 'SOLAS VGM',
CUSTOMS: 'Customs clearance',
AMS_ACI: 'AMS/ACI filing',
DG_FEE: 'Dangerous goods fee',
BAF: 'Bunker Adjustment Factor',
CAF: 'Currency Adjustment Factor',
THC: 'Terminal Handling Charges',
BL_FEE: 'Bill of Lading fee',
TELEX_RELEASE: 'Telex release',
ORIGIN_CHARGES: 'Origin charges',
DEST_CHARGES: 'Destination charges',
};
return descriptions[code] || code.replace(/_/g, ' ');
}
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}

View File

@ -1,7 +1,6 @@
import { CsvRate } from '../entities/csv-rate.entity';
import { PortCode } from '../value-objects/port-code.vo';
import { ContainerType } from '../value-objects/container-type.vo';
import { Volume } from '../value-objects/volume.vo';
import {
SearchCsvRatesPort,
CsvRateSearchInput,
@ -11,11 +10,8 @@ import {
} from '@domain/ports/in/search-csv-rates.port';
import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port';
import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service';
import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.service';
import { RateOfferGeneratorService } from './rate-offer-generator.service';
/**
* Config Metadata Interface (to avoid circular dependency)
*/
interface CsvRateConfig {
companyName: string;
csvFilePath: string;
@ -25,21 +21,10 @@ interface CsvRateConfig {
};
}
/**
* Config Repository Port (simplified interface)
*/
export interface CsvRateConfigRepositoryPort {
findActiveConfigs(): Promise<CsvRateConfig[]>;
}
/**
* CSV Rate Search Service
*
* Domain service implementing CSV rate search use case.
* Applies business rules for matching rates and filtering.
*
* Pure domain logic - no framework dependencies.
*/
export class CsvRateSearchService implements SearchCsvRatesPort {
private readonly priceCalculator: CsvRatePriceCalculatorService;
private readonly offerGenerator: RateOfferGeneratorService;
@ -54,63 +39,39 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
const searchStartTime = new Date();
// Parse and validate input
const origin = PortCode.create(input.origin);
const destination = PortCode.create(input.destination);
const volume = new Volume(input.volumeCBM, input.weightKG);
const palletCount = input.palletCount ?? 0;
// Load all CSV rates
const allRates = await this.loadAllRates();
// Apply route and volume matching
let matchingRates = this.filterByRoute(allRates, origin, destination);
matchingRates = this.filterByVolume(matchingRates, volume);
matchingRates = this.filterByPalletCount(matchingRates, palletCount);
// Apply container type filter if specified
if (input.containerType) {
const containerType = ContainerType.create(input.containerType);
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
}
// Apply advanced filters
if (input.filters) {
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume);
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, input);
}
// Calculate prices and create results
const results: CsvRateSearchResult[] = matchingRates.map(rate => {
// Calculate detailed price breakdown
const priceBreakdown = this.priceCalculator.calculatePrice(rate, {
volumeCBM: input.volumeCBM,
weightKG: input.weightKG,
palletCount: input.palletCount ?? 0,
hasDangerousGoods: input.hasDangerousGoods ?? false,
requiresSpecialHandling: input.requiresSpecialHandling ?? false,
requiresTailgate: input.requiresTailgate ?? false,
requiresStraps: input.requiresStraps ?? false,
requiresThermalCover: input.requiresThermalCover ?? false,
hasRegulatedProducts: input.hasRegulatedProducts ?? false,
requiresAppointment: input.requiresAppointment ?? false,
});
return {
rate,
calculatedPrice: {
usd: priceBreakdown.totalPrice,
eur: priceBreakdown.totalPrice, // TODO: Add currency conversion
primaryCurrency: priceBreakdown.currency,
},
priceBreakdown,
source: 'CSV' as const,
matchScore: this.calculateMatchScore(rate, input),
matchScore: this.calculateMatchScore(rate),
};
});
// Sort by total price (ascending)
results.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice);
results.sort(
(a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting
);
return {
results,
@ -122,101 +83,67 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
}
/**
* Execute CSV rate search with service level offers generation
* Generates 3 offers (RAPID, STANDARD, ECONOMIC) for each matching rate
* Search with service level offers returns 3 variants per rate (ECONOMIC / STANDARD / RAPID).
* Price multipliers (0.85 / 1.0 / 1.2) are applied to totalPriceForSorting.
*/
async executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
const searchStartTime = new Date();
// Parse and validate input
const origin = PortCode.create(input.origin);
const destination = PortCode.create(input.destination);
const volume = new Volume(input.volumeCBM, input.weightKG);
const palletCount = input.palletCount ?? 0;
// Load all CSV rates
const allRates = await this.loadAllRates();
// Apply route and volume matching
let matchingRates = this.filterByRoute(allRates, origin, destination);
matchingRates = this.filterByVolume(matchingRates, volume);
matchingRates = this.filterByPalletCount(matchingRates, palletCount);
// Apply container type filter if specified
if (input.containerType) {
const containerType = ContainerType.create(input.containerType);
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
}
// Apply advanced filters (before generating offers)
if (input.filters) {
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume);
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, input);
}
// Filter eligible rates for offer generation
const eligibleRates = this.offerGenerator.filterEligibleRates(matchingRates);
// Generate 3 offers (RAPID, STANDARD, ECONOMIC) for each eligible rate
const allOffers = this.offerGenerator.generateOffersForRates(eligibleRates);
// Convert offers to search results
const results: CsvRateSearchResult[] = allOffers.map(offer => {
// Calculate detailed price breakdown with adjusted prices
const priceBreakdown = this.priceCalculator.calculatePrice(offer.rate, {
volumeCBM: input.volumeCBM,
weightKG: input.weightKG,
palletCount: input.palletCount ?? 0,
hasDangerousGoods: input.hasDangerousGoods ?? false,
requiresSpecialHandling: input.requiresSpecialHandling ?? false,
requiresTailgate: input.requiresTailgate ?? false,
requiresStraps: input.requiresStraps ?? false,
requiresThermalCover: input.requiresThermalCover ?? false,
hasRegulatedProducts: input.hasRegulatedProducts ?? false,
requiresAppointment: input.requiresAppointment ?? false,
});
// Apply service level price adjustment to the total price
const adjustedTotalPrice =
priceBreakdown.totalPrice *
(offer.serviceLevel === ServiceLevel.RAPID
? 1.2
: offer.serviceLevel === ServiceLevel.ECONOMIC
? 0.85
: 1.0);
const multiplier = offer.priceMultiplier;
const adjustedBreakdown = {
...priceBreakdown,
freightCharge: round2(priceBreakdown.freightCharge * multiplier),
totalFreight: round2(priceBreakdown.totalFreight * multiplier),
totalFob: round2(priceBreakdown.totalFob * multiplier),
totalPriceForSorting: round2(priceBreakdown.totalPriceForSorting * multiplier),
};
return {
rate: offer.rate,
calculatedPrice: {
usd: adjustedTotalPrice,
eur: adjustedTotalPrice, // TODO: Add currency conversion
primaryCurrency: priceBreakdown.currency,
},
priceBreakdown: {
...priceBreakdown,
totalPrice: adjustedTotalPrice,
},
priceBreakdown: adjustedBreakdown,
source: 'CSV' as const,
matchScore: this.calculateMatchScore(offer.rate, input),
matchScore: this.calculateMatchScore(offer.rate),
serviceLevel: offer.serviceLevel,
originalPrice: {
usd: offer.originalPriceUSD,
eur: offer.originalPriceEUR,
},
priceMultiplier: offer.priceMultiplier,
originalTransitDays: offer.originalTransitDays,
adjustedTransitDays: offer.adjustedTransitDays,
};
});
// Apply service level filter if specified
let filteredResults = results;
if (input.filters?.serviceLevels && input.filters.serviceLevels.length > 0) {
if (input.filters?.serviceLevels?.length) {
filteredResults = results.filter(
r => r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel)
);
}
// Sort by total price (ascending) - ECONOMIC first, then STANDARD, then RAPID
filteredResults.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice);
filteredResults.sort(
(a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting
);
return {
results: filteredResults,
@ -229,197 +156,110 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
async getAvailableCompanies(): Promise<string[]> {
const allRates = await this.loadAllRates();
const companies = new Set(allRates.map(rate => rate.companyName));
return Array.from(companies).sort();
return [...new Set(allRates.map(r => r.companyName))].sort();
}
async getAvailableContainerTypes(): Promise<string[]> {
const allRates = await this.loadAllRates();
const types = new Set(allRates.map(rate => rate.containerType.getValue()));
return Array.from(types).sort();
return [...new Set(allRates.map(r => r.containerType.getValue()))].sort();
}
/**
* Get all unique origin port codes from CSV rates
* Used to limit port selection to only those with available routes
*/
async getAvailableOrigins(): Promise<string[]> {
const allRates = await this.loadAllRates();
const origins = new Set(allRates.map(rate => rate.origin.getValue()));
return Array.from(origins).sort();
return [...new Set(allRates.map(r => r.originCode.getValue()))].sort();
}
/**
* Get all destination port codes available for a given origin
* Used to limit destination selection based on selected origin
*/
async getAvailableDestinations(origin: string): Promise<string[]> {
const allRates = await this.loadAllRates();
const originCode = PortCode.create(origin);
const destinations = new Set(
allRates
.filter(rate => rate.origin.equals(originCode))
.map(rate => rate.destination.getValue())
);
return Array.from(destinations).sort();
return [
...new Set(
allRates.filter(r => r.originCode.equals(originCode)).map(r => r.destinationCode.getValue())
),
].sort();
}
/**
* Get all available routes (origin-destination pairs) from CSV rates
* Returns a map of origin codes to their available destination codes
*/
async getAvailableRoutes(): Promise<Map<string, string[]>> {
const allRates = await this.loadAllRates();
const routeMap = new Map<string, Set<string>>();
allRates.forEach(rate => {
const origin = rate.origin.getValue();
const destination = rate.destination.getValue();
if (!routeMap.has(origin)) {
routeMap.set(origin, new Set());
}
const origin = rate.originCode.getValue();
const destination = rate.destinationCode.getValue();
if (!routeMap.has(origin)) routeMap.set(origin, new Set());
routeMap.get(origin)!.add(destination);
});
// Convert Sets to sorted arrays
const result = new Map<string, string[]>();
routeMap.forEach((destinations, origin) => {
result.set(origin, Array.from(destinations).sort());
result.set(origin, [...destinations].sort());
});
return result;
}
/**
* Load all rates from all CSV files
*/
private async loadAllRates(): Promise<CsvRate[]> {
// If config repository is available, load rates with emails and company names from configs
if (this.configRepository) {
const configs = await this.configRepository.findActiveConfigs();
const ratePromises = configs.map(config => {
if (configs.length > 0) {
const results = await Promise.allSettled(
configs.map(config => {
const email = config.metadata?.companyEmail || 'bookings@example.com';
// Pass company name from config to override CSV column value
return this.csvRateLoader.loadRatesFromCsv(config.csvFilePath, email, config.companyName);
});
return this.csvRateLoader.loadRatesFromCsv(
config.csvFilePath,
email,
config.companyName
);
})
);
// Use allSettled to handle missing files gracefully
const results = await Promise.allSettled(ratePromises);
const rateArrays = results
.filter(
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
)
.map(result => result.value);
// Log any failed file loads
const failures = results.filter(result => result.status === 'rejected');
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
console.warn(
`Failed to load ${failures.length} CSV files:`,
failures.map(
(f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`
)
);
console.warn(`Failed to load ${failures.length} CSV files from database configs`);
}
return rateArrays.flat();
return results
.filter((r): r is PromiseFulfilledResult<CsvRate[]> => r.status === 'fulfilled')
.flatMap(r => r.value);
}
// DB has no active configs — fall through to local CSV files
console.warn('No active CSV rate configs in database, loading from local CSV files');
}
// Fallback: load files without email (use default)
const files = await this.csvRateLoader.getAvailableCsvFiles();
const ratePromises = files.map(file =>
this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com')
const results = await Promise.allSettled(
files.map(file => this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com'))
);
// Use allSettled here too for consistency
const results = await Promise.allSettled(ratePromises);
const rateArrays = results
.filter(
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
)
.map(result => result.value);
return rateArrays.flat();
return results
.filter((r): r is PromiseFulfilledResult<CsvRate[]> => r.status === 'fulfilled')
.flatMap(r => r.value);
}
/**
* Filter rates by route (origin/destination)
*/
private filterByRoute(rates: CsvRate[], origin: PortCode, destination: PortCode): CsvRate[] {
return rates.filter(rate => rate.matchesRoute(origin, destination));
}
/**
* Filter rates by volume/weight range
*/
private filterByVolume(rates: CsvRate[], volume: Volume): CsvRate[] {
return rates.filter(rate => rate.matchesVolume(volume));
}
/**
* Filter rates by pallet count
*/
private filterByPalletCount(rates: CsvRate[], palletCount: number): CsvRate[] {
return rates.filter(rate => rate.matchesPalletCount(palletCount));
}
/**
* Apply advanced filters to rate list
*/
private applyAdvancedFilters(
rates: CsvRate[],
filters: RateSearchFilters,
volume: Volume
input: CsvRateSearchInput
): CsvRate[] {
let filtered = rates;
// Company filter
if (filters.companies && filters.companies.length > 0) {
if (filters.companies?.length) {
filtered = filtered.filter(rate => filters.companies!.includes(rate.companyName));
}
// Volume CBM filter
if (filters.minVolumeCBM !== undefined) {
filtered = filtered.filter(rate => rate.volumeRange.maxCBM >= filters.minVolumeCBM!);
}
if (filters.maxVolumeCBM !== undefined) {
filtered = filtered.filter(rate => rate.volumeRange.minCBM <= filters.maxVolumeCBM!);
if (filters.onlyDirect) {
filtered = filtered.filter(rate => rate.isDirectRoute());
}
// Weight KG filter
if (filters.minWeightKG !== undefined) {
filtered = filtered.filter(rate => rate.weightRange.maxKG >= filters.minWeightKG!);
}
if (filters.maxWeightKG !== undefined) {
filtered = filtered.filter(rate => rate.weightRange.minKG <= filters.maxWeightKG!);
if (filters.excludeNonDgRoutes) {
filtered = filtered.filter(rate => rate.isDgAccepted());
}
// Pallet count filter
if (filters.palletCount !== undefined) {
filtered = filtered.filter(rate => rate.matchesPalletCount(filters.palletCount!));
}
// Price filter (calculate price first)
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
const currency = filters.currency || 'USD';
filtered = filtered.filter(rate => {
const price = rate.getPriceInCurrency(volume, currency);
const amount = price.getAmount();
if (filters.minPrice !== undefined && amount < filters.minPrice) {
return false;
}
if (filters.maxPrice !== undefined && amount > filters.maxPrice) {
return false;
}
return true;
});
}
// Transit days filter
if (filters.minTransitDays !== undefined) {
filtered = filtered.filter(rate => rate.transitDays >= filters.minTransitDays!);
}
@ -427,52 +267,55 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!);
}
// Container type filter
if (filters.containerTypes && filters.containerTypes.length > 0) {
if (filters.containerTypes?.length) {
filtered = filtered.filter(rate =>
filters.containerTypes!.includes(rate.containerType.getValue())
);
}
// All-in prices only filter
if (filters.onlyAllInPrices) {
filtered = filtered.filter(rate => rate.isAllInPrice());
}
// Departure date / validity filter
if (filters.departureDate) {
filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!));
}
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
filtered = filtered.filter(rate => {
const bd = this.priceCalculator.calculatePrice(rate, {
volumeCBM: input.volumeCBM,
weightKG: input.weightKG,
hasDangerousGoods: input.hasDangerousGoods ?? false,
});
if (filters.minPrice !== undefined && bd.totalPriceForSorting < filters.minPrice) {
return false;
}
if (filters.maxPrice !== undefined && bd.totalPriceForSorting > filters.maxPrice) {
return false;
}
return true;
});
}
return filtered;
}
/**
* Calculate match score (0-100) based on how well rate matches input
* Higher score = better match
* Score (0100) based on routing type, departure frequency, and rate validity.
* Higher = better match.
*/
private calculateMatchScore(rate: CsvRate, input: CsvRateSearchInput): number {
private calculateMatchScore(rate: CsvRate): number {
let score = 100;
// Reduce score if volume/weight is near boundaries
const volumeUtilization =
(input.volumeCBM - rate.volumeRange.minCBM) /
(rate.volumeRange.maxCBM - rate.volumeRange.minCBM);
if (volumeUtilization < 0.2 || volumeUtilization > 0.8) {
score -= 10; // Near boundaries
}
// Reduce score if pallet count doesn't match exactly
if (rate.palletCount !== 0 && input.palletCount !== rate.palletCount) {
// Direct route bonus
if (rate.isDirectRoute()) {
score += 10;
} else {
score -= 5;
}
// Increase score for all-in prices (simpler for customers)
if (rate.isAllInPrice()) {
score += 5;
}
// Frequency bonus (Weekly = best)
const freqScore = rate.getFrequencyScore(); // 14
score += (freqScore - 2) * 5; // Weekly: +10, Bi-Weekly: +5, Bi-Monthly: 0, Monthly: -5
// Reduce score for rates expiring soon
// Validity penalty
const daysUntilExpiry = Math.floor(
(rate.validity.getEndDate().getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
@ -485,3 +328,7 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
return Math.max(0, Math.min(100, score));
}
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}

View File

@ -2,16 +2,8 @@ import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.
import { CsvRate } from '../entities/csv-rate.entity';
import { PortCode } from '../value-objects/port-code.vo';
import { ContainerType } from '../value-objects/container-type.vo';
import { Money } from '../value-objects/money.vo';
import { DateRange } from '../value-objects/date-range.vo';
/**
* Test Suite for Rate Offer Generator Service
*
* Vérifie que:
* - RAPID est le plus cher ET le plus rapide
* - ECONOMIC est le moins cher ET le plus lent
* - STANDARD est au milieu en prix et transit time
*/
describe('RateOfferGeneratorService', () => {
let service: RateOfferGeneratorService;
let mockRate: CsvRate;
@ -19,415 +11,226 @@ describe('RateOfferGeneratorService', () => {
beforeEach(() => {
service = new RateOfferGeneratorService();
// Créer un tarif de base pour les tests
// Prix: 1000 USD / 900 EUR, Transit: 20 jours
// Mock minimal CsvRate compatible with new schema
mockRate = {
companyName: 'Test Carrier',
companyEmail: 'test@carrier.com',
origin: PortCode.create('FRPAR'),
destination: PortCode.create('USNYC'),
originCFS: 'Fos Sur Mer',
originCode: PortCode.create('FRFOS'),
portOfLoading: 'FOS SUR MER',
routing: 'Direct',
destinationCFS: 'New York',
destinationCode: PortCode.create('USNYC'),
destinationCountry: 'USA',
containerType: ContainerType.create('LCL'),
volumeRange: { minCBM: 1, maxCBM: 10 },
weightRange: { minKG: 100, maxKG: 5000 },
palletCount: 0,
pricing: {
pricePerCBM: 100,
pricePerKG: 0.5,
basePriceUSD: Money.create(1000, 'USD'),
basePriceEUR: Money.create(900, 'EUR'),
freight: {
freightCurrency: 'USD',
freightRatePerCBM: 50,
freightMinimum: 500,
},
currency: 'USD',
hasSurcharges: false,
surchargeBAF: null,
surchargeCAF: null,
surchargeDetails: null,
fob: {
fobCurrency: 'EUR',
fobDocumentation: 55,
fobISPS: 18,
fobHandling: 22,
fobHandlingUnit: 'W',
fobHandlingMinimum: 110,
fobSolas: 15,
fobCustoms: 85,
fobAMS_ACI: 35,
fobISF5: 0,
fobDGAdmin: 50,
},
dgSurcharge: {
dgSurchargeCurrency: 'EUR',
dgSurchargeRate: 20,
dgSurchargeUnit: 'UP',
dgSurchargeMin: 50,
},
remarks: '',
frequency: 'Weekly',
transitDays: 20,
validity: {
getStartDate: () => new Date('2024-01-01'),
getEndDate: () => new Date('2024-12-31'),
},
validity: DateRange.create(new Date('2026-01-01'), new Date('2026-12-31'), true),
isValidForDate: () => true,
isCurrentlyValid: () => true,
matchesRoute: () => true,
matchesVolume: () => true,
matchesPalletCount: () => true,
getPriceInCurrency: () => Money.create(1000, 'USD'),
isAllInPrice: () => true,
getSurchargeDetails: () => null,
isDgAccepted: () => true,
isDgOnRequest: () => false,
isDirectRoute: () => true,
getFrequencyScore: () => 4,
getRouteDescription: () => 'FRFOS → USNYC',
getSummary: () => 'Test Carrier: FRFOS → USNYC',
toString: () => 'Test Carrier: FRFOS → USNYC',
} as any;
});
describe('generateOffers', () => {
it('devrait générer exactement 3 offres (RAPID, STANDARD, ECONOMIC)', () => {
it('generates exactly 3 offers (RAPID, STANDARD, ECONOMIC)', () => {
const offers = service.generateOffers(mockRate);
expect(offers).toHaveLength(3);
expect(offers.map(o => o.serviceLevel)).toEqual(
expect.arrayContaining([ServiceLevel.RAPID, ServiceLevel.STANDARD, ServiceLevel.ECONOMIC])
);
});
it('ECONOMIC doit être le moins cher', () => {
it('ECONOMIC has the lowest price multiplier (0.85)', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// ECONOMIC doit avoir le prix le plus bas
expect(economic!.adjustedPriceUSD).toBeLessThan(standard!.adjustedPriceUSD);
expect(economic!.adjustedPriceUSD).toBeLessThan(rapid!.adjustedPriceUSD);
// Vérifier le prix attendu: 1000 * 0.85 = 850 USD
expect(economic!.adjustedPriceUSD).toBe(850);
expect(economic!.priceAdjustmentPercent).toBe(-15);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
expect(economic.priceMultiplier).toBe(0.85);
expect(economic.priceAdjustmentPercent).toBe(-15);
});
it('RAPID doit être le plus cher', () => {
it('RAPID has the highest price multiplier (1.2)', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// RAPID doit avoir le prix le plus élevé
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(standard!.adjustedPriceUSD);
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(economic!.adjustedPriceUSD);
// Vérifier le prix attendu: 1000 * 1.20 = 1200 USD
expect(rapid!.adjustedPriceUSD).toBe(1200);
expect(rapid!.priceAdjustmentPercent).toBe(20);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.priceMultiplier).toBe(1.2);
expect(rapid.priceAdjustmentPercent).toBe(20);
});
it("STANDARD doit avoir le prix de base (pas d'ajustement)", () => {
it('STANDARD has no price adjustment (multiplier = 1.0)', () => {
const offers = service.generateOffers(mockRate);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
// STANDARD doit avoir le prix de base (pas de changement)
expect(standard!.adjustedPriceUSD).toBe(1000);
expect(standard!.adjustedPriceEUR).toBe(900);
expect(standard!.priceAdjustmentPercent).toBe(0);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
expect(standard.priceMultiplier).toBe(1.0);
expect(standard.priceAdjustmentPercent).toBe(0);
});
it('RAPID doit être le plus rapide (moins de jours de transit)', () => {
it('RAPID has the shortest transit time', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// RAPID doit avoir le transit time le plus court
expect(rapid!.adjustedTransitDays).toBeLessThan(standard!.adjustedTransitDays);
expect(rapid!.adjustedTransitDays).toBeLessThan(economic!.adjustedTransitDays);
// Vérifier le transit attendu: 20 * 0.70 = 14 jours
expect(rapid!.adjustedTransitDays).toBe(14);
expect(rapid!.transitAdjustmentPercent).toBe(-30);
expect(rapid.adjustedTransitDays).toBeLessThan(standard.adjustedTransitDays);
expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
// 20 * 0.70 = 14
expect(rapid.adjustedTransitDays).toBe(14);
});
it('ECONOMIC doit être le plus lent (plus de jours de transit)', () => {
it('ECONOMIC has the longest transit time', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// ECONOMIC doit avoir le transit time le plus long
expect(economic!.adjustedTransitDays).toBeGreaterThan(standard!.adjustedTransitDays);
expect(economic!.adjustedTransitDays).toBeGreaterThan(rapid!.adjustedTransitDays);
// Vérifier le transit attendu: 20 * 1.50 = 30 jours
expect(economic!.adjustedTransitDays).toBe(30);
expect(economic!.transitAdjustmentPercent).toBe(50);
expect(economic.adjustedTransitDays).toBeGreaterThan(standard.adjustedTransitDays);
// 20 * 1.50 = 30
expect(economic.adjustedTransitDays).toBe(30);
});
it("STANDARD doit avoir le transit time de base (pas d'ajustement)", () => {
it('STANDARD has no transit adjustment', () => {
const offers = service.generateOffers(mockRate);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
// STANDARD doit avoir le transit time de base
expect(standard!.adjustedTransitDays).toBe(20);
expect(standard!.transitAdjustmentPercent).toBe(0);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
expect(standard.adjustedTransitDays).toBe(20);
expect(standard.transitAdjustmentPercent).toBe(0);
});
it('les offres doivent être triées par prix croissant (ECONOMIC -> STANDARD -> RAPID)', () => {
it('offers are sorted by priceMultiplier (ECONOMIC → STANDARD → RAPID)', () => {
const offers = service.generateOffers(mockRate);
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
expect(offers[1].serviceLevel).toBe(ServiceLevel.STANDARD);
expect(offers[2].serviceLevel).toBe(ServiceLevel.RAPID);
// Vérifier que les prix sont dans l'ordre croissant
expect(offers[0].adjustedPriceUSD).toBeLessThan(offers[1].adjustedPriceUSD);
expect(offers[1].adjustedPriceUSD).toBeLessThan(offers[2].adjustedPriceUSD);
});
it('doit conserver les informations originales du tarif', () => {
const offers = service.generateOffers(mockRate);
it('clamps transit time to minimum (5 days)', () => {
const shortTransitRate = { ...mockRate, transitDays: 3 } as any;
const offers = service.generateOffers(shortTransitRate);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.adjustedTransitDays).toBe(5);
});
it('clamps transit time to maximum (90 days)', () => {
const longTransitRate = { ...mockRate, transitDays: 80 } as any;
const offers = service.generateOffers(longTransitRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
expect(economic.adjustedTransitDays).toBe(90);
});
it('preserves the original rate reference', () => {
const offers = service.generateOffers(mockRate);
for (const offer of offers) {
expect(offer.rate).toBe(mockRate);
expect(offer.originalPriceUSD).toBe(1000);
expect(offer.originalPriceEUR).toBe(900);
expect(offer.originalTransitDays).toBe(20);
}
});
it('doit appliquer la contrainte de transit time minimum (5 jours)', () => {
// Tarif avec transit time très court (3 jours)
const shortTransitRate = {
...mockRate,
transitDays: 3,
} as any;
const offers = service.generateOffers(shortTransitRate);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// RAPID avec 3 * 0.70 = 2.1 jours, mais minimum est 5 jours
expect(rapid!.adjustedTransitDays).toBe(5);
});
it('doit appliquer la contrainte de transit time maximum (90 jours)', () => {
// Tarif avec transit time très long (80 jours)
const longTransitRate = {
...mockRate,
transitDays: 80,
} as any;
const offers = service.generateOffers(longTransitRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
// ECONOMIC avec 80 * 1.50 = 120 jours, mais maximum est 90 jours
expect(economic!.adjustedTransitDays).toBe(90);
});
});
describe('generateOffersForRates', () => {
it('doit générer 3 offres par tarif', () => {
const rate1 = mockRate;
const rate2 = {
...mockRate,
companyName: 'Another Carrier',
} as any;
const offers = service.generateOffersForRates([rate1, rate2]);
expect(offers).toHaveLength(6); // 2 tarifs * 3 offres
});
it('doit trier toutes les offres par prix croissant', () => {
const rate1 = mockRate; // Prix base: 1000 USD
const rate2 = {
...mockRate,
companyName: 'Cheaper Carrier',
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(500, 'USD'), // Prix base plus bas
},
} as any;
const offers = service.generateOffersForRates([rate1, rate2]);
// Vérifier que les prix sont triés
for (let i = 0; i < offers.length - 1; i++) {
expect(offers[i].adjustedPriceUSD).toBeLessThanOrEqual(offers[i + 1].adjustedPriceUSD);
}
// L'offre la moins chère devrait être ECONOMIC du rate2
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
expect(offers[0].rate.companyName).toBe('Cheaper Carrier');
it('generates 3 offers per rate', () => {
const rate2 = { ...mockRate, companyName: 'Another Carrier' } as any;
const offers = service.generateOffersForRates([mockRate, rate2]);
expect(offers).toHaveLength(6);
});
});
describe('generateOffersForServiceLevel', () => {
it('doit générer uniquement les offres RAPID', () => {
it('generates only RAPID offers', () => {
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.RAPID);
expect(offers).toHaveLength(1);
expect(offers[0].serviceLevel).toBe(ServiceLevel.RAPID);
});
it('doit générer uniquement les offres ECONOMIC', () => {
it('generates only ECONOMIC offers', () => {
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.ECONOMIC);
expect(offers).toHaveLength(1);
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
});
});
describe('getCheapestOffer', () => {
it("doit retourner l'offre ECONOMIC la moins chère", () => {
const rate1 = mockRate; // 1000 USD base
const rate2 = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(500, 'USD'),
},
} as any;
const cheapest = service.getCheapestOffer([rate1, rate2]);
expect(cheapest).not.toBeNull();
expect(cheapest!.serviceLevel).toBe(ServiceLevel.ECONOMIC);
// 500 * 0.85 = 425 USD
expect(cheapest!.adjustedPriceUSD).toBe(425);
});
it('doit retourner null si aucun tarif', () => {
const cheapest = service.getCheapestOffer([]);
expect(cheapest).toBeNull();
});
});
describe('getFastestOffer', () => {
it("doit retourner l'offre RAPID la plus rapide", () => {
const rate1 = { ...mockRate, transitDays: 20 } as any;
const rate2 = { ...mockRate, transitDays: 10 } as any;
const fastest = service.getFastestOffer([rate1, rate2]);
expect(fastest).not.toBeNull();
expect(fastest!.serviceLevel).toBe(ServiceLevel.RAPID);
// 10 * 0.70 = 7 jours
expect(fastest!.adjustedTransitDays).toBe(7);
});
it('doit retourner null si aucun tarif', () => {
const fastest = service.getFastestOffer([]);
expect(fastest).toBeNull();
});
});
describe('getBestOffersPerServiceLevel', () => {
it('doit retourner la meilleure offre de chaque niveau de service', () => {
const rate1 = mockRate;
const rate2 = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(800, 'USD'),
},
} as any;
const best = service.getBestOffersPerServiceLevel([rate1, rate2]);
it('returns one offer per service level', () => {
const best = service.getBestOffersPerServiceLevel([mockRate]);
expect(best.rapid).not.toBeNull();
expect(best.standard).not.toBeNull();
expect(best.economic).not.toBeNull();
});
// Toutes doivent provenir du rate2 (moins cher)
expect(best.rapid!.originalPriceUSD).toBe(800);
expect(best.standard!.originalPriceUSD).toBe(800);
expect(best.economic!.originalPriceUSD).toBe(800);
it('returns null for all levels when no rates', () => {
const best = service.getBestOffersPerServiceLevel([]);
expect(best.rapid).toBeNull();
expect(best.standard).toBeNull();
expect(best.economic).toBeNull();
});
});
describe('isRateEligible', () => {
it('doit accepter un tarif valide', () => {
it('accepts a valid rate', () => {
expect(service.isRateEligible(mockRate)).toBe(true);
});
it('doit rejeter un tarif avec transit time = 0', () => {
const invalidRate = { ...mockRate, transitDays: 0 } as any;
expect(service.isRateEligible(invalidRate)).toBe(false);
it('rejects a rate with transitDays = 0', () => {
const invalid = { ...mockRate, transitDays: 0 } as any;
expect(service.isRateEligible(invalid)).toBe(false);
});
it('doit rejeter un tarif avec prix = 0', () => {
const invalidRate = {
it('rejects a rate with freightRatePerCBM = 0 and freightMinimum = 0', () => {
const invalid = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(0, 'USD'),
},
freight: { ...mockRate.freight, freightRatePerCBM: 0, freightMinimum: 0 },
} as any;
expect(service.isRateEligible(invalidRate)).toBe(false);
expect(service.isRateEligible(invalid)).toBe(false);
});
it('doit rejeter un tarif expiré', () => {
const expiredRate = {
...mockRate,
isValidForDate: () => false,
} as any;
expect(service.isRateEligible(expiredRate)).toBe(false);
it('rejects an expired rate', () => {
const expired = { ...mockRate, isValidForDate: () => false } as any;
expect(service.isRateEligible(expired)).toBe(false);
});
});
describe('filterEligibleRates', () => {
it('doit filtrer les tarifs invalides', () => {
const validRate = mockRate;
const invalidRate1 = { ...mockRate, transitDays: 0 } as any;
const invalidRate2 = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(0, 'USD'),
},
} as any;
const eligibleRates = service.filterEligibleRates([validRate, invalidRate1, invalidRate2]);
expect(eligibleRates).toHaveLength(1);
expect(eligibleRates[0]).toBe(validRate);
});
});
describe('Validation de la logique métier', () => {
it('RAPID doit TOUJOURS être plus cher que ECONOMIC', () => {
// Test avec différents prix de base
const prices = [100, 500, 1000, 5000, 10000];
for (const price of prices) {
const rate = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(price, 'USD'),
},
} as any;
const offers = service.generateOffers(rate);
describe('Business logic invariants', () => {
it('RAPID priceMultiplier always > ECONOMIC priceMultiplier', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
}
expect(rapid.priceMultiplier).toBeGreaterThan(economic.priceMultiplier);
});
it('RAPID doit TOUJOURS être plus rapide que ECONOMIC', () => {
// Test avec différents transit times de base
const transitDays = [5, 10, 20, 30, 60];
for (const days of transitDays) {
it('RAPID transit always < ECONOMIC transit for different base days', () => {
for (const days of [5, 10, 20, 30, 60]) {
const rate = { ...mockRate, transitDays: days } as any;
const offers = service.generateOffers(rate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
}
});
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le prix', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(standard.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
expect(standard.adjustedPriceUSD).toBeLessThan(rapid.adjustedPriceUSD);
});
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le transit time', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(standard.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
expect(standard.adjustedTransitDays).toBeGreaterThan(rapid.adjustedTransitDays);
});
});
});

View File

@ -3,9 +3,9 @@ import { CsvRate } from '../entities/csv-rate.entity';
/**
* Service Level Types
*
* - RAPID: Offre la plus chère + la plus rapide (transit time réduit)
* - STANDARD: Offre standard (prix et transit time de base)
* - ECONOMIC: Offre la moins chère + la plus lente (transit time augmenté)
* - RAPID : +20% price, -30% transit (express, priority)
* - STANDARD : base price and transit
* - ECONOMIC : -15% price, +50% transit (cheapest, slowest)
*/
export enum ServiceLevel {
RAPID = 'RAPID',
@ -13,243 +13,110 @@ export enum ServiceLevel {
ECONOMIC = 'ECONOMIC',
}
/**
* Rate Offer - Variante d'un tarif avec un niveau de service
*/
export interface RateOffer {
rate: CsvRate;
serviceLevel: ServiceLevel;
adjustedPriceUSD: number;
adjustedPriceEUR: number;
priceMultiplier: number;
adjustedTransitDays: number;
originalPriceUSD: number;
originalPriceEUR: number;
originalTransitDays: number;
priceAdjustmentPercent: number;
transitAdjustmentPercent: number;
description: string;
}
/**
* Configuration pour les ajustements de prix et transit par niveau de service
*/
interface ServiceLevelConfig {
priceMultiplier: number; // Multiplicateur de prix (1.0 = pas de changement)
transitMultiplier: number; // Multiplicateur de transit time (1.0 = pas de changement)
priceMultiplier: number;
transitMultiplier: number;
description: string;
}
/**
* Rate Offer Generator Service
* Generates RAPID / STANDARD / ECONOMIC variants for a given CSV rate.
*
* Service du domaine qui génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV.
*
* Règles métier:
* - RAPID : Prix +20%, Transit -30% (plus cher, plus rapide)
* - STANDARD : Prix +0%, Transit +0% (tarif de base)
* - ECONOMIC : Prix -15%, Transit +50% (moins cher, plus lent)
*
* Pure domain logic - Pas de dépendances framework
* Price adjustment is applied to the total calculated price in the search service
* this service only stores the multiplier and the adjusted transit time.
*/
export class RateOfferGeneratorService {
/**
* Configuration par défaut des niveaux de service
* Ces valeurs peuvent être ajustées selon les besoins métier
*/
private readonly SERVICE_LEVEL_CONFIGS: Record<ServiceLevel, ServiceLevelConfig> = {
[ServiceLevel.RAPID]: {
priceMultiplier: 1.2, // +20% du prix de base
transitMultiplier: 0.7, // -30% du temps de transit (plus rapide)
description: 'Express - Livraison rapide avec service prioritaire',
priceMultiplier: 1.2,
transitMultiplier: 0.7,
description: 'Express Livraison rapide avec service prioritaire',
},
[ServiceLevel.STANDARD]: {
priceMultiplier: 1.0, // Prix de base (pas de changement)
transitMultiplier: 1.0, // Transit time de base (pas de changement)
description: 'Standard - Service régulier au meilleur rapport qualité/prix',
priceMultiplier: 1.0,
transitMultiplier: 1.0,
description: 'Standard Service régulier au meilleur rapport qualité/prix',
},
[ServiceLevel.ECONOMIC]: {
priceMultiplier: 0.85, // -15% du prix de base
transitMultiplier: 1.5, // +50% du temps de transit (plus lent)
description: 'Économique - Tarif réduit avec délai étendu',
priceMultiplier: 0.85,
transitMultiplier: 1.5,
description: 'Économique Tarif réduit avec délai étendu',
},
};
/**
* Transit time minimum (en jours) pour garantir la cohérence
* Même avec réduction, on ne peut pas descendre en dessous de ce minimum
*/
private readonly MIN_TRANSIT_DAYS = 5;
/**
* Transit time maximum (en jours) pour garantir la cohérence
* Même avec augmentation, on ne peut pas dépasser ce maximum
*/
private readonly MAX_TRANSIT_DAYS = 90;
/**
* Génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV
*
* @param rate - Le tarif CSV de base
* @returns Tableau de 3 offres triées par prix croissant (ECONOMIC, STANDARD, RAPID)
*/
generateOffers(rate: CsvRate): RateOffer[] {
const offers: RateOffer[] = [];
// Extraire les prix de base
const basePriceUSD = rate.pricing.basePriceUSD.getAmount();
const basePriceEUR = rate.pricing.basePriceEUR.getAmount();
const baseTransitDays = rate.transitDays;
// Générer les 3 offres
for (const serviceLevel of Object.values(ServiceLevel)) {
const config = this.SERVICE_LEVEL_CONFIGS[serviceLevel];
// Calculer les prix ajustés
const adjustedPriceUSD = this.roundPrice(basePriceUSD * config.priceMultiplier);
const adjustedPriceEUR = this.roundPrice(basePriceEUR * config.priceMultiplier);
// Calculer le transit time ajusté (avec contraintes min/max)
const rawTransitDays = baseTransitDays * config.transitMultiplier;
const adjustedTransitDays = this.constrainTransitDays(Math.round(rawTransitDays));
// Calculer les pourcentages d'ajustement
const priceAdjustmentPercent = Math.round((config.priceMultiplier - 1) * 100);
const transitAdjustmentPercent = Math.round((config.transitMultiplier - 1) * 100);
const rawTransit = rate.transitDays * config.transitMultiplier;
const adjustedTransitDays = this.clampTransit(Math.round(rawTransit));
offers.push({
rate,
serviceLevel,
adjustedPriceUSD,
adjustedPriceEUR,
priceMultiplier: config.priceMultiplier,
adjustedTransitDays,
originalPriceUSD: basePriceUSD,
originalPriceEUR: basePriceEUR,
originalTransitDays: baseTransitDays,
priceAdjustmentPercent,
transitAdjustmentPercent,
originalTransitDays: rate.transitDays,
priceAdjustmentPercent: Math.round((config.priceMultiplier - 1) * 100),
transitAdjustmentPercent: Math.round((config.transitMultiplier - 1) * 100),
description: config.description,
});
}
// Trier par prix croissant: ECONOMIC (moins cher) -> STANDARD -> RAPID (plus cher)
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
// ECONOMIC → STANDARD → RAPID (cheapest first)
return offers.sort((a, b) => a.priceMultiplier - b.priceMultiplier);
}
/**
* Génère plusieurs offres pour une liste de tarifs
*
* @param rates - Liste de tarifs CSV
* @returns Liste de toutes les offres générées (3 par tarif), triées par prix
*/
generateOffersForRates(rates: CsvRate[]): RateOffer[] {
const allOffers: RateOffer[] = [];
for (const rate of rates) {
const offers = this.generateOffers(rate);
allOffers.push(...offers);
return rates.flatMap(rate => this.generateOffers(rate));
}
// Trier toutes les offres par prix croissant
return allOffers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
}
/**
* Génère uniquement les offres d'un niveau de service spécifique
*
* @param rates - Liste de tarifs CSV
* @param serviceLevel - Niveau de service souhaité (RAPID, STANDARD, ECONOMIC)
* @returns Liste des offres du niveau de service demandé, triées par prix
*/
generateOffersForServiceLevel(rates: CsvRate[], serviceLevel: ServiceLevel): RateOffer[] {
const offers: RateOffer[] = [];
for (const rate of rates) {
const allOffers = this.generateOffers(rate);
const matchingOffer = allOffers.find(o => o.serviceLevel === serviceLevel);
if (matchingOffer) {
offers.push(matchingOffer);
}
return rates
.map(rate => this.generateOffers(rate).find(o => o.serviceLevel === serviceLevel)!)
.filter(Boolean);
}
// Trier par prix croissant
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
}
/**
* Obtient l'offre la moins chère (ECONOMIC) parmi une liste de tarifs
*/
getCheapestOffer(rates: CsvRate[]): RateOffer | null {
if (rates.length === 0) return null;
const economicOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC);
return economicOffers[0] || null;
}
/**
* Obtient l'offre la plus rapide (RAPID) parmi une liste de tarifs
*/
getFastestOffer(rates: CsvRate[]): RateOffer | null {
if (rates.length === 0) return null;
const rapidOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID);
// Trier par transit time croissant (plus rapide en premier)
rapidOffers.sort((a, b) => a.adjustedTransitDays - b.adjustedTransitDays);
return rapidOffers[0] || null;
}
/**
* Obtient les meilleures offres (meilleur rapport qualité/prix)
* Retourne une offre de chaque niveau de service avec le meilleur prix
*/
getBestOffersPerServiceLevel(rates: CsvRate[]): {
rapid: RateOffer | null;
standard: RateOffer | null;
economic: RateOffer | null;
} {
return {
rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] || null,
standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] || null,
economic: this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC)[0] || null,
rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] ?? null,
standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] ?? null,
economic: this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC)[0] ?? null,
};
}
/**
* Arrondit le prix à 2 décimales
*/
private roundPrice(price: number): number {
return Math.round(price * 100) / 100;
}
/**
* Contraint le transit time entre les limites min et max
*/
private constrainTransitDays(days: number): number {
return Math.max(this.MIN_TRANSIT_DAYS, Math.min(this.MAX_TRANSIT_DAYS, days));
}
/**
* Vérifie si un tarif est éligible pour la génération d'offres
*
* Critères:
* - Transit time doit être > 0
* - Prix doit être > 0
* - Tarif doit être valide (non expiré)
*/
isRateEligible(rate: CsvRate): boolean {
if (rate.transitDays <= 0) return false;
if (rate.pricing.basePriceUSD.getAmount() <= 0) return false;
// A rate is usable if it has a freight rate or at least a freight minimum
if (rate.freight.freightRatePerCBM <= 0 && rate.freight.freightMinimum <= 0) return false;
if (!rate.isValidForDate(new Date())) return false;
return true;
}
/**
* Filtre les tarifs éligibles pour la génération d'offres
*/
filterEligibleRates(rates: CsvRate[]): CsvRate[] {
return rates.filter(rate => this.isRateEligible(rate));
}
private clampTransit(days: number): number {
return Math.max(this.MIN_TRANSIT_DAYS, Math.min(this.MAX_TRANSIT_DAYS, days));
}
}

View File

@ -14,3 +14,4 @@ export * from './booking-status.vo';
export * from './subscription-plan.vo';
export * from './subscription-status.vo';
export * from './license-status.vo';
export * from './locale.vo';

View File

@ -0,0 +1,19 @@
/**
* Locale Value Object
*
* Represents the supported UI / response languages of the platform.
*/
export const SUPPORTED_LOCALES = ['fr', 'en'] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'fr';
export function isLocale(value: unknown): value is Locale {
return typeof value === 'string' && (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
export function toLocale(value: unknown, fallback: Locale = DEFAULT_LOCALE): Locale {
return isLocale(value) ? value : fallback;
}

View File

@ -0,0 +1,9 @@
{
"LOGIN_SUCCESS": "Login successful",
"LOGOUT_SUCCESS": "Logout successful",
"REGISTER_SUCCESS": "Registration successful — please verify your email",
"PASSWORD_RESET_SENT": "If the email exists, a reset link has been sent",
"PASSWORD_RESET_SUCCESS": "Password has been reset successfully",
"EMAIL_VERIFIED": "Email verified successfully",
"VERIFICATION_EMAIL_SENT": "Verification email has been sent"
}

View File

@ -0,0 +1,10 @@
{
"status": {
"DRAFT": "Draft",
"CONFIRMED": "Confirmed",
"SHIPPED": "Shipped",
"DELIVERED": "Delivered",
"CANCELLED": "Cancelled",
"REJECTED": "Rejected"
}
}

View File

@ -0,0 +1,5 @@
{
"SUCCESS": "Success",
"YES": "Yes",
"NO": "No"
}

View File

@ -0,0 +1,44 @@
{
"common": {
"greeting": "Hello {firstName}",
"footer": "The Xpeditis team",
"ignoreIfNotYou": "If you did not request this email, you can safely ignore it."
},
"verification": {
"subject": "Verify your email",
"title": "Welcome to Xpeditis!",
"body": "Please confirm your email address by clicking the button below.",
"cta": "Verify my email"
},
"passwordReset": {
"subject": "Reset your password",
"title": "Reset your password",
"body": "Click the button below to set a new password. This link is valid for 1 hour.",
"cta": "Reset my password"
},
"welcome": {
"subject": "Welcome to Xpeditis, {firstName}!",
"title": "Welcome aboard!",
"body": "Your account is ready. Start searching maritime rates and bookings right away.",
"cta": "Go to dashboard"
},
"bookingConfirmation": {
"subject": "Booking {bookingNumber} confirmed",
"title": "Booking Confirmation",
"body": "Your booking {bookingNumber} has been confirmed successfully.",
"details": "Details",
"cta": "View booking"
},
"userInvitation": {
"subject": "You have been invited to join Xpeditis",
"title": "You have been invited",
"body": "{inviterName} has invited you to join {organizationName} on Xpeditis.",
"cta": "Accept invitation"
},
"csvBookingRequest": {
"subject": "New booking request {bookingReference}",
"title": "New booking request",
"body": "A new booking request has been submitted. Please review the details.",
"cta": "Review booking"
}
}

View File

@ -0,0 +1,23 @@
{
"INTERNAL_ERROR": "Internal server error",
"UNAUTHORIZED": "Authentication required",
"FORBIDDEN": "You do not have permission to perform this action",
"NOT_FOUND": "Resource not found",
"CONFLICT": "Conflict",
"RATE_LIMITED": "Too many requests — please try again later",
"PORT_NOT_FOUND": "Port not found: {portCode}",
"PORT_INVALID_CODE": "Invalid port code: {portCode}",
"USER_NOT_FOUND": "User not found",
"USER_EMAIL_TAKEN": "This email is already in use",
"USER_INACTIVE": "User account is inactive",
"USER_EMAIL_NOT_VERIFIED": "Email address not verified",
"ORGANIZATION_NOT_FOUND": "Organization not found",
"INVALID_CREDENTIALS": "Invalid email or password",
"INVALID_TOKEN": "Invalid or expired token",
"BOOKING_NOT_FOUND": "Booking {bookingNumber} not found",
"BOOKING_INVALID_STATUS": "Invalid booking status transition",
"RATE_QUOTE_NOT_FOUND": "Rate quote not found",
"RATE_QUOTE_EXPIRED": "Rate quote has expired",
"CARRIER_NOT_FOUND": "Carrier not found",
"NO_LICENSES_AVAILABLE": "No licenses available for this organization"
}

View File

@ -0,0 +1,30 @@
{
"booking": {
"created": {
"title": "Booking Created",
"message": "Your booking {bookingNumber} has been created successfully."
},
"updated": {
"title": "Booking Updated",
"message": "Booking {bookingNumber} status changed to {status}."
},
"confirmed": {
"title": "Booking Confirmed",
"message": "Your booking {bookingNumber} has been confirmed by the carrier."
},
"rejected": {
"title": "Booking Rejected",
"message": "Your booking {bookingNumber} has been rejected by the carrier."
},
"documentUploaded": {
"title": "Document Uploaded",
"message": "Document \"{documentName}\" has been uploaded for your booking."
}
},
"system": {
"welcome": {
"title": "Welcome to Xpeditis",
"message": "Hi {firstName}, welcome aboard! Start by searching for rates."
}
}
}

View File

@ -0,0 +1,36 @@
{
"booking": {
"title": "BOOKING CONFIRMATION",
"bookingNumber": "Booking Number",
"routeInformation": "Route Information",
"origin": "Origin",
"destination": "Destination",
"shipperInformation": "Shipper Information",
"consigneeInformation": "Consignee Information",
"containerDetails": "Container Details",
"cargoDescription": "Cargo Description",
"totalPrice": "Total Price",
"estimatedDeparture": "Estimated Departure",
"estimatedArrival": "Estimated Arrival",
"carrier": "Carrier",
"status": "Status"
},
"rateQuote": {
"title": "RATE QUOTE COMPARISON",
"quoteNumber": "Quote Number",
"issuedAt": "Issued At",
"validUntil": "Valid Until",
"origin": "Origin",
"destination": "Destination",
"carrier": "Carrier",
"transitTime": "Transit Time",
"containerType": "Container Type",
"baseRate": "Base Rate",
"surcharges": "Surcharges",
"totalPrice": "Total Price"
},
"common": {
"generatedOn": "Generated on {date}",
"page": "Page {current} of {total}"
}
}

View File

@ -0,0 +1,30 @@
{
"EMAIL_REQUIRED": "Email is required",
"EMAIL_INVALID": "Invalid email format",
"PASSWORD_REQUIRED": "Password is required",
"PASSWORD_MIN_LENGTH": "Password must be at least {constraint1} characters",
"PASSWORD_MAX_LENGTH": "Password must be at most {constraint1} characters",
"PASSWORD_PATTERN": "Password must contain uppercase, lowercase, number and special character",
"FIRST_NAME_REQUIRED": "First name is required",
"FIRST_NAME_MIN_LENGTH": "First name must be at least {constraint1} characters",
"LAST_NAME_REQUIRED": "Last name is required",
"LAST_NAME_MIN_LENGTH": "Last name must be at least {constraint1} characters",
"PHONE_INVALID": "Invalid phone number",
"SIREN_PATTERN": "SIREN must be exactly 9 digits",
"SIRET_PATTERN": "SIRET must be exactly 14 digits",
"STREET_MIN_LENGTH": "Street must be at least {constraint1} characters",
"CITY_REQUIRED": "City is required",
"POSTAL_CODE_REQUIRED": "Postal code is required",
"COUNTRY_PATTERN": "Country must be a 2-letter ISO code (e.g., FR, US, CN)",
"FIELD_REQUIRED": "This field is required",
"FIELD_TOO_SHORT": "Must be at least {constraint1} characters",
"FIELD_TOO_LONG": "Must be at most {constraint1} characters",
"NUMBER_MIN": "Must be at least {constraint1}",
"NUMBER_MAX": "Must be at most {constraint1}",
"INVALID_UUID": "Invalid identifier format",
"INVALID_DATE": "Invalid date",
"INVALID_ENUM": "Invalid value — allowed values: {constraint1}",
"INVALID_BOOLEAN": "Must be true or false",
"INVALID_URL": "Invalid URL",
"LOCALE_INVALID": "Language must be 'fr' or 'en'"
}

View File

@ -0,0 +1,9 @@
{
"LOGIN_SUCCESS": "Connexion réussie",
"LOGOUT_SUCCESS": "Déconnexion réussie",
"REGISTER_SUCCESS": "Inscription réussie — veuillez vérifier votre email",
"PASSWORD_RESET_SENT": "Si l'email existe, un lien de réinitialisation a été envoyé",
"PASSWORD_RESET_SUCCESS": "Mot de passe réinitialisé avec succès",
"EMAIL_VERIFIED": "Email vérifié avec succès",
"VERIFICATION_EMAIL_SENT": "Email de vérification envoyé"
}

View File

@ -0,0 +1,10 @@
{
"status": {
"DRAFT": "Brouillon",
"CONFIRMED": "Confirmée",
"SHIPPED": "Expédiée",
"DELIVERED": "Livrée",
"CANCELLED": "Annulée",
"REJECTED": "Refusée"
}
}

View File

@ -0,0 +1,5 @@
{
"SUCCESS": "Succès",
"YES": "Oui",
"NO": "Non"
}

View File

@ -0,0 +1,44 @@
{
"common": {
"greeting": "Bonjour {firstName}",
"footer": "L'équipe Xpeditis",
"ignoreIfNotYou": "Si vous n'êtes pas à l'origine de cet email, vous pouvez l'ignorer."
},
"verification": {
"subject": "Vérifiez votre email",
"title": "Bienvenue sur Xpeditis !",
"body": "Veuillez confirmer votre adresse email en cliquant sur le bouton ci-dessous.",
"cta": "Vérifier mon email"
},
"passwordReset": {
"subject": "Réinitialisez votre mot de passe",
"title": "Réinitialisez votre mot de passe",
"body": "Cliquez sur le bouton ci-dessous pour définir un nouveau mot de passe. Ce lien est valide 1 heure.",
"cta": "Réinitialiser mon mot de passe"
},
"welcome": {
"subject": "Bienvenue sur Xpeditis, {firstName} !",
"title": "Bienvenue à bord !",
"body": "Votre compte est prêt. Commencez dès maintenant à rechercher des tarifs maritimes et à réserver.",
"cta": "Accéder au tableau de bord"
},
"bookingConfirmation": {
"subject": "Réservation {bookingNumber} confirmée",
"title": "Confirmation de réservation",
"body": "Votre réservation {bookingNumber} a été confirmée avec succès.",
"details": "Détails",
"cta": "Voir la réservation"
},
"userInvitation": {
"subject": "Vous avez été invité à rejoindre Xpeditis",
"title": "Vous avez été invité",
"body": "{inviterName} vous invite à rejoindre {organizationName} sur Xpeditis.",
"cta": "Accepter l'invitation"
},
"csvBookingRequest": {
"subject": "Nouvelle demande de réservation {bookingReference}",
"title": "Nouvelle demande de réservation",
"body": "Une nouvelle demande de réservation vous est soumise. Veuillez examiner les détails.",
"cta": "Examiner la réservation"
}
}

View File

@ -0,0 +1,23 @@
{
"INTERNAL_ERROR": "Erreur interne du serveur",
"UNAUTHORIZED": "Authentification requise",
"FORBIDDEN": "Vous n'avez pas la permission d'effectuer cette action",
"NOT_FOUND": "Ressource introuvable",
"CONFLICT": "Conflit",
"RATE_LIMITED": "Trop de requêtes — veuillez réessayer plus tard",
"PORT_NOT_FOUND": "Port introuvable : {portCode}",
"PORT_INVALID_CODE": "Code de port invalide : {portCode}",
"USER_NOT_FOUND": "Utilisateur introuvable",
"USER_EMAIL_TAKEN": "Cet email est déjà utilisé",
"USER_INACTIVE": "Le compte utilisateur est inactif",
"USER_EMAIL_NOT_VERIFIED": "Adresse email non vérifiée",
"ORGANIZATION_NOT_FOUND": "Organisation introuvable",
"INVALID_CREDENTIALS": "Email ou mot de passe invalide",
"INVALID_TOKEN": "Jeton invalide ou expiré",
"BOOKING_NOT_FOUND": "Réservation {bookingNumber} introuvable",
"BOOKING_INVALID_STATUS": "Transition de statut de réservation invalide",
"RATE_QUOTE_NOT_FOUND": "Cotation introuvable",
"RATE_QUOTE_EXPIRED": "La cotation a expiré",
"CARRIER_NOT_FOUND": "Transporteur introuvable",
"NO_LICENSES_AVAILABLE": "Aucune licence disponible pour cette organisation"
}

View File

@ -0,0 +1,30 @@
{
"booking": {
"created": {
"title": "Réservation créée",
"message": "Votre réservation {bookingNumber} a été créée avec succès."
},
"updated": {
"title": "Réservation mise à jour",
"message": "Le statut de la réservation {bookingNumber} est passé à {status}."
},
"confirmed": {
"title": "Réservation confirmée",
"message": "Votre réservation {bookingNumber} a été confirmée par le transporteur."
},
"rejected": {
"title": "Réservation refusée",
"message": "Votre réservation {bookingNumber} a été refusée par le transporteur."
},
"documentUploaded": {
"title": "Document ajouté",
"message": "Le document « {documentName} » a été ajouté à votre réservation."
}
},
"system": {
"welcome": {
"title": "Bienvenue sur Xpeditis",
"message": "Bonjour {firstName}, bienvenue à bord ! Commencez par rechercher des tarifs."
}
}
}

View File

@ -0,0 +1,36 @@
{
"booking": {
"title": "CONFIRMATION DE RÉSERVATION",
"bookingNumber": "Numéro de réservation",
"routeInformation": "Informations de route",
"origin": "Origine",
"destination": "Destination",
"shipperInformation": "Expéditeur",
"consigneeInformation": "Destinataire",
"containerDetails": "Détails du conteneur",
"cargoDescription": "Description de la cargaison",
"totalPrice": "Prix total",
"estimatedDeparture": "Départ estimé",
"estimatedArrival": "Arrivée estimée",
"carrier": "Transporteur",
"status": "Statut"
},
"rateQuote": {
"title": "COMPARAISON DE COTATIONS",
"quoteNumber": "Numéro de cotation",
"issuedAt": "Émis le",
"validUntil": "Valide jusqu'au",
"origin": "Origine",
"destination": "Destination",
"carrier": "Transporteur",
"transitTime": "Temps de transit",
"containerType": "Type de conteneur",
"baseRate": "Tarif de base",
"surcharges": "Surtaxes",
"totalPrice": "Prix total"
},
"common": {
"generatedOn": "Généré le {date}",
"page": "Page {current} sur {total}"
}
}

View File

@ -0,0 +1,30 @@
{
"EMAIL_REQUIRED": "L'email est requis",
"EMAIL_INVALID": "Format d'email invalide",
"PASSWORD_REQUIRED": "Le mot de passe est requis",
"PASSWORD_MIN_LENGTH": "Le mot de passe doit contenir au moins {constraint1} caractères",
"PASSWORD_MAX_LENGTH": "Le mot de passe doit contenir au plus {constraint1} caractères",
"PASSWORD_PATTERN": "Le mot de passe doit contenir une majuscule, une minuscule, un chiffre et un caractère spécial",
"FIRST_NAME_REQUIRED": "Le prénom est requis",
"FIRST_NAME_MIN_LENGTH": "Le prénom doit contenir au moins {constraint1} caractères",
"LAST_NAME_REQUIRED": "Le nom est requis",
"LAST_NAME_MIN_LENGTH": "Le nom doit contenir au moins {constraint1} caractères",
"PHONE_INVALID": "Numéro de téléphone invalide",
"SIREN_PATTERN": "Le SIREN doit contenir exactement 9 chiffres",
"SIRET_PATTERN": "Le SIRET doit contenir exactement 14 chiffres",
"STREET_MIN_LENGTH": "L'adresse doit contenir au moins {constraint1} caractères",
"CITY_REQUIRED": "La ville est requise",
"POSTAL_CODE_REQUIRED": "Le code postal est requis",
"COUNTRY_PATTERN": "Le pays doit être un code ISO à 2 lettres (ex. FR, US, CN)",
"FIELD_REQUIRED": "Ce champ est requis",
"FIELD_TOO_SHORT": "Doit contenir au moins {constraint1} caractères",
"FIELD_TOO_LONG": "Doit contenir au plus {constraint1} caractères",
"NUMBER_MIN": "Doit être supérieur ou égal à {constraint1}",
"NUMBER_MAX": "Doit être inférieur ou égal à {constraint1}",
"INVALID_UUID": "Format d'identifiant invalide",
"INVALID_DATE": "Date invalide",
"INVALID_ENUM": "Valeur invalide — valeurs autorisées : {constraint1}",
"INVALID_BOOLEAN": "Doit être vrai ou faux",
"INVALID_URL": "URL invalide",
"LOCALE_INVALID": "La langue doit être 'fr' ou 'en'"
}

View File

@ -5,41 +5,58 @@ import * as path from 'path';
/**
* CSV Converter Service
*
* Détecte automatiquement le format du CSV et convertit au format attendu
* Supporte:
* - Format standard Xpeditis
* - Format "Frais FOB FRET"
* Detects and converts CSV files to the standard 33-column Xpeditis format.
*
* Standard format columns (33):
* companyName, companyEmail, originCFS, originCode, portOfLoading, routing,
* destinationCFS, destinationCode, destinationCountry, containerType,
* freightCurrency, freightRatePerCBM, freightMinimum,
* fobCurrency, fobDocumentation, fobISPS, fobHandling, fobHandlingUnit,
* fobHandlingMinimum, fobSolas, fobCustoms, fobAMS_ACI, fobISF5, fobDGAdmin,
* dgSurchargeCurrency, dgSurchargeRate, dgSurchargeUnit, dgSurchargeMin,
* remarks, frequency, transitDays, validFrom, validUntil
*/
@Injectable()
export class CsvConverterService {
private readonly logger = new Logger(CsvConverterService.name);
// Headers du format standard attendu
private readonly STANDARD_HEADERS = [
'companyName',
'origin',
'destination',
'companyEmail',
'originCFS',
'originCode',
'portOfLoading',
'routing',
'destinationCFS',
'destinationCode',
'destinationCountry',
'containerType',
'minVolumeCBM',
'maxVolumeCBM',
'minWeightKG',
'maxWeightKG',
'palletCount',
'pricePerCBM',
'pricePerKG',
'basePriceUSD',
'basePriceEUR',
'currency',
'hasSurcharges',
'surchargeBAF',
'surchargeCAF',
'surchargeDetails',
'freightCurrency',
'freightRatePerCBM',
'freightMinimum',
'fobCurrency',
'fobDocumentation',
'fobISPS',
'fobHandling',
'fobHandlingUnit',
'fobHandlingMinimum',
'fobSolas',
'fobCustoms',
'fobAMS_ACI',
'fobISF5',
'fobDGAdmin',
'dgSurchargeCurrency',
'dgSurchargeRate',
'dgSurchargeUnit',
'dgSurchargeMin',
'remarks',
'frequency',
'transitDays',
'validFrom',
'validUntil',
];
// Headers du format "Frais FOB FRET"
// Legacy "Frais FOB FRET" format indicators (older Excel exports)
private readonly FOB_FRET_HEADERS = [
'Origine UN code',
'Destination UN code',
@ -49,259 +66,32 @@ export class CsvConverterService {
'Transit time',
];
/**
* Parse une ligne CSV en gérant les champs entre guillemets
*/
private parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
/**
* Détecte le format du CSV
*/
async detectFormat(filePath: string): Promise<'STANDARD' | 'FOB_FRET' | 'UNKNOWN'> {
try {
const content = await fs.readFile(filePath, 'utf-8');
const lines = content.split('\n').filter(line => line.trim());
const lines = content.split('\n').filter(l => l.trim());
if (lines.length === 0) return 'UNKNOWN';
if (lines.length === 0) {
return 'UNKNOWN';
}
// Vérifier les 2 premières lignes (parfois la vraie ligne d'en-tête est la ligne 2)
for (let i = 0; i < Math.min(2, lines.length); i++) {
const headers = this.parseCSVLine(lines[i]);
// Vérifier format standard
const hasStandardHeaders = this.STANDARD_HEADERS.some(h => headers.includes(h));
if (hasStandardHeaders) {
return 'STANDARD';
if (this.STANDARD_HEADERS.some(h => headers.includes(h))) return 'STANDARD';
if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) return 'FOB_FRET';
}
// Vérifier format FOB FRET
const hasFobFretHeaders = this.FOB_FRET_HEADERS.some(h => headers.includes(h));
if (hasFobFretHeaders) {
return 'FOB_FRET';
}
}
return 'UNKNOWN';
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Error detecting CSV format: ${errorMessage}`);
} catch {
return 'UNKNOWN';
}
}
/**
* Calcule les surcharges à partir des colonnes FOB
*/
private calculateSurcharges(row: Record<string, string>): string {
const surcharges: string[] = [];
const surchargeFields = [
{ key: 'Documentation (LS et Minimum)', prefix: 'DOC' },
{ key: 'ISPS (LS et Minimum)', prefix: 'ISPS' },
{ key: 'Manutention', prefix: 'HANDLING' },
{ key: 'Solas (LS et Minimum)', prefix: 'SOLAS' },
{ key: 'Douane (LS et Minimum)', prefix: 'CUSTOMS' },
{ key: 'AMS/ACI (LS et Minimum)', prefix: 'AMS_ACI' },
{ key: 'ISF5 (LS et Minimum)', prefix: 'ISF5' },
{ key: 'Frais admin de dangereux (LS et Minimum)', prefix: 'DG_FEE' },
];
surchargeFields.forEach(({ key, prefix }) => {
if (row[key]) {
const unit = key === 'Manutention' ? row['Unité de manutention (UP;Tonne)'] || 'UP' : '';
surcharges.push(`${prefix}:${row[key]}${unit ? ' ' + unit : ''}`);
}
});
return surcharges.join(' | ');
}
/**
* Convertit une ligne FOB FRET vers le format standard
*/
private convertFobFretRow(row: Record<string, string>, companyName: string): Record<string, any> {
const currency = row['Devise FRET'] || 'USD';
const freightRate = parseFloat(row['Taux de FRET (UP)']) || 0;
const minFreight = parseFloat(row['Minimum FRET (LS)']) || 0;
const transitDays = parseInt(row['Transit time']) || 0;
// Calcul des surcharges
const surchargeDetails = this.calculateSurcharges(row);
const hasSurcharges = surchargeDetails.length > 0;
// Dates de validité (90 jours par défaut)
const validFrom = new Date().toISOString().split('T')[0];
const validUntil = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
// Volumes et poids standards pour LCL
const minVolumeCBM = 1;
const maxVolumeCBM = 20;
const minWeightKG = 100;
const maxWeightKG = 20000;
// Prix par CBM
const pricePerCBM = freightRate > 0 ? freightRate : minFreight;
// Prix par KG (estimation: prix CBM / 200 kg/m³)
const pricePerKG = pricePerCBM > 0 ? (pricePerCBM / 200).toFixed(2) : '0';
return {
companyName,
origin: row['Origine UN code'] || '',
destination: row['Destination UN code'] || '',
containerType: 'LCL',
minVolumeCBM,
maxVolumeCBM,
minWeightKG,
maxWeightKG,
palletCount: 0,
pricePerCBM,
pricePerKG,
basePriceUSD: currency === 'USD' ? pricePerCBM : 0,
basePriceEUR: currency === 'EUR' ? pricePerCBM : 0,
currency,
hasSurcharges,
surchargeBAF: '',
surchargeCAF: '',
surchargeDetails,
transitDays,
validFrom,
validUntil,
};
}
/**
* Convertit un CSV FOB FRET vers le format standard
*/
async convertFobFretToStandard(
inputPath: string,
companyName: string
): Promise<{ outputPath: string; rowsConverted: number }> {
this.logger.log(`Converting FOB FRET CSV: ${inputPath}`);
try {
// Lire le fichier
const content = await fs.readFile(inputPath, 'utf-8');
const lines = content.split('\n').filter(line => line.trim());
if (lines.length < 2) {
throw new Error('CSV file is empty or has no data rows');
}
// Trouver la ligne d'en-tête réelle (chercher celle avec "Devise FRET")
let headerLineIndex = 0;
for (let i = 0; i < Math.min(2, lines.length); i++) {
const headers = this.parseCSVLine(lines[i]);
if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) {
headerLineIndex = i;
break;
}
}
// Parse headers
const headers = this.parseCSVLine(lines[headerLineIndex]);
this.logger.log(`Found FOB FRET headers at line ${headerLineIndex + 1}`);
// Parse data rows (commencer après la ligne d'en-tête)
const dataRows: Record<string, string>[] = [];
for (let i = headerLineIndex + 1; i < lines.length; i++) {
const values = this.parseCSVLine(lines[i]);
const row: Record<string, string> = {};
headers.forEach((header, index) => {
row[header] = values[index] || '';
});
// Vérifier que la ligne a des données valides
if (row['Origine UN code'] && row['Destination UN code']) {
dataRows.push(row);
}
}
this.logger.log(`Found ${dataRows.length} valid data rows`);
// Convertir les lignes
const convertedRows = dataRows.map(row => this.convertFobFretRow(row, companyName));
// Générer le CSV de sortie
const outputLines: string[] = [this.STANDARD_HEADERS.join(',')];
convertedRows.forEach(row => {
const values = this.STANDARD_HEADERS.map(header => {
const value = row[header];
// Échapper les virgules et quotes
if (
typeof value === 'string' &&
(value.includes(',') || value.includes('"') || value.includes('\n'))
) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
});
outputLines.push(values.join(','));
});
// Écrire le fichier converti (garder le chemin absolu)
const outputPath = inputPath.replace('.csv', '-converted.csv');
const absoluteOutputPath = path.isAbsolute(outputPath)
? outputPath
: path.resolve(process.cwd(), outputPath);
await fs.writeFile(absoluteOutputPath, outputLines.join('\n'), 'utf-8');
this.logger.log(`Conversion completed: ${absoluteOutputPath} (${convertedRows.length} rows)`);
return {
outputPath: absoluteOutputPath,
rowsConverted: convertedRows.length,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Error converting CSV: ${errorMessage}`, errorStack);
throw new Error(`CSV conversion failed: ${errorMessage}`);
}
}
/**
* Convertit automatiquement un CSV si nécessaire
*/
async autoConvert(
inputPath: string,
companyName: string
): Promise<{ convertedPath: string; wasConverted: boolean; rowsConverted?: number }> {
const format = await this.detectFormat(inputPath);
this.logger.log(`Detected CSV format: ${format}`);
this.logger.log(`Detected CSV format: ${format} for ${inputPath}`);
if (format === 'STANDARD') {
return {
convertedPath: inputPath,
wasConverted: false,
};
return { convertedPath: inputPath, wasConverted: false };
}
if (format === 'FOB_FRET') {
@ -313,6 +103,134 @@ export class CsvConverterService {
};
}
throw new Error(`Unknown CSV format. Please provide a valid CSV file.`);
throw new Error(
'Unknown CSV format. Please provide a file matching the standard 33-column schema.'
);
}
async convertFobFretToStandard(
inputPath: string,
companyName: string
): Promise<{ outputPath: string; rowsConverted: number }> {
this.logger.log(`Converting legacy FOB FRET CSV: ${inputPath}`);
const content = await fs.readFile(inputPath, 'utf-8');
const lines = content.split('\n').filter(l => l.trim());
if (lines.length < 2) throw new Error('CSV file is empty or has no data rows');
// Find the header line
let headerLineIndex = 0;
for (let i = 0; i < Math.min(2, lines.length); i++) {
const headers = this.parseCSVLine(lines[i]);
if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) {
headerLineIndex = i;
break;
}
}
const headers = this.parseCSVLine(lines[headerLineIndex]);
const dataRows: Record<string, string>[] = [];
for (let i = headerLineIndex + 1; i < lines.length; i++) {
const values = this.parseCSVLine(lines[i]);
const row: Record<string, string> = {};
headers.forEach((header, idx) => (row[header] = values[idx] || ''));
if (row['Origine UN code'] && row['Destination UN code']) {
dataRows.push(row);
}
}
const convertedRows = dataRows.map(row => this.convertFobFretRow(row, companyName));
const outputLines: string[] = [this.STANDARD_HEADERS.join(',')];
convertedRows.forEach(row => {
const values = this.STANDARD_HEADERS.map(header => {
const value = row[header] ?? '';
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
});
outputLines.push(values.join(','));
});
const outputPath = path.isAbsolute(inputPath)
? inputPath.replace('.csv', '-converted.csv')
: path.resolve(process.cwd(), inputPath.replace('.csv', '-converted.csv'));
await fs.writeFile(outputPath, outputLines.join('\n'), 'utf-8');
this.logger.log(`Conversion complete: ${outputPath} (${convertedRows.length} rows)`);
return { outputPath, rowsConverted: convertedRows.length };
}
private convertFobFretRow(row: Record<string, string>, companyName: string): Record<string, any> {
const freightCurrency = row['Devise FRET'] || 'USD';
const freightRatePerCBM = parseFloat(row['Taux de FRET (UP)']) || 0;
const freightMinimum = parseFloat(row['Minimum FRET (LS)']) || 0;
const transitDays = parseInt(row['Transit time'], 10) || 0;
const fobCurrency = row['Devise FOB'] || 'EUR';
const validFrom = new Date().toISOString().split('T')[0];
const validUntil = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const originCode = row['Origine UN code'] || '';
const destinationCode = row['Destination UN code'] || '';
return {
companyName,
companyEmail: row['Email'] || '',
originCFS: row['Origine CFS'] || originCode,
originCode,
portOfLoading: row['Port of Loading'] || originCode,
routing: row['Routing'] || 'Direct',
destinationCFS: row['Destination CFS'] || destinationCode,
destinationCode,
destinationCountry: row['Destination Country'] || '',
containerType: 'LCL',
freightCurrency,
freightRatePerCBM,
freightMinimum,
fobCurrency,
fobDocumentation: parseInt(row['Documentation (LS et Minimum)'], 10) || 0,
fobISPS: parseInt(row['ISPS (LS et Minimum)'], 10) || 0,
fobHandling: parseInt(row['Manutention'], 10) || 0,
fobHandlingUnit: row['Unité de manutention (UP;Tonne)'] || 'W',
fobHandlingMinimum: parseInt(row['Minimum manutention'], 10) || 0,
fobSolas: parseInt(row['Solas (LS et Minimum)'], 10) || 0,
fobCustoms: parseInt(row['Douane (LS et Minimum)'], 10) || 0,
fobAMS_ACI: parseFloat(row['AMS/ACI (LS et Minimum)']) || 0,
fobISF5: parseFloat(row['ISF5 (LS et Minimum)']) || 0,
fobDGAdmin: parseInt(row['Frais admin de dangereux (LS et Minimum)'], 10) || 0,
dgSurchargeCurrency: row['Devise surcharge DG'] || fobCurrency,
dgSurchargeRate: row['Taux surcharge DG'] || '0',
dgSurchargeUnit: row['Unité surcharge DG'] || 'LS',
dgSurchargeMin: row['Minimum surcharge DG'] || '0',
remarks: row['Remarques'] || '',
frequency: row['Frequence'] || 'Weekly',
transitDays,
validFrom,
validUntil,
};
}
private parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
}

View File

@ -4,61 +4,109 @@ import { parse } from 'csv-parse/sync';
import * as fs from 'fs/promises';
import * as path from 'path';
import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port';
import { CsvRate } from '@domain/entities/csv-rate.entity';
import {
CsvRate,
FreightPricing,
FobCharges,
DgSurchargeInfo,
DgSurchargeValue,
HandlingUnit,
FrequencyType,
} from '@domain/entities/csv-rate.entity';
import { PortCode } from '@domain/value-objects/port-code.vo';
import { ContainerType } from '@domain/value-objects/container-type.vo';
import { Money } from '@domain/value-objects/money.vo';
import { Surcharge, SurchargeType, SurchargeCollection } from '@domain/value-objects/surcharge.vo';
import { DateRange } from '@domain/value-objects/date-range.vo';
import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter';
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
/**
* CSV Row Interface
* Maps to CSV file structure
* Standardized 33-column CSV row.
* All suppliers share this exact schema.
*/
interface CsvRow {
// Supplier identity
companyName: string;
origin: string;
destination: string;
companyEmail: string;
// Route geography
originCFS: string;
originCode: string;
portOfLoading: string;
routing: string;
destinationCFS: string;
destinationCode: string;
destinationCountry: string;
// Container
containerType: string;
minVolumeCBM: string;
maxVolumeCBM: string;
minWeightKG: string;
maxWeightKG: string;
palletCount: string;
pricePerCBM: string;
pricePerKG: string;
basePriceUSD: string;
basePriceEUR: string;
currency: string;
hasSurcharges: string;
surchargeBAF?: string;
surchargeCAF?: string;
surchargeDetails?: string;
// Freight
freightCurrency: string;
freightRatePerCBM: string;
freightMinimum: string;
// FOB charges
fobCurrency: string;
fobDocumentation: string;
fobISPS: string;
fobHandling: string;
fobHandlingUnit: string;
fobHandlingMinimum: string;
fobSolas: string;
fobCustoms: string;
fobAMS_ACI: string;
fobISF5: string;
fobDGAdmin: string;
// DG surcharge
dgSurchargeCurrency: string;
dgSurchargeRate: string;
dgSurchargeUnit: string;
dgSurchargeMin: string;
// Metadata
remarks: string;
frequency: string;
transitDays: string;
validFrom: string;
validUntil: string;
}
/**
* CSV Rate Loader Adapter
*
* Infrastructure adapter for loading shipping rates from CSV files.
* Implements CsvRateLoaderPort interface.
*
* Features:
* - CSV parsing with validation
* - Mapping CSV rows to domain entities
* - Error handling and logging
* - File system operations
*/
const REQUIRED_COLUMNS = [
'companyName',
'companyEmail',
'originCFS',
'originCode',
'portOfLoading',
'routing',
'destinationCFS',
'destinationCode',
'destinationCountry',
'containerType',
'freightCurrency',
'freightRatePerCBM',
'freightMinimum',
'fobCurrency',
'fobDocumentation',
'fobISPS',
'fobHandling',
'fobHandlingUnit',
'fobHandlingMinimum',
'fobSolas',
'fobCustoms',
'fobAMS_ACI',
'fobISF5',
'fobDGAdmin',
'dgSurchargeCurrency',
'dgSurchargeRate',
'dgSurchargeUnit',
'dgSurchargeMin',
'remarks',
'frequency',
'transitDays',
'validFrom',
'validUntil',
];
@Injectable()
export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
private readonly logger = new Logger(CsvRateLoaderAdapter.name);
private readonly csvDirectory: string;
// Company name to CSV file mapping
private readonly companyFileMapping: Map<string, string> = new Map([
['SSC Consolidation', 'ssc-consolidation.csv'],
['ECU Worldwide', 'ecu-worldwide.csv'],
@ -71,10 +119,6 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
@Optional() private readonly configService?: ConfigService,
@Optional() private readonly csvConfigRepository?: TypeOrmCsvRateConfigRepository
) {
// CSV files are stored in infrastructure/storage/csv-storage/rates/
// Use absolute path based on project root (works in both dev and production)
// In production, process.cwd() points to the backend app directory
// In development with nest start --watch, it also points to the backend directory
this.csvDirectory = path.join(
process.cwd(),
'src',
@ -84,10 +128,6 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
'rates'
);
this.logger.log(`CSV directory initialized: ${this.csvDirectory}`);
if (this.s3Storage && this.configService) {
this.logger.log('✅ MinIO/S3 storage support enabled for CSV files');
}
}
async loadRatesFromCsv(
@ -95,49 +135,32 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
companyEmail: string,
companyNameOverride?: string
): Promise<CsvRate[]> {
this.logger.log(
`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`
);
this.logger.log(`Loading rates from CSV: ${filePath}`);
try {
let fileContent: string;
// Try to load from MinIO first if configured
if (this.s3Storage && this.configService && this.csvConfigRepository && companyNameOverride) {
try {
const config = await this.csvConfigRepository.findByCompanyName(companyNameOverride);
const minioObjectKey = config?.metadata?.minioObjectKey as string | undefined;
if (minioObjectKey) {
const bucket = this.configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates');
this.logger.log(`📥 Loading CSV from MinIO: ${bucket}/${minioObjectKey}`);
const buffer = await this.s3Storage.download({ bucket, key: minioObjectKey });
fileContent = buffer.toString('utf-8');
this.logger.log(`✅ Successfully loaded CSV from MinIO`);
} else {
// Fallback to local file
throw new Error('No MinIO object key found, using local file');
throw new Error('No MinIO object key');
}
} catch (minioError: any) {
this.logger.warn(
`⚠️ Failed to load from MinIO: ${minioError.message}. Falling back to local file.`
);
// Fallback to local file system
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(this.csvDirectory, filePath);
this.logger.warn(`MinIO unavailable: ${minioError.message}. Using local file.`);
const fullPath = this.resolvePath(filePath);
fileContent = await fs.readFile(fullPath, 'utf-8');
}
} else {
// Read from local file system
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(this.csvDirectory, filePath);
const fullPath = this.resolvePath(filePath);
fileContent = await fs.readFile(fullPath, 'utf-8');
}
// Parse CSV
const records: CsvRow[] = parse(fileContent, {
columns: true,
skip_empty_lines: true,
@ -145,62 +168,48 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
});
this.logger.log(`Parsed ${records.length} rows from ${filePath}`);
// Validate structure
this.validateCsvStructure(records);
// Map to domain entities
const rates = records.map((record, index) => {
try {
return this.mapToCsvRate(record, companyEmail, companyNameOverride);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`);
throw new Error(`Invalid data in row ${index + 1} of ${filePath}: ${errorMessage}`);
const msg = error instanceof Error ? error.message : String(error);
throw new Error(`Row ${index + 1} in ${filePath}: ${msg}`);
}
});
this.logger.log(`Successfully loaded ${rates.length} rates from ${filePath}`);
this.logger.log(`Loaded ${rates.length} rates from ${filePath}`);
return rates;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to load CSV file ${filePath}: ${errorMessage}`);
throw new Error(`CSV loading failed for ${filePath}: ${errorMessage}`);
const msg = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to load ${filePath}: ${msg}`);
throw new Error(`CSV loading failed for ${filePath}: ${msg}`);
}
}
async loadRatesByCompany(companyName: string): Promise<CsvRate[]> {
const fileName = this.companyFileMapping.get(companyName);
if (!fileName) {
this.logger.warn(`No CSV file configured for company: ${companyName}`);
this.logger.warn(`No CSV file for company: ${companyName}`);
return [];
}
// Use placeholder email since we don't have access to config repository here
const placeholderEmail = `info@${companyName.toLowerCase().replace(/\s+/g, '-')}.com`;
return this.loadRatesFromCsv(fileName, placeholderEmail);
const email = `info@${companyName.toLowerCase().replace(/\s+/g, '-')}.com`;
return this.loadRatesFromCsv(fileName, email);
}
async validateCsvFile(
filePath: string
): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> {
const errors: string[] = [];
try {
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(this.csvDirectory, filePath);
// Check if file exists
const fullPath = this.resolvePath(filePath);
try {
await fs.access(fullPath);
} catch {
errors.push(`File not found: ${filePath}`);
return { valid: false, errors };
return { valid: false, errors: [`File not found: ${filePath}`] };
}
// Read and parse
const fileContent = await fs.readFile(fullPath, 'utf-8');
const records: CsvRow[] = parse(fileContent, {
columns: true,
@ -209,200 +218,154 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
});
if (records.length === 0) {
errors.push('CSV file is empty');
return { valid: false, errors, rowCount: 0 };
return { valid: false, errors: ['CSV file is empty'], rowCount: 0 };
}
// Validate structure
try {
this.validateCsvStructure(records);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(errorMessage);
} catch (e) {
errors.push(e instanceof Error ? e.message : String(e));
}
// Validate each row (use dummy email for validation)
records.forEach((record, index) => {
try {
this.mapToCsvRate(record, 'validation@example.com');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(`Row ${index + 1}: ${errorMessage}`);
} catch (e) {
errors.push(`Row ${index + 1}: ${e instanceof Error ? e.message : String(e)}`);
}
});
return { valid: errors.length === 0, errors, rowCount: records.length };
} catch (e) {
return {
valid: errors.length === 0,
errors,
rowCount: records.length,
valid: false,
errors: [`Validation failed: ${e instanceof Error ? e.message : String(e)}`],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(`Validation failed: ${errorMessage}`);
return { valid: false, errors };
}
}
async getAvailableCsvFiles(): Promise<string[]> {
try {
// If MinIO/S3 is configured, list files from there
if (this.s3Storage && this.configService && this.csvConfigRepository) {
if (this.s3Storage && this.csvConfigRepository) {
try {
const configs = await this.csvConfigRepository.findAll();
const minioFiles = configs
.filter(config => config.metadata?.minioObjectKey)
.map(config => config.metadata?.minioObjectKey as string);
if (minioFiles.length > 0) {
this.logger.log(`📂 Found ${minioFiles.length} CSV files in MinIO`);
return minioFiles;
} else {
this.logger.warn('⚠️ No CSV files configured in MinIO, falling back to local files');
}
} catch (minioError: any) {
this.logger.warn(
`⚠️ Failed to list MinIO files: ${minioError.message}. Falling back to local files.`
);
.filter(c => c.metadata?.minioObjectKey)
.map(c => c.metadata?.minioObjectKey as string);
if (minioFiles.length > 0) return minioFiles;
} catch {
// fall through to local
}
}
// Fallback: list from local file system
try {
await fs.access(this.csvDirectory);
} catch {
this.logger.warn(`CSV directory does not exist: ${this.csvDirectory}`);
return [];
}
const files = await fs.readdir(this.csvDirectory);
return files.filter(file => file.endsWith('.csv'));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to list CSV files: ${errorMessage}`);
return files.filter(f => f.endsWith('.csv'));
} catch {
return [];
}
}
/**
* Validate that CSV has all required columns
*/
private resolvePath(filePath: string): string {
return path.isAbsolute(filePath) ? filePath : path.join(this.csvDirectory, filePath);
}
private validateCsvStructure(records: CsvRow[]): void {
const requiredColumns = [
'companyName',
'origin',
'destination',
'containerType',
'minVolumeCBM',
'maxVolumeCBM',
'minWeightKG',
'maxWeightKG',
'palletCount',
'pricePerCBM',
'pricePerKG',
'basePriceUSD',
'basePriceEUR',
'currency',
'hasSurcharges',
'transitDays',
'validFrom',
'validUntil',
];
if (records.length === 0) {
throw new Error('CSV file is empty');
}
if (records.length === 0) throw new Error('CSV file is empty');
const firstRecord = records[0];
const missingColumns = requiredColumns.filter(col => !(col in firstRecord));
if (missingColumns.length > 0) {
throw new Error(`Missing required columns: ${missingColumns.join(', ')}`);
const missing = REQUIRED_COLUMNS.filter(col => !(col in firstRecord));
if (missing.length > 0) {
throw new Error(`Missing required columns: ${missing.join(', ')}`);
}
}
/**
* Map CSV row to CsvRate domain entity
*/
private mapToCsvRate(
record: CsvRow,
companyEmail: string,
companyNameOverride?: string
): CsvRate {
// Parse surcharges
const surcharges = this.parseSurcharges(record);
private mapToCsvRate(r: CsvRow, companyEmail: string, companyNameOverride?: string): 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();
// Create DateRange
const validFrom = new Date(record.validFrom);
const validUntil = new Date(record.validUntil);
const freight: FreightPricing = {
freightCurrency: r.freightCurrency.toUpperCase(),
freightRatePerCBM: parseFloat(r.freightRatePerCBM) || 0,
freightMinimum: parseFloat(r.freightMinimum) || 0,
};
const fob: FobCharges = {
fobCurrency: r.fobCurrency.toUpperCase(),
fobDocumentation: parseInt(r.fobDocumentation, 10) || 0,
fobISPS: parseInt(r.fobISPS, 10) || 0,
fobHandling: parseInt(r.fobHandling, 10) || 0,
fobHandlingUnit: (r.fobHandlingUnit?.toUpperCase() === 'W' ? 'W' : 'UP') as HandlingUnit,
fobHandlingMinimum: parseInt(r.fobHandlingMinimum, 10) || 0,
fobSolas: parseInt(r.fobSolas, 10) || 0,
fobCustoms: parseInt(r.fobCustoms, 10) || 0,
fobAMS_ACI: parseFloat(r.fobAMS_ACI) || 0,
fobISF5: parseFloat(r.fobISF5) || 0,
fobDGAdmin: parseInt(r.fobDGAdmin, 10) || 0,
};
const dgSurcharge: DgSurchargeInfo = {
dgSurchargeCurrency: (r.dgSurchargeCurrency || r.fobCurrency).toUpperCase(),
dgSurchargeRate: parseDgValue(r.dgSurchargeRate),
dgSurchargeUnit: (['UP', 'LS', '%'].includes(r.dgSurchargeUnit?.toUpperCase())
? r.dgSurchargeUnit.toUpperCase()
: 'LS') as 'UP' | 'LS' | '%',
dgSurchargeMin: parseDgValue(r.dgSurchargeMin),
};
const validFrom = new Date(r.validFrom);
const validUntil = new Date(r.validUntil);
const validity = DateRange.create(validFrom, validUntil, true);
// Use override company name if provided, otherwise use the one from CSV
const companyName = companyNameOverride || record.companyName.trim();
const frequency = parseFrequency(r.frequency);
// Create CsvRate
return new CsvRate(
companyName,
companyEmail,
PortCode.create(record.origin),
PortCode.create(record.destination),
ContainerType.create(record.containerType),
{
minCBM: parseFloat(record.minVolumeCBM),
maxCBM: parseFloat(record.maxVolumeCBM),
},
{
minKG: parseFloat(record.minWeightKG),
maxKG: parseFloat(record.maxWeightKG),
},
parseInt(record.palletCount, 10),
{
pricePerCBM: parseFloat(record.pricePerCBM),
pricePerKG: parseFloat(record.pricePerKG),
basePriceUSD: Money.create(parseFloat(record.basePriceUSD), 'USD'),
basePriceEUR: Money.create(parseFloat(record.basePriceEUR), 'EUR'),
},
record.currency.toUpperCase(),
new SurchargeCollection(surcharges),
parseInt(record.transitDays, 10),
email,
r.originCFS.trim(),
PortCode.create(r.originCode.trim()),
r.portOfLoading.trim(),
r.routing.trim(),
r.destinationCFS.trim(),
PortCode.create(r.destinationCode.trim()),
r.destinationCountry.trim(),
ContainerType.create(r.containerType.trim()),
freight,
fob,
dgSurcharge,
r.remarks?.trim() || '',
frequency,
parseInt(r.transitDays, 10),
validity
);
}
}
/**
* Parse surcharges from CSV row
*/
private parseSurcharges(record: CsvRow): Surcharge[] {
const hasSurcharges = record.hasSurcharges.toLowerCase() === 'true';
function parseDgValue(raw: string): DgSurchargeValue {
if (!raw || raw.trim() === '') return 0;
const upper = raw.trim().toUpperCase();
if (upper === 'ON REQUEST') return 'ON REQUEST';
if (upper === 'NOT ACCEPTED') return 'NOT ACCEPTED';
const num = parseFloat(raw);
return isNaN(num) ? 0 : num;
}
if (!hasSurcharges) {
return [];
}
const surcharges: Surcharge[] = [];
const currency = record.currency.toUpperCase();
// BAF (Bunker Adjustment Factor)
if (record.surchargeBAF && parseFloat(record.surchargeBAF) > 0) {
surcharges.push(
new Surcharge(
SurchargeType.BAF,
Money.create(parseFloat(record.surchargeBAF), currency),
'Bunker Adjustment Factor'
)
);
}
// CAF (Currency Adjustment Factor)
if (record.surchargeCAF && parseFloat(record.surchargeCAF) > 0) {
surcharges.push(
new Surcharge(
SurchargeType.CAF,
Money.create(parseFloat(record.surchargeCAF), currency),
'Currency Adjustment Factor'
)
);
}
return surcharges;
function parseFrequency(raw: string): FrequencyType {
switch (raw?.trim()) {
case 'Weekly':
return 'Weekly';
case 'Bi-Weekly':
return 'Bi-Weekly';
case 'Bi-Monthly':
return 'Bi-Monthly';
case 'Monthly':
return 'Monthly';
default:
return 'Weekly';
}
}

View File

@ -73,7 +73,9 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
this.buildTransporter(ip, host);
return;
} catch (err: any) {
this.logger.warn(`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`);
this.logger.warn(
`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`
);
}
}
@ -87,9 +89,9 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
private resolveViaDoH(hostname: string): Promise<string> {
return new Promise((resolve, reject) => {
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=A`;
const req = https.get(url, { headers: { Accept: 'application/dns-json' } }, (res) => {
const req = https.get(url, { headers: { Accept: 'application/dns-json' } }, res => {
let raw = '';
res.on('data', (chunk) => (raw += chunk));
res.on('data', chunk => (raw += chunk));
res.on('end', () => {
try {
const json = JSON.parse(raw);
@ -136,7 +138,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
`Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}`
);
this.transporter.verify((error) => {
this.transporter.verify(error => {
if (error) {
this.logger.error(`❌ SMTP connection FAILED: ${error.message}`);
} else {
@ -148,8 +150,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
async send(options: EmailOptions): Promise<void> {
try {
const from =
options.from ??
this.configService.get<string>('SMTP_FROM', EMAIL_SENDERS.NOREPLY);
options.from ?? this.configService.get<string>('SMTP_FROM', EMAIL_SENDERS.NOREPLY);
// Génère automatiquement la version plain text si absente (améliore le score anti-spam)
const text = options.text ?? (options.html ? htmlToPlainText(options.html) : undefined);

View File

@ -0,0 +1,19 @@
/**
* UserPreferenceResolver
*
* nestjs-i18n resolver that reads the authenticated user's preferredLanguage
* from the request (populated by JwtAuthGuard). Highest priority in the chain.
*/
import { Injectable, ExecutionContext } from '@nestjs/common';
import { I18nResolver } from 'nestjs-i18n';
import { isLocale } from '@domain/value-objects/locale.vo';
@Injectable()
export class UserPreferenceResolver implements I18nResolver {
resolve(context: ExecutionContext): string | undefined {
const request = context.switchToHttp().getRequest();
const preferred = request?.user?.preferredLanguage;
return isLocale(preferred) ? preferred : undefined;
}
}

View File

@ -75,11 +75,24 @@ export class CsvBookingOrmEntity {
@Column({
name: 'status',
type: 'enum',
enum: ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
enum: [
'PENDING_PAYMENT',
'PENDING_BANK_TRANSFER',
'PENDING',
'ACCEPTED',
'REJECTED',
'CANCELLED',
],
default: 'PENDING_PAYMENT',
})
@Index()
status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
status:
| 'PENDING_PAYMENT'
| 'PENDING_BANK_TRANSFER'
| 'PENDING'
| 'ACCEPTED'
| 'REJECTED'
| 'CANCELLED';
@Column({ name: 'documents', type: 'jsonb' })
documents: Array<{

View File

@ -74,6 +74,9 @@ export class OrganizationOrmEntity {
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'default_language', type: 'varchar', length: 2, default: 'fr' })
defaultLanguage: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@ -1,10 +1,4 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
@Entity('password_reset_tokens')
export class PasswordResetTokenOrmEntity {

View File

@ -62,6 +62,9 @@ export class UserOrmEntity {
@Column({ name: 'last_login_at', type: 'timestamp', nullable: true })
lastLoginAt: Date | null;
@Column({ name: 'preferred_language', type: 'varchar', length: 2, default: 'fr' })
preferredLanguage: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@ -5,6 +5,7 @@
*/
import { Organization, OrganizationProps } from '@domain/entities/organization.entity';
import { toLocale } from '@domain/value-objects/locale.vo';
import { OrganizationOrmEntity } from '../entities/organization.orm-entity';
export class OrganizationOrmMapper {
@ -34,6 +35,7 @@ export class OrganizationOrmMapper {
orm.siretVerified = props.siretVerified;
orm.statusBadge = props.statusBadge;
orm.isActive = props.isActive;
orm.defaultLanguage = props.defaultLanguage;
orm.createdAt = props.createdAt;
orm.updatedAt = props.updatedAt;
@ -66,6 +68,7 @@ export class OrganizationOrmMapper {
siretVerified: orm.siretVerified ?? false,
statusBadge: (orm.statusBadge as 'none' | 'silver' | 'gold' | 'platinium') || 'none',
isActive: orm.isActive,
defaultLanguage: toLocale(orm.defaultLanguage),
createdAt: orm.createdAt,
updatedAt: orm.updatedAt,
};

View File

@ -5,7 +5,10 @@
*/
import { Subscription } from '@domain/entities/subscription.entity';
import { SubscriptionOrmEntity, SubscriptionPlanOrmType } from '../entities/subscription.orm-entity';
import {
SubscriptionOrmEntity,
SubscriptionPlanOrmType,
} from '../entities/subscription.orm-entity';
/** Maps canonical domain plan names back to the values stored in the DB. */
const DOMAIN_TO_ORM_PLAN: Record<string, SubscriptionPlanOrmType> = {

View File

@ -5,6 +5,7 @@
*/
import { User, UserProps } from '@domain/entities/user.entity';
import { toLocale } from '@domain/value-objects/locale.vo';
import { UserOrmEntity } from '../entities/user.orm-entity';
export class UserOrmMapper {
@ -27,6 +28,7 @@ export class UserOrmMapper {
orm.isEmailVerified = props.isEmailVerified;
orm.isActive = props.isActive;
orm.lastLoginAt = props.lastLoginAt || null;
orm.preferredLanguage = props.preferredLanguage;
orm.createdAt = props.createdAt;
orm.updatedAt = props.updatedAt;
@ -50,6 +52,7 @@ export class UserOrmMapper {
isEmailVerified: orm.isEmailVerified,
isActive: orm.isActive,
lastLoginAt: orm.lastLoginAt || undefined,
preferredLanguage: toLocale(orm.preferredLanguage),
createdAt: orm.createdAt,
updatedAt: orm.updatedAt,
};

View File

@ -38,15 +38,9 @@ export class CreateApiKeysTable1741000000001 implements MigrationInterface {
await queryRunner.query(
`CREATE INDEX "idx_api_keys_organization_id" ON "api_keys" ("organization_id")`
);
await queryRunner.query(
`CREATE INDEX "idx_api_keys_user_id" ON "api_keys" ("user_id")`
);
await queryRunner.query(
`CREATE INDEX "idx_api_keys_is_active" ON "api_keys" ("is_active")`
);
await queryRunner.query(
`CREATE INDEX "idx_api_keys_key_hash" ON "api_keys" ("key_hash")`
);
await queryRunner.query(`CREATE INDEX "idx_api_keys_user_id" ON "api_keys" ("user_id")`);
await queryRunner.query(`CREATE INDEX "idx_api_keys_is_active" ON "api_keys" ("is_active")`);
await queryRunner.query(`CREATE INDEX "idx_api_keys_key_hash" ON "api_keys" ("key_hash")`);
await queryRunner.query(
`COMMENT ON TABLE "api_keys" IS 'API keys for programmatic access — GOLD and PLATINIUM plans only'`

View File

@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPreferredLanguage1745000000000 implements MigrationInterface {
name = 'AddPreferredLanguage1745000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "users"
ADD COLUMN "preferred_language" VARCHAR(2) NOT NULL DEFAULT 'fr'
`);
await queryRunner.query(`
ALTER TABLE "organizations"
ADD COLUMN "default_language" VARCHAR(2) NOT NULL DEFAULT 'fr'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "organizations" DROP COLUMN "default_language"
`);
await queryRunner.query(`
ALTER TABLE "users" DROP COLUMN "preferred_language"
`);
}
}

View File

@ -1,12 +1,14 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, VersioningType } from '@nestjs/common';
import { VersioningType } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { I18nService, I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n';
import helmet from 'helmet';
import compression from 'compression';
import { AppModule } from './app.module';
import { Logger } from 'nestjs-pino';
import { helmetConfig, corsConfig } from './infrastructure/security/security.config';
import { DomainExceptionFilter } from './application/filters/domain-exception.filter';
import type { Request, Response, NextFunction } from 'express';
async function bootstrap() {
@ -42,9 +44,9 @@ async function bootstrap() {
type: VersioningType.URI,
});
// Global validation pipe
// Global validation pipe — i18n-aware (messages translated to caller locale)
app.useGlobalPipes(
new ValidationPipe({
new I18nValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
@ -54,6 +56,15 @@ async function bootstrap() {
})
);
// Global exception filters — each filter declares its target via @Catch(),
// so they don't overlap: DomainExceptionFilter handles DomainException,
// I18nValidationExceptionFilter handles class-validator errors.
const i18nService = app.get(I18nService) as I18nService<Record<string, unknown>>;
app.useGlobalFilters(
new DomainExceptionFilter(i18nService),
new I18nValidationExceptionFilter({ detailedErrors: false })
);
// ─── Swagger documentation ────────────────────────────────────────────────
const swaggerUser = configService.get<string>('SWAGGER_USERNAME');
const swaggerPass = configService.get<string>('SWAGGER_PASSWORD');

View File

@ -1,7 +1,8 @@
'use client';
import { useRef } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { motion, useInView } from 'framer-motion';
import {
Ship,
@ -13,10 +14,42 @@ import {
Linkedin,
Calendar,
ArrowRight,
type LucideIcon,
} from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout';
type ValueKey = 'excellence' | 'transparency' | 'collaboration' | 'innovation';
type TeamKey = 'ceo' | 'cto' | 'coo' | 'vpSales' | 'vpEng' | 'vpProduct';
type TimelineKey = '2023' | '2024' | '2025' | '2026';
type StatKey = 'clients' | 'carriers' | 'countries' | 'bookings';
const VALUES: { key: ValueKey; icon: LucideIcon; color: string }[] = [
{ key: 'excellence', icon: Target, color: 'from-blue-500 to-cyan-500' },
{ key: 'transparency', icon: Heart, color: 'from-pink-500 to-rose-500' },
{ key: 'collaboration', icon: Users, color: 'from-purple-500 to-indigo-500' },
{ key: 'innovation', icon: TrendingUp, color: 'from-orange-500 to-amber-500' },
];
const TEAM: { key: TeamKey; name: string; linkedin: string }[] = [
{ key: 'ceo', name: 'Jean-Pierre Durand', linkedin: '#' },
{ key: 'cto', name: 'Marie Lefebvre', linkedin: '#' },
{ key: 'coo', name: 'Thomas Martin', linkedin: '#' },
{ key: 'vpSales', name: 'Sophie Bernard', linkedin: '#' },
{ key: 'vpEng', name: 'Alexandre Petit', linkedin: '#' },
{ key: 'vpProduct', name: 'Claire Moreau', linkedin: '#' },
];
const TIMELINE_YEARS: TimelineKey[] = ['2023', '2024', '2025', '2026'];
const STATS: { key: StatKey; value: string }[] = [
{ key: 'clients', value: '500+' },
{ key: 'carriers', value: '50+' },
{ key: 'countries', value: '15' },
{ key: 'bookings', value: '100K+' },
];
export default function AboutPage() {
const t = useTranslations('marketing.about');
const heroRef = useRef(null);
const missionRef = useRef(null);
const valuesRef = useRef(null);
@ -31,117 +64,6 @@ export default function AboutPage() {
const isTimelineInView = useInView(timelineRef, { once: true });
const isStatsInView = useInView(statsRef, { once: true });
const values = [
{
icon: Target,
title: 'Excellence',
description:
'Nous visons l\'excellence dans chaque aspect de notre plateforme, en offrant une expérience utilisateur de premier ordre.',
color: 'from-blue-500 to-cyan-500',
},
{
icon: Heart,
title: 'Transparence',
description:
'Nous croyons en une communication ouverte et honnête avec nos clients, partenaires et employés.',
color: 'from-pink-500 to-rose-500',
},
{
icon: Users,
title: 'Collaboration',
description:
'Le succès se construit ensemble. Nous travaillons main dans la main avec nos clients pour atteindre leurs objectifs.',
color: 'from-purple-500 to-indigo-500',
},
{
icon: TrendingUp,
title: 'Innovation',
description:
'Nous repoussons constamment les limites de la technologie pour révolutionner le fret maritime.',
color: 'from-orange-500 to-amber-500',
},
];
const team = [
{
name: 'Jean-Pierre Durand',
role: 'CEO & Co-fondateur',
bio: 'Ex-directeur chez Maersk, 20 ans d\'expérience dans le shipping',
image: '/assets/images/team/ceo.jpg',
linkedin: '#',
},
{
name: 'Marie Lefebvre',
role: 'CTO & Co-fondatrice',
bio: 'Ex-Google, experte en plateformes B2B et systèmes distribués',
image: '/assets/images/team/cto.jpg',
linkedin: '#',
},
{
name: 'Thomas Martin',
role: 'COO',
bio: 'Ex-CMA CGM, spécialiste des opérations maritimes internationales',
image: '/assets/images/team/coo.jpg',
linkedin: '#',
},
{
name: 'Sophie Bernard',
role: 'VP Sales',
bio: '15 ans d\'expérience commerciale dans le secteur logistique',
image: '/assets/images/team/vp-sales.jpg',
linkedin: '#',
},
{
name: 'Alexandre Petit',
role: 'VP Engineering',
bio: 'Ex-Uber Freight, expert en systèmes de réservation temps réel',
image: '/assets/images/team/vp-eng.jpg',
linkedin: '#',
},
{
name: 'Claire Moreau',
role: 'VP Product',
bio: 'Ex-Flexport, passionnée par l\'UX et l\'innovation produit',
image: '/assets/images/team/vp-product.jpg',
linkedin: '#',
},
];
const timeline = [
{
year: '2021',
title: 'Fondation',
description: 'Création de Xpeditis avec une vision claire : simplifier le fret maritime pour tous.',
},
{
year: '2022',
title: 'Première version',
description: 'Lancement de la plateforme beta avec 10 compagnies maritimes partenaires.',
},
{
year: '2023',
title: 'Série A',
description: 'Levée de fonds de 15M€ pour accélérer notre expansion européenne.',
},
{
year: '2024',
title: 'Expansion',
description: '50+ compagnies maritimes, présence dans 15 pays européens.',
},
{
year: '2025',
title: 'Leader européen',
description: 'Plateforme #1 du fret maritime B2B en Europe avec 500+ clients actifs.',
},
];
const stats = [
{ value: '500+', label: 'Clients actifs' },
{ value: '50+', label: 'Compagnies maritimes' },
{ value: '15', label: 'Pays couverts' },
{ value: '100K+', label: 'Réservations/an' },
];
const containerVariants = {
hidden: { opacity: 0, y: 50 },
visible: {
@ -188,21 +110,19 @@ export default function AboutPage() {
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
>
<Ship className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">Notre histoire</span>
<span className="text-white/90 text-sm font-medium">{t('badge')}</span>
</motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
Révolutionner le fret maritime,
{t('title1')}
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
une réservation à la fois
{t('title2')}
</span>
</h1>
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
Fondée en 2021, Xpeditis est née d'une vision simple : rendre le fret maritime aussi simple
qu'une réservation de vol. Nous connectons les transitaires du monde entier avec les plus
grandes compagnies maritimes.
{t('intro')}
</p>
</motion.div>
</div>
@ -234,11 +154,9 @@ export default function AboutPage() {
<div className="w-16 h-16 bg-brand-turquoise rounded-2xl flex items-center justify-center mb-6">
<Target className="w-8 h-8 text-white" />
</div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">Notre Mission</h2>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('mission.title')}</h2>
<p className="text-gray-600 text-lg leading-relaxed">
Démocratiser l'accès au fret maritime en offrant une plateforme technologique de pointe
qui simplifie la recherche, la comparaison et la réservation de transport maritime pour
tous les professionnels de la logistique.
{t('mission.body')}
</p>
</motion.div>
@ -249,11 +167,9 @@ export default function AboutPage() {
<div className="w-16 h-16 bg-brand-green rounded-2xl flex items-center justify-center mb-6">
<Eye className="w-8 h-8 text-white" />
</div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">Notre Vision</h2>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('vision.title')}</h2>
<p className="text-gray-600 text-lg leading-relaxed">
Devenir la référence mondiale du fret maritime digital, en connectant chaque transitaire
à chaque compagnie maritime, partout dans le monde, avec la transparence et l'efficacité
que mérite le commerce international.
{t('vision.body')}
</p>
</motion.div>
</motion.div>
@ -269,9 +185,9 @@ export default function AboutPage() {
className="max-w-7xl mx-auto px-6 lg:px-8"
>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{stats.map((stat, index) => (
{STATS.map((stat, index) => (
<motion.div
key={index}
key={stat.key}
variants={itemVariants}
className="text-center"
>
@ -283,7 +199,7 @@ export default function AboutPage() {
>
{stat.value}
</motion.div>
<div className="text-gray-600 font-medium">{stat.label}</div>
<div className="text-gray-600 font-medium">{t(`stats.${stat.key}`)}</div>
</motion.div>
))}
</div>
@ -299,9 +215,9 @@ export default function AboutPage() {
transition={{ duration: 0.8 }}
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">Nos Valeurs</h2>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('valuesTitle')}</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Les principes qui guident chacune de nos décisions
{t('valuesSubtitle')}
</p>
</motion.div>
@ -311,11 +227,11 @@ export default function AboutPage() {
animate={isValuesInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
>
{values.map((value, index) => {
{VALUES.map((value) => {
const IconComponent = value.icon;
return (
<motion.div
key={index}
key={value.key}
variants={itemVariants}
whileHover={{ y: -10 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
@ -325,8 +241,8 @@ export default function AboutPage() {
>
<IconComponent className="w-7 h-7 text-white" />
</div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{value.title}</h3>
<p className="text-gray-600">{value.description}</p>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`values.${value.key}.title`)}</h3>
<p className="text-gray-600">{t(`values.${value.key}.description`)}</p>
</motion.div>
);
})}
@ -343,14 +259,13 @@ export default function AboutPage() {
transition={{ duration: 0.8 }}
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">Notre Parcours</h2>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('timelineTitle')}</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
De la startup au leader européen du fret maritime digital
{t('timelineSubtitle')}
</p>
</motion.div>
<div className="relative">
{/* Timeline vertical rail + animated fill */}
<div className="hidden lg:block absolute left-1/2 transform -translate-x-1/2 w-0.5 h-full bg-brand-turquoise/15 overflow-hidden">
<motion.div
initial={{ scaleY: 0 }}
@ -362,9 +277,9 @@ export default function AboutPage() {
</div>
<div className="space-y-12">
{timeline.map((item, index) => (
{TIMELINE_YEARS.map((year, index) => (
<motion.div
key={index}
key={year}
initial={{ opacity: 0, x: index % 2 === 0 ? -64 : 64 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, amount: 0.4 }}
@ -375,14 +290,13 @@ export default function AboutPage() {
<div className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block hover:shadow-xl transition-shadow">
<div className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}>
<Calendar className="w-5 h-5 text-brand-turquoise" />
<span className="text-2xl font-bold text-brand-turquoise">{item.year}</span>
<span className="text-2xl font-bold text-brand-turquoise">{year}</span>
</div>
<h3 className="text-xl font-bold text-brand-navy mb-2">{item.title}</h3>
<p className="text-gray-600">{item.description}</p>
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`timeline.${year}.title`)}</h3>
<p className="text-gray-600">{t(`timeline.${year}.description`)}</p>
</div>
</div>
{/* Animated center dot */}
<div className="hidden lg:flex items-center justify-center mx-4 flex-shrink-0">
<motion.div
initial={{ scale: 0 }}
@ -402,7 +316,7 @@ export default function AboutPage() {
</section>
{/* Team Section */}
<section ref={teamRef} className="py-20">
<section ref={teamRef} className="py-20" style={{ display: 'none' }}>
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
@ -410,9 +324,9 @@ export default function AboutPage() {
transition={{ duration: 0.8 }}
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">Notre Équipe</h2>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('teamTitle')}</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Des experts passionnés par le maritime et la technologie
{t('teamSubtitle')}
</p>
</motion.div>
@ -422,9 +336,9 @@ export default function AboutPage() {
animate={isTeamInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
{team.map((member, index) => (
{TEAM.map((member) => (
<motion.div
key={index}
key={member.key}
variants={itemVariants}
whileHover={{ y: -10 }}
className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden group"
@ -444,8 +358,8 @@ export default function AboutPage() {
</div>
<div className="p-6">
<h3 className="text-xl font-bold text-brand-navy mb-1">{member.name}</h3>
<p className="text-brand-turquoise font-medium mb-3">{member.role}</p>
<p className="text-gray-600 text-sm">{member.bio}</p>
<p className="text-brand-turquoise font-medium mb-3">{t(`team.${member.key}.role`)}</p>
<p className="text-gray-600 text-sm">{t(`team.${member.key}.bio`)}</p>
</div>
</motion.div>
))}
@ -463,25 +377,24 @@ export default function AboutPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
Rejoignez l'aventure Xpeditis
{t('cta.title')}
</h2>
<p className="text-xl text-white/80 mb-10">
Que vous soyez transitaire à la recherche d'une solution moderne ou talent souhaitant
rejoindre une équipe passionnée, nous avons hâte de vous rencontrer.
{t('cta.body')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
<Link
href="/register"
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl font-semibold text-lg flex items-center space-x-2"
>
<span>Créer un compte</span>
<span>{t('cta.createAccount')}</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
<Link
href="/careers"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
>
Voir les offres d'emploi
{t('cta.viewCareers')}
</Link>
</div>
</motion.div>

View File

@ -1,7 +1,8 @@
'use client';
import { useState, useRef } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { motion, useInView } from 'framer-motion';
import {
Ship,
@ -15,11 +16,34 @@ import {
Globe,
FileText,
Anchor,
type LucideIcon,
} from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout';
type CategoryKey = 'all' | 'industry' | 'technology' | 'guides' | 'news';
type ArticleKey = 'incoterms' | 'costs' | 'ports' | 'funding' | 'green' | 'api' | 'documents';
const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [
{ key: 'all', icon: BookOpen },
{ key: 'industry', icon: Ship },
{ key: 'technology', icon: TrendingUp },
{ key: 'guides', icon: FileText },
{ key: 'news', icon: Globe },
];
const ARTICLES: { id: number; key: ArticleKey; category: Exclude<CategoryKey, 'all'>; tags: string[] }[] = [
{ id: 2, key: 'incoterms', category: 'guides', tags: ['Incoterms', 'Guide', 'Commerce'] },
{ id: 3, key: 'costs', category: 'guides', tags: ['Optimisation', 'Costs', 'Strategy'] },
{ id: 4, key: 'ports', category: 'industry', tags: ['Ports', 'Europe', 'Stats'] },
{ id: 5, key: 'funding', category: 'news', tags: ['Funding', 'Growth', 'Xpeditis'] },
{ id: 6, key: 'green', category: 'industry', tags: ['Environment', 'Decarbonization', 'Sustainability'] },
{ id: 7, key: 'api', category: 'technology', tags: ['API', 'Integration', 'Technical'] },
{ id: 8, key: 'documents', category: 'guides', tags: ['Documents', 'Export', 'Customs'] },
];
export default function BlogPage() {
const [selectedCategory, setSelectedCategory] = useState('all');
const t = useTranslations('marketing.blog');
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all');
const [searchQuery, setSearchQuery] = useState('');
const heroRef = useRef(null);
@ -30,121 +54,14 @@ export default function BlogPage() {
const isArticlesInView = useInView(articlesRef, { once: true });
const isCategoriesInView = useInView(categoriesRef, { once: true });
const categories = [
{ value: 'all', label: 'Tous les articles', icon: BookOpen },
{ value: 'industry', label: 'Industrie maritime', icon: Ship },
{ value: 'technology', label: 'Technologie', icon: TrendingUp },
{ value: 'guides', label: 'Guides pratiques', icon: FileText },
{ value: 'news', label: 'Actualités', icon: Globe },
];
const featuredArticle = {
id: 1,
title: 'L\'avenir du fret maritime : comment l\'IA transforme la logistique',
excerpt:
'Découvrez comment l\'intelligence artificielle révolutionne la gestion des expéditions maritimes et optimise les chaînes d\'approvisionnement mondiales.',
category: 'technology',
author: 'Marie Lefebvre',
authorRole: 'CTO',
date: '15 janvier 2025',
readTime: '8 min',
image: '/assets/images/blog/featured.jpg',
tags: ['IA', 'Innovation', 'Logistique'],
};
const articles = [
{
id: 2,
title: 'Guide complet des Incoterms 2020 pour le transport maritime',
excerpt:
'Tout ce que vous devez savoir sur les règles Incoterms et leur application dans le fret maritime international.',
category: 'guides',
author: 'Thomas Martin',
date: '10 janvier 2025',
readTime: '12 min',
image: '/assets/images/blog/incoterms.jpg',
tags: ['Incoterms', 'Guide', 'Commerce international'],
},
{
id: 3,
title: 'Comment optimiser vos coûts de transport maritime en 2025',
excerpt:
'Stratégies et conseils pratiques pour réduire vos dépenses logistiques sans compromettre la qualité de service.',
category: 'guides',
author: 'Sophie Bernard',
date: '8 janvier 2025',
readTime: '6 min',
image: '/assets/images/blog/costs.jpg',
tags: ['Optimisation', 'Coûts', 'Stratégie'],
},
{
id: 4,
title: 'Les plus grands ports européens : classement 2025',
excerpt:
'Analyse des performances des principaux ports européens et tendances du trafic conteneurisé.',
category: 'industry',
author: 'Jean-Pierre Durand',
date: '5 janvier 2025',
readTime: '10 min',
image: '/assets/images/blog/ports.jpg',
tags: ['Ports', 'Europe', 'Statistiques'],
},
{
id: 5,
title: 'Xpeditis lève 15M€ pour accélérer son expansion',
excerpt:
'Notre série A nous permet de renforcer notre équipe et d\'étendre notre présence en Europe.',
category: 'news',
author: 'Jean-Pierre Durand',
date: '3 janvier 2025',
readTime: '4 min',
image: '/assets/images/blog/funding.jpg',
tags: ['Financement', 'Croissance', 'Xpeditis'],
},
{
id: 6,
title: 'Décarbonation du transport maritime : où en sommes-nous ?',
excerpt:
'État des lieux des initiatives environnementales dans le secteur maritime et perspectives pour 2030.',
category: 'industry',
author: 'Claire Moreau',
date: '28 décembre 2024',
readTime: '9 min',
image: '/assets/images/blog/green.jpg',
tags: ['Environnement', 'Décarbonation', 'Durabilité'],
},
{
id: 7,
title: 'APIs et intégrations : comment connecter votre TMS à Xpeditis',
excerpt:
'Guide technique pour intégrer notre plateforme avec vos systèmes de gestion existants.',
category: 'technology',
author: 'Alexandre Petit',
date: '22 décembre 2024',
readTime: '15 min',
image: '/assets/images/blog/api.jpg',
tags: ['API', 'Intégration', 'Technique'],
},
{
id: 8,
title: 'Les documents essentiels pour l\'export maritime',
excerpt:
'Check-list complète des documents requis pour vos expéditions maritimes internationales.',
category: 'guides',
author: 'Thomas Martin',
date: '18 décembre 2024',
readTime: '7 min',
image: '/assets/images/blog/documents.jpg',
tags: ['Documents', 'Export', 'Douane'],
},
];
const filteredArticles = articles.filter((article) => {
const filteredArticles = ARTICLES.filter((article) => {
const categoryMatch = selectedCategory === 'all' || article.category === selectedCategory;
const title = t(`articles.${article.key}.title` as any);
const excerpt = t(`articles.${article.key}.excerpt` as any);
const searchMatch =
searchQuery === '' ||
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
article.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
excerpt.toLowerCase().includes(searchQuery.toLowerCase());
return categoryMatch && searchMatch;
});
@ -194,20 +111,19 @@ export default function BlogPage() {
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
>
<BookOpen className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">Blog Xpeditis</span>
<span className="text-white/90 text-sm font-medium">{t('badge')}</span>
</motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
Actualités & Insights
{t('title1')}
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
du fret maritime
{t('title2')}
</span>
</h1>
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
Restez informé des dernières tendances du transport maritime, découvrez nos guides
pratiques et suivez l'actualité de Xpeditis.
{t('intro')}
</p>
{/* Search Bar */}
@ -221,7 +137,7 @@ export default function BlogPage() {
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Rechercher un article..."
placeholder={t('searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-12 pr-4 py-4 rounded-xl bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none"
@ -251,13 +167,13 @@ export default function BlogPage() {
className="max-w-7xl mx-auto px-6 lg:px-8"
>
<div className="flex flex-wrap items-center justify-center gap-4">
{categories.map((category) => {
{CATEGORIES.map((category) => {
const IconComponent = category.icon;
const isActive = selectedCategory === category.value;
const isActive = selectedCategory === category.key;
return (
<button
key={category.value}
onClick={() => setSelectedCategory(category.value)}
key={category.key}
onClick={() => setSelectedCategory(category.key)}
className={`flex items-center space-x-2 px-4 py-2 rounded-full transition-all ${
isActive
? 'bg-brand-turquoise text-white'
@ -265,7 +181,7 @@ export default function BlogPage() {
}`}
>
<IconComponent className="w-4 h-4" />
<span className="font-medium">{category.label}</span>
<span className="font-medium">{t(`categories.${category.key}`)}</span>
</button>
);
})}
@ -282,7 +198,7 @@ export default function BlogPage() {
viewport={{ once: true }}
transition={{ duration: 0.8 }}
>
<Link href={`/blog/${featuredArticle.id}`}>
<Link href="/blog/1">
<div className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden group cursor-pointer">
<div className="absolute inset-0 bg-gradient-to-r from-brand-navy via-brand-navy/80 to-transparent z-10" />
<div className="absolute right-0 top-0 bottom-0 w-1/2 bg-brand-turquoise/20 flex items-center justify-center">
@ -293,36 +209,36 @@ export default function BlogPage() {
<div className="max-w-2xl">
<div className="flex items-center space-x-2 mb-4">
<span className="px-3 py-1 bg-brand-turquoise text-white text-sm font-medium rounded-full">
À la une
{t('featuredBadge')}
</span>
<span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full">
{categories.find((c) => c.value === featuredArticle.category)?.label}
{t('categories.technology')}
</span>
</div>
<h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors">
{featuredArticle.title}
{t('featured.title')}
</h2>
<p className="text-lg text-white/80 mb-6">{featuredArticle.excerpt}</p>
<p className="text-lg text-white/80 mb-6">{t('featured.excerpt')}</p>
<div className="flex items-center space-x-6 text-white/60 text-sm">
<div className="flex items-center space-x-2">
<User className="w-4 h-4" />
<span>{featuredArticle.author}</span>
<span>{t('featured.author')}</span>
</div>
<div className="flex items-center space-x-2">
<Calendar className="w-4 h-4" />
<span>{featuredArticle.date}</span>
<span>{t('featured.date')}</span>
</div>
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>{featuredArticle.readTime}</span>
<span>{t('featured.readTime')}</span>
</div>
</div>
<div className="flex items-center space-x-2 mt-6 text-brand-turquoise font-medium opacity-0 group-hover:opacity-100 transition-opacity">
<span>Lire l'article</span>
<span>{t('readArticle')}</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</div>
</div>
@ -342,15 +258,15 @@ export default function BlogPage() {
transition={{ duration: 0.8 }}
className="flex items-center justify-between mb-12"
>
<h2 className="text-3xl font-bold text-brand-navy">Tous les articles</h2>
<span className="text-gray-500">{filteredArticles.length} articles</span>
<h2 className="text-3xl font-bold text-brand-navy">{t('allTitle')}</h2>
<span className="text-gray-500">{t('articlesCount', { count: filteredArticles.length })}</span>
</motion.div>
{filteredArticles.length === 0 ? (
<div className="text-center py-12">
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-medium text-gray-600">Aucun article trouvé</h3>
<p className="text-gray-500">Essayez de modifier vos filtres ou votre recherche</p>
<h3 className="text-xl font-medium text-gray-600">{t('noResults.title')}</h3>
<p className="text-gray-500">{t('noResults.body')}</p>
</div>
) : (
<motion.div
@ -367,17 +283,19 @@ export default function BlogPage() {
<Ship className="w-16 h-16 text-brand-navy/20" />
<div className="absolute top-4 left-4">
<span className="px-3 py-1 bg-white/90 text-brand-navy text-xs font-medium rounded-full">
{categories.find((c) => c.value === article.category)?.label}
{t(`categories.${article.category}`)}
</span>
</div>
</div>
<div className="p-6 flex-1 flex flex-col">
<h3 className="text-xl font-bold text-brand-navy mb-3 group-hover:text-brand-turquoise transition-colors line-clamp-2">
{article.title}
{t(`articles.${article.key}.title` as any)}
</h3>
<p className="text-gray-600 mb-4 line-clamp-2 flex-1">{article.excerpt}</p>
<p className="text-gray-600 mb-4 line-clamp-2 flex-1">
{t(`articles.${article.key}.excerpt` as any)}
</p>
<div className="flex flex-wrap gap-2 mb-4">
{article.tags.map((tag) => (
@ -395,13 +313,13 @@ export default function BlogPage() {
<div className="w-8 h-8 bg-brand-turquoise/10 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-brand-turquoise" />
</div>
<span>{article.author}</span>
<span>{t(`articles.${article.key}.author` as any)}</span>
</div>
<div className="flex items-center space-x-4">
<span>{article.date}</span>
<span>{t(`articles.${article.key}.date` as any)}</span>
<span className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
<span>{article.readTime}</span>
<span>{t(`articles.${article.key}.readTime` as any)}</span>
</span>
</div>
</div>
@ -423,7 +341,7 @@ export default function BlogPage() {
className="text-center mt-12"
>
<button className="px-8 py-4 bg-white border-2 border-brand-turquoise text-brand-turquoise rounded-lg hover:bg-brand-turquoise hover:text-white transition-all font-semibold">
Charger plus d'articles
{t('loadMore')}
</button>
</motion.div>
)}
@ -440,28 +358,27 @@ export default function BlogPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl font-bold text-white mb-6">
Restez informé
{t('newsletter.title')}
</h2>
<p className="text-xl text-white/80 mb-10">
Abonnez-vous à notre newsletter pour recevoir les derniers articles et actualités
du fret maritime directement dans votre boîte mail.
{t('newsletter.body')}
</p>
<form className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<input
type="email"
placeholder="votre@email.com"
placeholder={t('newsletter.emailPlaceholder')}
className="w-full sm:w-96 px-6 py-4 rounded-lg bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none"
/>
<button
type="submit"
className="w-full sm:w-auto px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-semibold flex items-center justify-center space-x-2"
>
<span>S'abonner</span>
<span>{t('newsletter.subscribe')}</span>
<ArrowRight className="w-5 h-5" />
</button>
</form>
<p className="text-white/50 text-sm mt-4">
En vous inscrivant, vous acceptez notre politique de confidentialité. Désabonnement possible à tout moment.
{t('newsletter.disclaimer')}
</p>
</motion.div>
</div>

View File

@ -8,21 +8,21 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { acceptCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings';
export default function BookingConfirmPage() {
const params = useParams();
const router = useRouter();
const token = params.token as string;
const t = useTranslations('bookingPortal.confirm');
const tCommon = useTranslations('bookingPortal.common');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [booking, setBooking] = useState<CsvBookingResponse | null>(null);
const [isAccepting, setIsAccepting] = useState(false);
const handleAccept = useCallback(async () => {
setIsAccepting(true);
setError(null);
try {
@ -33,31 +33,29 @@ export default function BookingConfirmPage() {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Une erreur est survenue lors de l\'acceptation');
setError(t('errorGeneric'));
}
} finally {
setIsLoading(false);
setIsAccepting(false);
}
}, [token]);
}, [token, t]);
useEffect(() => {
if (!token) {
setError('Token de confirmation invalide');
setError(t('tokenInvalid'));
setIsLoading(false);
return;
}
// Auto-accept the booking
handleAccept();
}, [token, handleAccept]);
}, [token, handleAccept, t]);
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Confirmation en cours...</p>
<p className="text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -84,24 +82,24 @@ export default function BookingConfirmPage() {
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Erreur de confirmation
{t('errorTitle')}
</h1>
<p className="text-gray-600">{error}</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-sm text-red-800">
<strong>Raisons possibles :</strong>
<strong>{t('errorReasonsTitle')}</strong>
</p>
<ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside">
<li>Le lien a expiré</li>
<li>La demande a déjà é acceptée ou refusée</li>
<li>Le token de confirmation est invalide</li>
<li>{t('errorReason1')}</li>
<li>{t('errorReason2')}</li>
<li>{t('errorReason3')}</li>
</ul>
</div>
<p className="text-sm text-gray-500 text-center">
Si vous pensez qu'il s'agit d'une erreur, veuillez contacter le client directement.
{t('errorContact')}
</p>
</div>
</div>
@ -133,67 +131,68 @@ export default function BookingConfirmPage() {
/>
</svg>
</div>
{/* Animated rings */}
<div className="absolute inset-0 rounded-full border-4 border-green-200 animate-ping opacity-20"></div>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-3">
Demande acceptée !
{t('successTitle')}
</h1>
<p className="text-lg text-gray-600 mb-2">
Merci d'avoir accepté cette demande de transport.
{t('successHeadline')}
</p>
<p className="text-gray-500">
Le client a é notifié par email.
{t('successBody')}
</p>
</div>
{/* Booking Summary */}
<div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Récapitulatif de la réservation
{t('summaryTitle')}
</h2>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">ID Réservation</span>
<span className="text-gray-600">{t('labels.bookingId')}</span>
<span className="font-semibold text-gray-900">{booking.bookingId}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Trajet</span>
<span className="text-gray-600">{t('labels.route')}</span>
<span className="font-semibold text-gray-900">
{booking.origin} {booking.destination}
</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Volume</span>
<span className="text-gray-600">{t('labels.volume')}</span>
<span className="font-semibold text-gray-900">{booking.volumeCBM} CBM</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Poids</span>
<span className="text-gray-600">{t('labels.weight')}</span>
<span className="font-semibold text-gray-900">{booking.weightKG} kg</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Palettes</span>
<span className="text-gray-600">{t('labels.pallets')}</span>
<span className="font-semibold text-gray-900">{booking.palletCount}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Type de conteneur</span>
<span className="text-gray-600">{t('labels.containerType')}</span>
<span className="font-semibold text-gray-900">{booking.containerType}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Temps de transit</span>
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
<span className="text-gray-600">{t('labels.transitDays')}</span>
<span className="font-semibold text-gray-900">
{t('transitDaysValue', { count: booking.transitDays })}
</span>
</div>
<div className="flex justify-between py-3">
<span className="text-gray-600 text-lg">Prix</span>
<span className="text-gray-600 text-lg">{t('labels.price')}</span>
<div className="text-right">
<div className="font-bold text-xl text-green-600">
{booking.primaryCurrency === 'USD'
@ -213,7 +212,7 @@ export default function BookingConfirmPage() {
{booking.notes && (
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm text-gray-600 mb-1">Notes :</p>
<p className="text-sm text-gray-600 mb-1">{t('labels.notes')}</p>
<p className="text-gray-800">{booking.notes}</p>
</div>
)}
@ -225,19 +224,19 @@ export default function BookingConfirmPage() {
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Prochaines étapes
{t('nextStepsTitle')}
</h3>
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
<li>Le client va finaliser les détails du conteneur</li>
<li>Vous recevrez un email avec les documents nécessaires</li>
<li>Le paiement sera traité selon vos conditions habituelles</li>
<li>{t('nextStep1')}</li>
<li>{t('nextStep2')}</li>
<li>{t('nextStep3')}</li>
</ul>
</div>
{/* Documents Section */}
{booking.documents && booking.documents.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-gray-900 mb-3">Documents fournis</h3>
<h3 className="font-semibold text-gray-900 mb-3">{t('labels.documents')}</h3>
<div className="space-y-2">
{booking.documents.map((doc, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-white rounded border border-gray-200">
@ -256,7 +255,7 @@ export default function BookingConfirmPage() {
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
Télécharger
{t('labels.download')}
</a>
</div>
))}
@ -266,7 +265,7 @@ export default function BookingConfirmPage() {
{/* Contact Info */}
<div className="text-center text-sm text-gray-500">
<p>Pour toute question, contactez-nous à</p>
<p>{tCommon('supportPrompt')}</p>
<a href="mailto:support@xpeditis.com" className="text-blue-600 hover:underline">
support@xpeditis.com
</a>

View File

@ -9,11 +9,14 @@
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { rejectCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings';
export default function BookingRejectPage() {
const params = useParams();
const token = params.token as string;
const t = useTranslations('bookingPortal.reject');
const tCommon = useTranslations('bookingPortal.common');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -25,14 +28,13 @@ export default function BookingRejectPage() {
useEffect(() => {
if (!token) {
setError('Token de refus invalide');
setError(t('tokenInvalid'));
setIsLoading(false);
return;
}
// Just validate the token exists, don't auto-reject
setIsLoading(false);
}, [token]);
}, [token, t]);
const handleReject = async () => {
if (!token) return;
@ -49,7 +51,7 @@ export default function BookingRejectPage() {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Une erreur est survenue lors du refus');
setError(t('errorGeneric'));
}
} finally {
setIsRejecting(false);
@ -61,7 +63,7 @@ export default function BookingRejectPage() {
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-gray-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement...</p>
<p className="text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -88,36 +90,34 @@ export default function BookingRejectPage() {
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Erreur de refus
{t('errorTitle')}
</h1>
<p className="text-gray-600">{error}</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-sm text-red-800">
<strong>Raisons possibles :</strong>
<strong>{t('errorReasonsTitle')}</strong>
</p>
<ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside">
<li>Le lien a expiré</li>
<li>La demande a déjà é acceptée ou refusée</li>
<li>Le token est invalide</li>
<li>{t('errorReason1')}</li>
<li>{t('errorReason2')}</li>
<li>{t('errorReason3')}</li>
</ul>
</div>
<p className="text-sm text-gray-500 text-center">
Si vous pensez qu'il s'agit d'une erreur, veuillez contacter le client directement.
{t('errorContact')}
</p>
</div>
</div>
);
}
// After successful rejection
if (hasRejected && booking) {
return (
<div className="min-h-screen bg-gradient-to-br from-red-50 via-white to-red-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-2xl w-full">
{/* Rejection Icon with Animation */}
<div className="text-center mb-8">
<div className="relative inline-block">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4 animate-scale-in">
@ -138,47 +138,46 @@ export default function BookingRejectPage() {
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-3">
Demande refusée
{t('rejectedTitle')}
</h1>
<p className="text-lg text-gray-600 mb-2">
Vous avez refusé cette demande de transport.
{t('rejectedHeadline')}
</p>
<p className="text-gray-500">
Le client a é notifié par email.
{t('rejectedBody')}
</p>
</div>
{/* Booking Summary */}
<div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Récapitulatif de la demande refusée
{t('summaryTitle')}
</h2>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">ID Réservation</span>
<span className="text-gray-600">{t('labels.bookingId')}</span>
<span className="font-semibold text-gray-900">{booking.bookingId}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Trajet</span>
<span className="text-gray-600">{t('labels.route')}</span>
<span className="font-semibold text-gray-900">
{booking.origin} {booking.destination}
</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Volume</span>
<span className="text-gray-600">{t('labels.volume')}</span>
<span className="font-semibold text-gray-900">{booking.volumeCBM} CBM</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">Poids</span>
<span className="text-gray-600">{t('labels.weight')}</span>
<span className="font-semibold text-gray-900">{booking.weightKG} kg</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">Prix proposé</span>
<span className="text-gray-600">{t('labels.proposedPrice')}</span>
<span className="font-semibold text-gray-900">
{booking.primaryCurrency === 'USD'
? `$${booking.priceUSD.toLocaleString()}`
@ -190,7 +189,7 @@ export default function BookingRejectPage() {
{reason && (
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm text-gray-600 mb-1">Raison du refus :</p>
<p className="text-sm text-gray-600 mb-1">{t('labels.rejectionReason')}</p>
<p className="text-gray-800 bg-white p-3 rounded border border-gray-200">
{reason}
</p>
@ -198,22 +197,20 @@ export default function BookingRejectPage() {
)}
</div>
{/* Info Message */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-blue-900 mb-2 flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Information
{t('infoTitle')}
</h3>
<p className="text-sm text-blue-800">
Le client pourra soumettre une nouvelle demande avec des conditions différentes si nécessaire.
{t('infoBody')}
</p>
</div>
{/* Contact Info */}
<div className="text-center text-sm text-gray-500">
<p>Pour toute question, contactez-nous à</p>
<p>{tCommon('supportPrompt')}</p>
<a href="mailto:support@xpeditis.com" className="text-blue-600 hover:underline">
support@xpeditis.com
</a>
@ -243,11 +240,9 @@ export default function BookingRejectPage() {
);
}
// Initial rejection form
return (
<div className="min-h-screen bg-gradient-to-br from-orange-50 via-white to-orange-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full">
{/* Warning Icon */}
<div className="text-center mb-6">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
@ -265,14 +260,13 @@ export default function BookingRejectPage() {
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Refuser cette demande
{t('formTitle')}
</h1>
<p className="text-gray-600">
Vous êtes sur le point de refuser cette demande de transport.
{t('formIntro')}
</p>
</div>
{/* Optional Reason Field */}
<div className="mb-6">
{!showReasonField ? (
<button
@ -280,7 +274,7 @@ export default function BookingRejectPage() {
className="w-full text-left px-4 py-3 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-gray-700">Ajouter une raison (optionnel)</span>
<span className="text-gray-700">{t('addReason')}</span>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
@ -289,20 +283,20 @@ export default function BookingRejectPage() {
) : (
<div>
<label htmlFor="reason" className="block text-sm font-medium text-gray-700 mb-2">
Raison du refus (optionnel)
{t('reasonLabel')}
</label>
<textarea
id="reason"
rows={4}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Ex: Prix trop élevé, délais trop courts, itinéraire non disponible..."
placeholder={t('reasonPlaceholder')}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent resize-none"
maxLength={500}
/>
<div className="mt-1 flex items-center justify-between">
<p className="text-xs text-gray-500">
Cette information sera communiquée au client
{t('reasonHint')}
</p>
<span className="text-xs text-gray-400">
{reason.length}/500
@ -312,14 +306,12 @@ export default function BookingRejectPage() {
)}
</div>
{/* Warning Message */}
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6">
<p className="text-sm text-orange-800">
<strong>Attention :</strong> Cette action est irréversible. Le client sera immédiatement notifié par email de votre refus.
<strong>{t('warningTitle')}</strong> {t('warningBody')}
</p>
</div>
{/* Action Buttons */}
<div className="space-y-3">
<button
onClick={handleReject}
@ -332,14 +324,14 @@ export default function BookingRejectPage() {
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refus en cours...
{t('submitting')}
</>
) : (
<>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Confirmer le refus
{t('submit')}
</>
)}
</button>
@ -348,13 +340,12 @@ export default function BookingRejectPage() {
href="mailto:support@xpeditis.com"
className="block w-full px-6 py-3 bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 font-semibold rounded-lg transition-colors text-center"
>
Contacter le support
{tCommon('contactSupport')}
</a>
</div>
{/* Help Text */}
<p className="mt-6 text-xs text-center text-gray-500">
Si vous avez des questions avant de refuser, contactez-nous par email.
{t('helpText')}
</p>
</div>
</div>

View File

@ -1,7 +1,8 @@
'use client';
import { useState, useRef } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { motion, useInView, AnimatePresence } from 'framer-motion';
import {
Briefcase,
@ -22,12 +23,63 @@ import {
LineChart,
Headphones,
Megaphone,
type LucideIcon,
} from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout';
type BenefitKey = 'health' | 'remote' | 'wellbeing' | 'learning' | 'international' | 'stockOptions';
type StatKey = 'employees' | 'nationalities' | 'offices' | 'womenTech';
type CultureKey = 'item1' | 'item2' | 'item3' | 'item4';
type JobKey = 'frontend' | 'backend' | 'pm' | 'ae' | 'csm' | 'data';
type DepartmentValue = 'all' | 'Engineering' | 'Product' | 'Sales' | 'Customer Success' | 'Data';
type LocationValue = 'all' | 'Paris' | 'Rotterdam' | 'Hambourg';
const BENEFITS: { key: BenefitKey; icon: LucideIcon }[] = [
{ key: 'health', icon: Heart },
{ key: 'remote', icon: Plane },
{ key: 'wellbeing', icon: Coffee },
{ key: 'learning', icon: GraduationCap },
{ key: 'international', icon: Users },
{ key: 'stockOptions', icon: Zap },
];
const STATS: { key: StatKey; value: string }[] = [
{ key: 'employees', value: '50+' },
{ key: 'nationalities', value: '15' },
{ key: 'offices', value: '3' },
{ key: 'womenTech', value: '40%' },
];
const CULTURE_ITEMS: CultureKey[] = ['item1', 'item2', 'item3', 'item4'];
type JobRecord = {
id: number;
key: JobKey;
department: Exclude<DepartmentValue, 'all'>;
location: Exclude<LocationValue, 'all'>;
type: string;
remote: boolean;
salary: string;
icon: LucideIcon;
};
const JOBS: JobRecord[] = [
{ id: 1, key: 'frontend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '65K - 85K €', icon: Code },
{ id: 2, key: 'backend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '55K - 75K €', icon: Code },
{ id: 3, key: 'pm', department: 'Product', location: 'Paris', type: 'CDI', remote: true, salary: '60K - 80K €', icon: LineChart },
{ id: 4, key: 'ae', department: 'Sales', location: 'Rotterdam', type: 'CDI', remote: false, salary: '50K - 70K € + variable', icon: Megaphone },
{ id: 5, key: 'csm', department: 'Customer Success', location: 'Paris', type: 'CDI', remote: true, salary: '45K - 60K €', icon: Headphones },
{ id: 6, key: 'data', department: 'Data', location: 'Hambourg', type: 'CDI', remote: true, salary: '50K - 65K €', icon: LineChart },
];
const DEPARTMENT_VALUES: DepartmentValue[] = ['all', 'Engineering', 'Product', 'Sales', 'Customer Success', 'Data'];
const LOCATION_VALUES: LocationValue[] = ['all', 'Paris', 'Rotterdam', 'Hambourg'];
const JOB_REQ_KEYS = ['req1', 'req2', 'req3', 'req4'] as const;
export default function CareersPage() {
const [selectedDepartment, setSelectedDepartment] = useState('all');
const [selectedLocation, setSelectedLocation] = useState('all');
const t = useTranslations('marketing.careers');
const [selectedDepartment, setSelectedDepartment] = useState<DepartmentValue>('all');
const [selectedLocation, setSelectedLocation] = useState<LocationValue>('all');
const [expandedJob, setExpandedJob] = useState<number | null>(null);
const heroRef = useRef(null);
@ -40,161 +92,7 @@ export default function CareersPage() {
const isJobsInView = useInView(jobsRef, { once: true });
const isCultureInView = useInView(cultureRef, { once: true });
const benefits = [
{
icon: Heart,
title: 'Mutuelle Premium',
description: 'Couverture santé complète pour vous et votre famille',
},
{
icon: Plane,
title: 'Télétravail Flexible',
description: 'Travaillez d\'où vous voulez, jusqu\'à 3 jours par semaine',
},
{
icon: Coffee,
title: 'Bien-être au Travail',
description: 'Salle de sport, fruits frais, et événements d\'équipe',
},
{
icon: GraduationCap,
title: 'Formation Continue',
description: '2 000€/an de budget formation et conférences',
},
{
icon: Users,
title: 'Équipe Internationale',
description: 'Travaillez avec des talents de 15 nationalités',
},
{
icon: Zap,
title: 'Stock Options',
description: 'Participez à la croissance de l\'entreprise',
},
];
const jobs = [
{
id: 1,
title: 'Senior Frontend Engineer',
department: 'Engineering',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '65K - 85K €',
description: 'Rejoignez notre équipe frontend pour développer la prochaine génération de notre plateforme.',
requirements: [
'5+ ans d\'expérience en développement frontend',
'Maîtrise de React, TypeScript et Next.js',
'Expérience avec les design systems',
'Capacité à mentorer des développeurs juniors',
],
icon: Code,
},
{
id: 2,
title: 'Backend Engineer (Node.js)',
department: 'Engineering',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '55K - 75K €',
description: 'Construisez des APIs scalables pour connecter les transitaires aux compagnies maritimes.',
requirements: [
'3+ ans d\'expérience en Node.js/NestJS',
'Maîtrise de PostgreSQL et Redis',
'Connaissance des architectures microservices',
'Expérience avec Docker et Kubernetes appréciée',
],
icon: Code,
},
{
id: 3,
title: 'Product Manager',
department: 'Product',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '60K - 80K €',
description: 'Définissez la vision produit et priorisez les fonctionnalités avec notre équipe.',
requirements: [
'4+ ans d\'expérience en product management B2B',
'Expérience dans la logistique ou le shipping appréciée',
'Capacité à analyser les données et définir les KPIs',
'Excellentes compétences en communication',
],
icon: LineChart,
},
{
id: 4,
title: 'Account Executive',
department: 'Sales',
location: 'Rotterdam',
type: 'CDI',
remote: false,
salary: '50K - 70K € + variable',
description: 'Développez notre portefeuille clients aux Pays-Bas et en Belgique.',
requirements: [
'3+ ans d\'expérience en vente B2B',
'Connaissance du secteur maritime/logistique',
'Maîtrise du néerlandais et de l\'anglais',
'Capacité à gérer des cycles de vente longs',
],
icon: Megaphone,
},
{
id: 5,
title: 'Customer Success Manager',
department: 'Customer Success',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '45K - 60K €',
description: 'Accompagnez nos clients dans l\'utilisation de la plateforme et maximisez leur satisfaction.',
requirements: [
'2+ ans d\'expérience en customer success',
'Expérience avec les outils CRM (HubSpot, Salesforce)',
'Excellent relationnel et sens du service',
'Capacité à former et accompagner les utilisateurs',
],
icon: Headphones,
},
{
id: 6,
title: 'Data Analyst',
department: 'Data',
location: 'Hambourg',
type: 'CDI',
remote: true,
salary: '50K - 65K €',
description: 'Analysez les données de shipping pour optimiser notre plateforme et nos processus.',
requirements: [
'3+ ans d\'expérience en data analysis',
'Maîtrise de SQL, Python et des outils BI',
'Expérience avec le shipping/logistics appréciée',
'Capacité à communiquer les insights aux équipes',
],
icon: LineChart,
},
];
const departments = [
{ value: 'all', label: 'Tous les départements' },
{ value: 'Engineering', label: 'Engineering' },
{ value: 'Product', label: 'Product' },
{ value: 'Sales', label: 'Sales' },
{ value: 'Customer Success', label: 'Customer Success' },
{ value: 'Data', label: 'Data' },
];
const locations = [
{ value: 'all', label: 'Toutes les villes' },
{ value: 'Paris', label: 'Paris' },
{ value: 'Rotterdam', label: 'Rotterdam' },
{ value: 'Hambourg', label: 'Hambourg' },
];
const filteredJobs = jobs.filter((job) => {
const filteredJobs = JOBS.filter((job) => {
const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment;
const locationMatch = selectedLocation === 'all' || job.location === selectedLocation;
return departmentMatch && locationMatch;
@ -246,20 +144,19 @@ export default function CareersPage() {
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
>
<Briefcase className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">Rejoignez-nous</span>
<span className="text-white/90 text-sm font-medium">{t('badge')}</span>
</motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
Construisons ensemble
{t('title1')}
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
le futur du maritime
{t('title2')}
</span>
</h1>
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
Rejoignez une équipe passionnée qui révolutionne le fret maritime. Des défis stimulants,
une culture bienveillante et des opportunités de croissance uniques vous attendent.
{t('intro')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
@ -267,14 +164,14 @@ export default function CareersPage() {
href="#jobs"
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl font-semibold text-lg flex items-center space-x-2"
>
<span>Voir les offres</span>
<span>{t('viewJobs')}</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</a>
<Link
href="/about"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
>
En savoir plus
{t('learnMore')}
</Link>
</div>
</motion.div>
@ -295,14 +192,9 @@ export default function CareersPage() {
<section className="py-16 bg-gray-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{[
{ value: '50+', label: 'Employés' },
{ value: '15', label: 'Nationalités' },
{ value: '3', label: 'Bureaux en Europe' },
{ value: '40%', label: 'Femmes dans la tech' },
].map((stat, index) => (
{STATS.map((stat, index) => (
<motion.div
key={index}
key={stat.key}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
@ -310,7 +202,7 @@ export default function CareersPage() {
className="text-center"
>
<div className="text-5xl font-bold text-brand-turquoise mb-2">{stat.value}</div>
<div className="text-gray-600 font-medium">{stat.label}</div>
<div className="text-gray-600 font-medium">{t(`stats.${stat.key}`)}</div>
</motion.div>
))}
</div>
@ -327,10 +219,10 @@ export default function CareersPage() {
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
Pourquoi nous rejoindre ?
{t('benefitsTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Nous investissons dans le bien-être et le développement de nos équipes
{t('benefitsSubtitle')}
</p>
</motion.div>
@ -340,11 +232,11 @@ export default function CareersPage() {
animate={isBenefitsInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
{benefits.map((benefit, index) => {
{BENEFITS.map((benefit) => {
const IconComponent = benefit.icon;
return (
<motion.div
key={index}
key={benefit.key}
variants={itemVariants}
whileHover={{ y: -5 }}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
@ -352,8 +244,8 @@ export default function CareersPage() {
<div className="w-14 h-14 bg-brand-turquoise/10 rounded-xl flex items-center justify-center mb-4">
<IconComponent className="w-7 h-7 text-brand-turquoise" />
</div>
<h3 className="text-xl font-bold text-brand-navy mb-2">{benefit.title}</h3>
<p className="text-gray-600">{benefit.description}</p>
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`benefits.${benefit.key}.title`)}</h3>
<p className="text-gray-600">{t(`benefits.${benefit.key}.description`)}</p>
</motion.div>
);
})}
@ -371,21 +263,15 @@ export default function CareersPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
Notre culture
{t('cultureTitle')}
</h2>
<p className="text-xl text-white/80 mb-8">
Chez Xpeditis, nous croyons que les meilleures idées viennent d'équipes diverses
et inclusives. Nous valorisons l'autonomie, la créativité et le feedback constructif.
{t('cultureBody')}
</p>
<ul className="space-y-4">
{[
'Transparence totale sur les décisions et les résultats',
'Feedback continu et culture de l\'amélioration',
'Équilibre vie pro/perso respecté',
'Célébration des succès collectifs',
].map((item, index) => (
{CULTURE_ITEMS.map((itemKey, index) => (
<motion.li
key={index}
key={itemKey}
initial={{ opacity: 0, x: -20 }}
animate={isCultureInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.5, delay: index * 0.1 }}
@ -394,7 +280,7 @@ export default function CareersPage() {
<div className="w-6 h-6 bg-brand-turquoise rounded-full flex items-center justify-center flex-shrink-0">
<ChevronRight className="w-4 h-4 text-white" />
</div>
<span>{item}</span>
<span>{t(`culture.${itemKey}`)}</span>
</motion.li>
))}
</ul>
@ -429,10 +315,10 @@ export default function CareersPage() {
className="text-center mb-12"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
Nos offres d'emploi
{t('jobsTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Trouvez le poste qui correspond à vos ambitions
{t('jobsSubtitle')}
</p>
</motion.div>
@ -446,12 +332,12 @@ export default function CareersPage() {
<div className="relative">
<select
value={selectedDepartment}
onChange={(e) => setSelectedDepartment(e.target.value)}
onChange={(e) => setSelectedDepartment(e.target.value as DepartmentValue)}
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
>
{departments.map((dept) => (
<option key={dept.value} value={dept.value}>
{dept.label}
{DEPARTMENT_VALUES.map((value) => (
<option key={value} value={value}>
{value === 'all' ? t('filters.allDepartments') : t(`departments.${value}` as any)}
</option>
))}
</select>
@ -460,12 +346,12 @@ export default function CareersPage() {
<div className="relative">
<select
value={selectedLocation}
onChange={(e) => setSelectedLocation(e.target.value)}
onChange={(e) => setSelectedLocation(e.target.value as LocationValue)}
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
>
{locations.map((loc) => (
<option key={loc.value} value={loc.value}>
{loc.label}
{LOCATION_VALUES.map((value) => (
<option key={value} value={value}>
{value === 'all' ? t('filters.allLocations') : t(`locations.${value}` as any)}
</option>
))}
</select>
@ -483,8 +369,8 @@ export default function CareersPage() {
{filteredJobs.length === 0 ? (
<div className="text-center py-12">
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-medium text-gray-600">Aucune offre trouvée</h3>
<p className="text-gray-500">Essayez de modifier vos filtres</p>
<h3 className="text-xl font-medium text-gray-600">{t('noJobs.title')}</h3>
<p className="text-gray-500">{t('noJobs.body')}</p>
</div>
) : (
filteredJobs.map((job) => {
@ -507,15 +393,15 @@ export default function CareersPage() {
<IconComponent className="w-6 h-6 text-brand-turquoise" />
</div>
<div>
<h3 className="text-xl font-bold text-brand-navy">{job.title}</h3>
<h3 className="text-xl font-bold text-brand-navy">{t(`jobs.${job.key}.title`)}</h3>
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span className="flex items-center space-x-1">
<Building2 className="w-4 h-4" />
<span>{job.department}</span>
<span>{t(`departments.${job.department}` as any)}</span>
</span>
<span className="flex items-center space-x-1">
<MapPin className="w-4 h-4" />
<span>{job.location}</span>
<span>{t(`locations.${job.location}` as any)}</span>
</span>
<span className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
@ -528,7 +414,7 @@ export default function CareersPage() {
<div className="hidden md:flex items-center space-x-2">
{job.remote && (
<span className="px-3 py-1 bg-green-100 text-green-700 text-sm font-medium rounded-full">
Remote OK
{t('jobCard.remote')}
</span>
)}
<span className="px-3 py-1 bg-brand-turquoise/10 text-brand-turquoise text-sm font-medium rounded-full">
@ -554,13 +440,13 @@ export default function CareersPage() {
className="border-t border-gray-100"
>
<div className="p-6 bg-gray-50">
<p className="text-gray-600 mb-6">{job.description}</p>
<h4 className="font-bold text-brand-navy mb-3">Profil recherché :</h4>
<p className="text-gray-600 mb-6">{t(`jobs.${job.key}.description`)}</p>
<h4 className="font-bold text-brand-navy mb-3">{t('jobCard.profile')}</h4>
<ul className="space-y-2 mb-6">
{job.requirements.map((req, index) => (
<li key={index} className="flex items-start space-x-2 text-gray-600">
{JOB_REQ_KEYS.map((reqKey) => (
<li key={reqKey} className="flex items-start space-x-2 text-gray-600">
<ChevronRight className="w-5 h-5 text-brand-turquoise flex-shrink-0 mt-0.5" />
<span>{req}</span>
<span>{t(`jobs.${job.key}.${reqKey}` as any)}</span>
</li>
))}
</ul>
@ -569,11 +455,11 @@ export default function CareersPage() {
href={`/careers/${job.id}`}
className="px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-medium flex items-center space-x-2"
>
<span>Postuler</span>
<span>{t('jobCard.apply')}</span>
<ArrowRight className="w-4 h-4" />
</Link>
<button className="px-6 py-3 border border-gray-300 rounded-lg hover:border-brand-turquoise transition-all font-medium text-gray-700">
En savoir plus
{t('jobCard.learnMore')}
</button>
</div>
</div>
@ -598,17 +484,16 @@ export default function CareersPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl font-bold text-brand-navy mb-6">
Pas de poste correspondant ?
{t('cta.title')}
</h2>
<p className="text-xl text-gray-600 mb-10">
Envoyez-nous une candidature spontanée ! Nous sommes toujours à la recherche de
talents passionnés pour rejoindre notre aventure.
{t('cta.body')}
</p>
<Link
href="/contact"
className="inline-flex items-center space-x-2 px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold text-lg"
>
<span>Candidature spontanée</span>
<span>{t('cta.spontaneous')}</span>
<ArrowRight className="w-5 h-5" />
</Link>
</motion.div>

View File

@ -1,13 +1,16 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useParams } from 'next/navigation';
import { useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
import { CheckCircle, Loader2, XCircle } from 'lucide-react';
export default function CarrierAcceptPage() {
const params = useParams();
const router = useRouter();
const token = params.token as string;
const t = useTranslations('carrierPortal');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -18,20 +21,18 @@ export default function CarrierAcceptPage() {
useEffect(() => {
const acceptBooking = async () => {
// Protection contre les doubles appels
if (hasCalledApi.current) {
return;
}
hasCalledApi.current = true;
if (!token) {
setError('Token manquant');
setError(t('common.tokenMissing'));
setLoading(false);
return;
}
try {
// Appeler l'API backend pour accepter le booking
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/accept/${token}`, {
method: 'GET',
@ -45,17 +46,17 @@ export default function CarrierAcceptPage() {
try {
errorData = await response.json();
} catch (e) {
errorData = { message: `Erreur HTTP ${response.status}` };
errorData = { message: `HTTP ${response.status}` };
}
let errorMessage = errorData.message || 'Erreur lors de l\'acceptation du booking';
let errorMessage = errorData.message || t('accept.errorFallback');
if (errorMessage.includes('status ACCEPTED') || errorMessage.includes('ACCEPTED')) {
errorMessage = 'Ce booking a déjà été accepté.';
errorMessage = t('common.bookingAlreadyAccepted');
} else if (errorMessage.includes('status REJECTED')) {
errorMessage = 'Ce booking a déjà été refusé.';
errorMessage = t('common.bookingAlreadyRejected');
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) {
errorMessage = 'Booking introuvable. Le lien peut avoir expiré.';
errorMessage = t('common.bookingNotFound');
}
throw new Error(errorMessage);
@ -63,7 +64,6 @@ export default function CarrierAcceptPage() {
setLoading(false);
// Démarrer le compte à rebours
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
@ -78,13 +78,13 @@ export default function CarrierAcceptPage() {
return () => clearInterval(timer);
} catch (err) {
console.error('Error accepting booking:', err);
setError(err instanceof Error ? err.message : 'Erreur lors de l\'acceptation');
setError(err instanceof Error ? err.message : t('accept.errorGeneric'));
setLoading(false);
}
};
acceptBooking();
}, [token, router]);
}, [token, router, t]);
if (loading) {
return (
@ -92,10 +92,10 @@ export default function CarrierAcceptPage() {
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<Loader2 className="w-16 h-16 text-green-600 mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">
Traitement en cours...
{t('accept.loadingTitle')}
</h1>
<p className="text-gray-600">
Nous traitons votre acceptation.
{t('accept.loadingMessage')}
</p>
</div>
</div>
@ -107,13 +107,13 @@ export default function CarrierAcceptPage() {
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('common.errorTitle')}</h1>
<p className="text-gray-600 mb-6">{error}</p>
<button
onClick={() => router.push('/')}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retour à l'accueil
{t('common.backHome')}
</button>
</div>
</div>
@ -125,27 +125,27 @@ export default function CarrierAcceptPage() {
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<CheckCircle className="w-20 h-20 text-green-500 mx-auto mb-6" />
<h1 className="text-3xl font-bold text-gray-900 mb-4">
Merci !
{t('accept.thanksTitle')}
</h1>
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6 mb-6">
<p className="text-green-800 font-medium text-lg mb-2">
Votre acceptation a bien é prise en compte
{t('accept.successHeadline')}
</p>
<p className="text-green-700 text-sm">
Nous vous remercions d'avoir accepté cette demande de transport.
{t('accept.successBody')}
</p>
</div>
<p className="text-gray-500 text-sm mb-4">
Redirection vers la page d'accueil dans {countdown} seconde{countdown > 1 ? 's' : ''}...
{t('common.redirecting', { countdown })}
</p>
<button
onClick={() => router.push('/')}
className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold transition-colors"
>
Retour à l'accueil
{t('common.backHome')}
</button>
</div>
</div>

View File

@ -2,6 +2,7 @@
import { useEffect, useState, useRef } from 'react';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import {
FileText,
@ -55,13 +56,13 @@ interface AccessRequirements {
status: string;
}
const documentTypeLabels: Record<string, string> = {
BILL_OF_LADING: 'Connaissement',
PACKING_LIST: 'Liste de colisage',
COMMERCIAL_INVOICE: 'Facture commerciale',
CERTIFICATE_OF_ORIGIN: "Certificat d'origine",
OTHER: 'Autre document',
};
const DOCUMENT_TYPE_KEYS = [
'BILL_OF_LADING',
'PACKING_LIST',
'COMMERCIAL_INVOICE',
'CERTIFICATE_OF_ORIGIN',
'OTHER',
] as const;
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
@ -80,6 +81,7 @@ const getFileIcon = (mimeType: string) => {
export default function CarrierDocumentsPage() {
const params = useParams();
const token = params.token as string;
const t = useTranslations('carrierPortal.documents');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -96,10 +98,17 @@ export default function CarrierDocumentsPage() {
const hasCalledApi = useRef(false);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const getDocumentTypeLabel = (type: string): string => {
if ((DOCUMENT_TYPE_KEYS as readonly string[]).includes(type)) {
return t(`documentTypes.${type}` as any);
}
return type;
};
// Check access requirements first
const checkRequirements = async () => {
if (!token) {
setError('Lien invalide');
setError(t('linkInvalid'));
setLoading(false);
return;
}
@ -120,13 +129,13 @@ export default function CarrierDocumentsPage() {
try {
errorData = await response.json();
} catch {
errorData = { message: `Erreur HTTP ${response.status}` };
errorData = { message: `HTTP ${response.status}` };
}
const errorMessage = errorData.message || 'Erreur lors du chargement';
const errorMessage = errorData.message || t('loadError');
if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
throw new Error(t('bookingNotFound'));
}
throw new Error(errorMessage);
@ -135,16 +144,12 @@ export default function CarrierDocumentsPage() {
const reqData: AccessRequirements = await response.json();
setRequirements(reqData);
// If booking is not accepted yet
if (reqData.status !== 'ACCEPTED') {
setError(
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
);
setError(t('notAcceptedYet'));
setLoading(false);
return;
}
// If no password required, fetch documents directly
if (!reqData.requiresPassword) {
await fetchDocumentsWithoutPassword();
} else {
@ -152,7 +157,7 @@ export default function CarrierDocumentsPage() {
}
} catch (err) {
console.error('Error checking requirements:', err);
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
setError(err instanceof Error ? err.message : t('loadError'));
setLoading(false);
}
};
@ -172,22 +177,19 @@ export default function CarrierDocumentsPage() {
try {
errorData = await response.json();
} catch {
errorData = { message: `Erreur HTTP ${response.status}` };
errorData = { message: `HTTP ${response.status}` };
}
const errorMessage = errorData.message || 'Erreur lors du chargement des documents';
const errorMessage = errorData.message || t('loadDocsError');
if (
errorMessage.includes('pas encore été acceptée') ||
errorMessage.includes('not accepted')
) {
throw new Error(
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
);
throw new Error(t('notAcceptedYet'));
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
throw new Error(t('bookingNotFound'));
} else if (errorMessage.includes('Mot de passe requis') || errorMessage.includes('required')) {
// Password is now required, show the form
setRequirements({ requiresPassword: true, status: 'ACCEPTED' });
setLoading(false);
return;
@ -201,7 +203,7 @@ export default function CarrierDocumentsPage() {
setLoading(false);
} catch (err) {
console.error('Error fetching documents:', err);
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
setError(err instanceof Error ? err.message : t('loadError'));
setLoading(false);
}
};
@ -225,17 +227,17 @@ export default function CarrierDocumentsPage() {
try {
errorData = await response.json();
} catch {
errorData = { message: `Erreur HTTP ${response.status}` };
errorData = { message: `HTTP ${response.status}` };
}
const errorMessage = errorData.message || 'Erreur lors de la vérification';
const errorMessage = errorData.message || t('verifyError');
if (
response.status === 401 ||
errorMessage.includes('incorrect') ||
errorMessage.includes('invalid')
) {
setPasswordError('Mot de passe incorrect. Vérifiez votre email pour retrouver le mot de passe.');
setPasswordError(t('passwordIncorrect'));
setVerifying(false);
return;
}
@ -248,7 +250,7 @@ export default function CarrierDocumentsPage() {
setVerifying(false);
} catch (err) {
console.error('Error verifying password:', err);
setPasswordError(err instanceof Error ? err.message : 'Erreur lors de la vérification');
setPasswordError(err instanceof Error ? err.message : t('verifyError'));
setVerifying(false);
}
};
@ -262,7 +264,7 @@ export default function CarrierDocumentsPage() {
const handlePasswordSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setPasswordError('Veuillez entrer le mot de passe');
setPasswordError(t('passwordMissing'));
return;
}
fetchDocumentsWithPassword(password.trim());
@ -272,13 +274,11 @@ export default function CarrierDocumentsPage() {
setDownloading(doc.id);
try {
// The downloadUrl is already a signed URL, open it directly
window.open(doc.downloadUrl, '_blank');
} catch (err) {
console.error('Error downloading document:', err);
alert('Erreur lors du téléchargement. Veuillez réessayer.');
alert(t('downloadError'));
} finally {
// Small delay to show loading state
setTimeout(() => setDownloading(null), 500);
}
};
@ -300,8 +300,8 @@ export default function CarrierDocumentsPage() {
<div className="min-h-screen flex items-center justify-center bg-brand-gray">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
<Loader2 className="w-16 h-16 text-brand-turquoise mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">Chargement...</h1>
<p className="text-gray-600">Veuillez patienter</p>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('loading')}</h1>
<p className="text-gray-600">{t('loadingHint')}</p>
</div>
</div>
);
@ -313,13 +313,13 @@ export default function CarrierDocumentsPage() {
<div className="min-h-screen flex items-center justify-center bg-brand-gray">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('errorTitle')}</h1>
<p className="text-gray-600 mb-6">{error}</p>
<button
onClick={handleRefresh}
className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 font-medium transition-colors"
>
Réessayer
{t('retry')}
</button>
</div>
</div>
@ -335,14 +335,13 @@ export default function CarrierDocumentsPage() {
<div className="mx-auto w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mb-4">
<Lock className="w-8 h-8 text-brand-turquoise" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Accès sécurisé</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('password.title')}</h1>
<p className="text-gray-600">
Cette page est protégée. Entrez le mot de passe reçu par email pour accéder aux
documents.
{t('password.intro')}
</p>
{requirements.bookingNumber && (
<p className="mt-2 text-sm text-gray-500">
Réservation: <span className="font-mono font-bold">{requirements.bookingNumber}</span>
{t('password.bookingLabel')} <span className="font-mono font-bold">{requirements.bookingNumber}</span>
</p>
)}
</div>
@ -350,7 +349,7 @@ export default function CarrierDocumentsPage() {
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Mot de passe
{t('password.passwordLabel')}
</label>
<div className="relative">
<input
@ -358,7 +357,7 @@ export default function CarrierDocumentsPage() {
id="password"
value={password}
onChange={e => setPassword(e.target.value.toUpperCase())}
placeholder="Ex: A3B7K9"
placeholder={t('password.passwordPlaceholder')}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-brand-turquoise text-center text-xl tracking-widest font-mono uppercase"
autoComplete="off"
autoFocus
@ -387,12 +386,12 @@ export default function CarrierDocumentsPage() {
{verifying ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Vérification...
{t('password.verifying')}
</>
) : (
<>
<Lock className="w-5 h-5" />
Accéder aux documents
{t('password.submit')}
</>
)}
</button>
@ -400,10 +399,9 @@ export default function CarrierDocumentsPage() {
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-sm text-amber-800">
<strong> trouver le mot de passe ?</strong>
<strong>{t('password.helpTitle')}</strong>
<br />
Le mot de passe vous a é envoyé dans l'email de confirmation de la réservation. Il
correspond aux 6 derniers caractères du numéro de devis.
{t('password.helpBody')}
</p>
</div>
</div>
@ -433,7 +431,7 @@ export default function CarrierDocumentsPage() {
onClick={handleRefresh}
className="text-sm text-brand-turquoise hover:text-brand-navy font-medium"
>
Actualiser
{t('header.refresh')}
</button>
</div>
</header>
@ -449,7 +447,7 @@ export default function CarrierDocumentsPage() {
</div>
{booking.bookingNumber && (
<p className="text-center text-white/70 text-sm mt-1">
N° {booking.bookingNumber}
{t('summary.bookingNumberPrefix')} {booking.bookingNumber}
</p>
)}
</div>
@ -458,33 +456,33 @@ export default function CarrierDocumentsPage() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">Volume</p>
<p className="text-xs text-gray-500">{t('summary.volume')}</p>
<p className="font-semibold text-gray-900">{booking.volumeCBM} CBM</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">Poids</p>
<p className="text-xs text-gray-500">{t('summary.weight')}</p>
<p className="font-semibold text-gray-900">{booking.weightKG} kg</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">Transit</p>
<p className="font-semibold text-gray-900">{booking.transitDays} jours</p>
<p className="text-xs text-gray-500">{t('summary.transit')}</p>
<p className="font-semibold text-gray-900">{t('summary.transitDays', { count: booking.transitDays })}</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">Type</p>
<p className="text-xs text-gray-500">{t('summary.type')}</p>
<p className="font-semibold text-gray-900">{booking.containerType}</p>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
<span className="text-gray-500">
Transporteur:{' '}
{t('summary.carrierLabel')}{' '}
<span className="text-gray-900 font-medium">{booking.carrierName}</span>
</span>
<span className="text-gray-500">
Ref:{' '}
{t('summary.refLabel')}{' '}
<span className="font-mono text-gray-900">
{booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()}
</span>
@ -498,16 +496,16 @@ export default function CarrierDocumentsPage() {
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<FileText className="w-5 h-5 text-brand-turquoise" />
Documents ({documents.length})
{t('list.heading', { count: documents.length })}
</h2>
</div>
{documents.length === 0 ? (
<div className="p-8 text-center">
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600">Aucun document disponible pour le moment.</p>
<p className="text-gray-600">{t('list.empty')}</p>
<p className="text-gray-500 text-sm mt-1">
Les documents apparaîtront ici une fois ajoutés.
{t('list.emptyHint')}
</p>
</div>
) : (
@ -523,7 +521,7 @@ export default function CarrierDocumentsPage() {
<p className="font-medium text-gray-900">{doc.fileName}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs px-2 py-0.5 bg-brand-turquoise/10 text-brand-navy rounded-full">
{documentTypeLabels[doc.type] || doc.type}
{getDocumentTypeLabel(doc.type)}
</span>
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
</div>
@ -543,7 +541,7 @@ export default function CarrierDocumentsPage() {
) : (
<>
<Download className="w-4 h-4" />
<span>Télécharger</span>
<span>{t('list.download')}</span>
</>
)}
</button>
@ -555,13 +553,13 @@ export default function CarrierDocumentsPage() {
{/* Info */}
<p className="mt-6 text-center text-sm text-gray-500">
Cette page affiche toujours les documents les plus récents de la réservation.
{t('footerNote')}
</p>
</main>
{/* Footer */}
<footer className="mt-auto py-6 text-center text-sm text-brand-navy/50">
<p>© {new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
<p>{t('footer', { year: new Date().getFullYear() })}</p>
</footer>
</div>
);

View File

@ -1,13 +1,16 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useParams } from 'next/navigation';
import { useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
import { XCircle, Loader2, CheckCircle } from 'lucide-react';
export default function CarrierRejectPage() {
const params = useParams();
const router = useRouter();
const token = params.token as string;
const t = useTranslations('carrierPortal');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -18,20 +21,18 @@ export default function CarrierRejectPage() {
useEffect(() => {
const rejectBooking = async () => {
// Protection contre les doubles appels
if (hasCalledApi.current) {
return;
}
hasCalledApi.current = true;
if (!token) {
setError('Token manquant');
setError(t('common.tokenMissing'));
setLoading(false);
return;
}
try {
// Appeler l'API backend pour refuser le booking
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/reject/${token}`, {
method: 'GET',
@ -45,17 +46,17 @@ export default function CarrierRejectPage() {
try {
errorData = await response.json();
} catch (e) {
errorData = { message: `Erreur HTTP ${response.status}` };
errorData = { message: `HTTP ${response.status}` };
}
let errorMessage = errorData.message || 'Erreur lors du refus du booking';
let errorMessage = errorData.message || t('reject.errorFallback');
if (errorMessage.includes('status REJECTED')) {
errorMessage = 'Ce booking a déjà été refusé.';
errorMessage = t('common.bookingAlreadyRejected');
} else if (errorMessage.includes('status ACCEPTED')) {
errorMessage = 'Ce booking a déjà été accepté.';
errorMessage = t('common.bookingAlreadyAccepted');
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) {
errorMessage = 'Booking introuvable. Le lien peut avoir expiré.';
errorMessage = t('common.bookingNotFound');
}
throw new Error(errorMessage);
@ -63,7 +64,6 @@ export default function CarrierRejectPage() {
setLoading(false);
// Démarrer le compte à rebours
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
@ -78,13 +78,13 @@ export default function CarrierRejectPage() {
return () => clearInterval(timer);
} catch (err) {
console.error('Error rejecting booking:', err);
setError(err instanceof Error ? err.message : 'Erreur lors du refus');
setError(err instanceof Error ? err.message : t('reject.errorGeneric'));
setLoading(false);
}
};
rejectBooking();
}, [token, router]);
}, [token, router, t]);
if (loading) {
return (
@ -92,10 +92,10 @@ export default function CarrierRejectPage() {
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<Loader2 className="w-16 h-16 text-orange-600 mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">
Traitement en cours...
{t('reject.loadingTitle')}
</h1>
<p className="text-gray-600">
Nous traitons votre refus.
{t('reject.loadingMessage')}
</p>
</div>
</div>
@ -107,13 +107,13 @@ export default function CarrierRejectPage() {
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('common.errorTitle')}</h1>
<p className="text-gray-600 mb-6">{error}</p>
<button
onClick={() => router.push('/')}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retour à l'accueil
{t('common.backHome')}
</button>
</div>
</div>
@ -125,27 +125,27 @@ export default function CarrierRejectPage() {
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<CheckCircle className="w-20 h-20 text-orange-500 mx-auto mb-6" />
<h1 className="text-3xl font-bold text-gray-900 mb-4">
Merci de votre réponse
{t('reject.thanksTitle')}
</h1>
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-6 mb-6">
<p className="text-orange-800 font-medium text-lg mb-2">
Votre refus a bien é pris en compte
{t('reject.successHeadline')}
</p>
<p className="text-orange-700 text-sm">
Nous avons bien enregistré votre décision concernant cette demande de transport.
{t('reject.successBody')}
</p>
</div>
<p className="text-gray-500 text-sm mb-4">
Redirection vers la page d'accueil dans {countdown} seconde{countdown > 1 ? 's' : ''}...
{t('common.redirecting', { countdown })}
</p>
<button
onClick={() => router.push('/')}
className="w-full px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 font-semibold transition-colors"
>
Retour à l'accueil
{t('common.backHome')}
</button>
</div>
</div>

View File

@ -1,6 +1,7 @@
'use client';
import { useRef } from 'react';
import { useTranslations } from 'next-intl';
import { motion, useInView } from 'framer-motion';
import {
Shield,
@ -15,86 +16,41 @@ import {
Edit,
Eye,
Mail,
type LucideIcon,
} from 'lucide-react';
import Link from 'next/link';
import { Link } from '@/i18n/navigation';
import { LandingHeader, LandingFooter } from '@/components/layout';
type RightKey = 'access' | 'rectification' | 'erasure' | 'portability';
type PrincipleKey = 'minimization' | 'retention' | 'integrity' | 'transparency';
type MeasureKey = 'technical' | 'organizational';
const RIGHTS: { key: RightKey; icon: LucideIcon }[] = [
{ key: 'access', icon: Eye },
{ key: 'rectification', icon: Edit },
{ key: 'erasure', icon: Trash2 },
{ key: 'portability', icon: Download },
];
const PRINCIPLES: { key: PrincipleKey; icon: LucideIcon }[] = [
{ key: 'minimization', icon: Database },
{ key: 'retention', icon: Clock },
{ key: 'integrity', icon: Shield },
{ key: 'transparency', icon: FileText },
];
const MEASURES: MeasureKey[] = ['technical', 'organizational'];
const MEASURE_ITEMS = ['item1', 'item2', 'item3', 'item4', 'item5'] as const;
const REGISTER_ITEMS = ['item1', 'item2', 'item3', 'item4', 'item5'] as const;
export default function CompliancePage() {
const t = useTranslations('marketing.compliance');
const heroRef = useRef(null);
const contentRef = useRef(null);
const isHeroInView = useInView(heroRef, { once: true });
const isContentInView = useInView(contentRef, { once: true });
const rights = [
{
icon: Eye,
title: 'Droit d\'accès',
description: 'Obtenez une copie de toutes les données personnelles que nous détenons sur vous.',
},
{
icon: Edit,
title: 'Droit de rectification',
description: 'Faites corriger vos données personnelles si elles sont inexactes ou incomplètes.',
},
{
icon: Trash2,
title: 'Droit à l\'effacement',
description: 'Demandez la suppression de vos données personnelles ("droit à l\'oubli").',
},
{
icon: Download,
title: 'Droit à la portabilité',
description: 'Recevez vos données dans un format structuré, lisible par machine.',
},
];
const principles = [
{
icon: Database,
title: 'Minimisation des données',
description: 'Nous ne collectons que les données strictement nécessaires à nos services.',
},
{
icon: Clock,
title: 'Limitation de conservation',
description: 'Vos données sont conservées uniquement le temps nécessaire.',
},
{
icon: Shield,
title: 'Intégrité et confidentialité',
description: 'Vos données sont protégées contre tout accès non autorisé.',
},
{
icon: FileText,
title: 'Transparence',
description: 'Nous vous informons clairement sur l\'utilisation de vos données.',
},
];
const measures = [
{
category: 'Mesures techniques',
items: [
'Chiffrement des données au repos et en transit',
'Authentification multi-facteurs',
'Journalisation des accès aux données',
'Sauvegardes chiffrées régulières',
'Pseudonymisation des données sensibles',
],
},
{
category: 'Mesures organisationnelles',
items: [
'Délégué à la Protection des Données (DPO) désigné',
'Formation régulière des employés',
'Politiques de sécurité documentées',
'Processus de gestion des incidents',
'Audits de conformité réguliers',
],
},
];
const containerVariants = {
hidden: { opacity: 0, y: 50 },
visible: {
@ -141,30 +97,29 @@ export default function CompliancePage() {
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
>
<Globe className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">Conformité européenne</span>
<span className="text-white/90 text-sm font-medium">{t('badge')}</span>
</motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
Conformité
{t('title1')}
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
RGPD
{t('title2')}
</span>
</h1>
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
Xpeditis s'engage à respecter le Règlement Général sur la Protection des Données (RGPD)
et à garantir vos droits en matière de protection des données personnelles.
{t('intro')}
</p>
<div className="flex items-center justify-center space-x-4">
<div className="flex items-center space-x-2 bg-white/10 px-4 py-2 rounded-lg">
<CheckCircle className="w-5 h-5 text-brand-green" />
<span className="text-white text-sm">Conforme RGPD</span>
<span className="text-white text-sm">{t('badges.compliant')}</span>
</div>
<div className="flex items-center space-x-2 bg-white/10 px-4 py-2 rounded-lg">
<UserCheck className="w-5 h-5 text-brand-green" />
<span className="text-white text-sm">DPO désigné</span>
<span className="text-white text-sm">{t('badges.dpo')}</span>
</div>
</div>
</motion.div>
@ -191,10 +146,10 @@ export default function CompliancePage() {
className="text-center mb-16"
>
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
Vos droits RGPD
{t('rightsTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Le RGPD vous confère des droits renforcés sur vos données personnelles
{t('rightsSubtitle')}
</p>
</motion.div>
@ -204,11 +159,11 @@ export default function CompliancePage() {
animate={isContentInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
>
{rights.map((right, index) => {
{RIGHTS.map((right) => {
const IconComponent = right.icon;
return (
<motion.div
key={index}
key={right.key}
variants={itemVariants}
whileHover={{ y: -5 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all text-center"
@ -216,8 +171,8 @@ export default function CompliancePage() {
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mx-auto mb-4">
<IconComponent className="w-8 h-8 text-brand-turquoise" />
</div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{right.title}</h3>
<p className="text-gray-600">{right.description}</p>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`rights.${right.key}.title`)}</h3>
<p className="text-gray-600">{t(`rights.${right.key}.description`)}</p>
</motion.div>
);
})}
@ -231,20 +186,20 @@ export default function CompliancePage() {
className="mt-12 text-center"
>
<p className="text-gray-600 mb-4">
Pour exercer vos droits, connectez-vous à votre compte ou contactez notre DPO
{t('rightsCta.text')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<Link
href="/login"
className="px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium"
>
Accéder à mon compte
{t('rightsCta.login')}
</Link>
<a
href="mailto:dpo@xpeditis.com"
className="px-6 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors font-medium"
>
Contacter le DPO
{t('rightsCta.dpo')}
</a>
</div>
</motion.div>
@ -262,19 +217,19 @@ export default function CompliancePage() {
className="text-center mb-16"
>
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
Nos principes de protection des données
{t('principlesTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Des principes fondamentaux qui guident notre traitement des données
{t('principlesSubtitle')}
</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{principles.map((principle, index) => {
{PRINCIPLES.map((principle, index) => {
const IconComponent = principle.icon;
return (
<motion.div
key={index}
key={principle.key}
initial={{ opacity: 0, scale: 0.9 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
@ -284,8 +239,8 @@ export default function CompliancePage() {
<div className="w-12 h-12 bg-brand-green/10 rounded-xl flex items-center justify-center mb-4">
<IconComponent className="w-6 h-6 text-brand-green" />
</div>
<h3 className="text-lg font-bold text-brand-navy mb-2">{principle.title}</h3>
<p className="text-gray-600 text-sm">{principle.description}</p>
<h3 className="text-lg font-bold text-brand-navy mb-2">{t(`principles.${principle.key}.title`)}</h3>
<p className="text-gray-600 text-sm">{t(`principles.${principle.key}.description`)}</p>
</motion.div>
);
})}
@ -304,29 +259,29 @@ export default function CompliancePage() {
className="text-center mb-16"
>
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
Mesures de protection
{t('measuresTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Des mesures techniques et organisationnelles pour assurer la sécurité de vos données
{t('measuresSubtitle')}
</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{measures.map((measure, index) => (
{MEASURES.map((key, index) => (
<motion.div
key={index}
key={key}
initial={{ opacity: 0, x: index === 0 ? -30 : 30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-8 rounded-2xl"
>
<h3 className="text-xl font-bold text-white mb-6">{measure.category}</h3>
<h3 className="text-xl font-bold text-white mb-6">{t(`measures.${key}.title`)}</h3>
<ul className="space-y-4">
{measure.items.map((item, i) => (
<li key={i} className="flex items-center space-x-3 text-white/80">
{MEASURE_ITEMS.map((itemKey) => (
<li key={itemKey} className="flex items-center space-x-3 text-white/80">
<CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" />
<span>{item}</span>
<span>{t(`measures.${key}.${itemKey}` as any)}</span>
</li>
))}
</ul>
@ -352,33 +307,18 @@ export default function CompliancePage() {
</div>
<div>
<h3 className="text-2xl font-bold text-brand-navy mb-4">
Registre des traitements
{t('register.title')}
</h3>
<p className="text-gray-600 mb-6">
Conformément à l'article 30 du RGPD, nous tenons un registre des activités de traitement
des données personnelles. Ce registre documente :
{t('register.body')}
</p>
<ul className="space-y-3 text-gray-600">
<li className="flex items-center space-x-3">
{REGISTER_ITEMS.map((itemKey) => (
<li key={itemKey} className="flex items-center space-x-3">
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
<span>Les finalités de chaque traitement</span>
</li>
<li className="flex items-center space-x-3">
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
<span>Les catégories de données traitées</span>
</li>
<li className="flex items-center space-x-3">
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
<span>Les destinataires des données</span>
</li>
<li className="flex items-center space-x-3">
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
<span>Les durées de conservation</span>
</li>
<li className="flex items-center space-x-3">
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
<span>Les mesures de sécurité appliquées</span>
<span>{t(`register.${itemKey}` as any)}</span>
</li>
))}
</ul>
</div>
</div>
@ -398,11 +338,10 @@ export default function CompliancePage() {
>
<UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
<h3 className="text-2xl font-bold text-white mb-4">
Contacter notre DPO
{t('dpo.title')}
</h3>
<p className="text-white/80 mb-6 max-w-2xl mx-auto">
Notre Délégué à la Protection des Données est à votre disposition pour toute question
relative au traitement de vos données personnelles ou à l'exercice de vos droits.
{t('dpo.body')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<a
@ -416,7 +355,7 @@ export default function CompliancePage() {
href="/privacy"
className="px-6 py-3 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-colors font-medium"
>
Politique de confidentialité
{t('dpo.privacyLink')}
</Link>
</div>
</motion.div>

View File

@ -1,6 +1,7 @@
'use client';
import { useState, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { motion, useInView } from 'framer-motion';
import {
Mail,
@ -17,11 +18,26 @@ import {
Zap,
BookOpen,
ArrowRight,
type LucideIcon,
} from 'lucide-react';
import { Link } from '@/i18n/navigation';
import { LandingHeader, LandingFooter } from '@/components/layout';
import { sendContactForm } from '@/lib/api/auth';
type MethodKey = 'email' | 'phone' | 'chat' | 'support';
type SubjectKey = 'demo' | 'pricing' | 'partnership' | 'support' | 'press' | 'careers' | 'other';
const METHODS: { key: MethodKey; icon: LucideIcon; color: string }[] = [
{ key: 'email', icon: Mail, color: 'from-blue-500 to-cyan-500' },
{ key: 'phone', icon: Phone, color: 'from-green-500 to-emerald-500' },
{ key: 'chat', icon: MessageSquare, color: 'from-purple-500 to-pink-500' },
{ key: 'support', icon: Headphones, color: 'from-orange-500 to-red-500' },
];
const SUBJECTS: SubjectKey[] = ['demo', 'pricing', 'partnership', 'support', 'press', 'careers', 'other'];
export default function ContactPage() {
const t = useTranslations('marketing.contact');
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
@ -64,7 +80,7 @@ export default function ContactPage() {
});
setIsSubmitted(true);
} catch (err: any) {
setError(err.message || "Une erreur est survenue lors de l'envoi. Veuillez réessayer.");
setError(err.message || t('form.genericError'));
} finally {
setIsSubmitting(false);
}
@ -79,59 +95,6 @@ export default function ContactPage() {
}));
};
const contactMethods = [
{
icon: Mail,
title: 'Email',
description: 'Envoyez-nous un email',
value: 'contact@xpeditis.com',
color: 'from-blue-500 to-cyan-500',
},
{
icon: Phone,
title: 'Téléphone',
description: 'Appelez-nous',
value: '+33 1 23 45 67 89',
color: 'from-green-500 to-emerald-500',
},
{
icon: MessageSquare,
title: 'Chat en direct',
description: 'Discutez avec notre équipe',
value: 'Disponible 24/7',
color: 'from-purple-500 to-pink-500',
},
{
icon: Headphones,
title: 'Support',
description: 'Support client',
value: 'support@xpeditis.com',
color: 'from-orange-500 to-red-500',
},
];
const offices = [
{
city: 'Paris',
address: '123 Avenue des Champs-Élysées',
postalCode: '75008 Paris, France',
phone: '+33 1 23 45 67 89',
email: 'paris@xpeditis.com',
isHQ: true,
},
];
const subjects = [
{ value: '', label: 'Sélectionnez un sujet' },
{ value: 'demo', label: 'Demande de démonstration' },
{ value: 'pricing', label: 'Questions sur les tarifs' },
{ value: 'partnership', label: 'Partenariat' },
{ value: 'support', label: 'Support technique' },
{ value: 'press', label: 'Relations presse' },
{ value: 'careers', label: 'Recrutement' },
{ value: 'other', label: 'Autre' },
];
const containerVariants = {
hidden: { opacity: 0, y: 50 },
visible: {
@ -178,20 +141,19 @@ export default function ContactPage() {
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
>
<Mail className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">Nous contacter</span>
<span className="text-white/90 text-sm font-medium">{t('badge')}</span>
</motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
Une question ?
{t('title1')}
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
Nous sommes pour vous
{t('title2')}
</span>
</h1>
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
Notre équipe est disponible pour répondre à toutes vos questions sur notre plateforme,
nos services et nos tarifs. N'hésitez pas à nous contacter !
{t('intro')}
</p>
</motion.div>
</div>
@ -216,11 +178,11 @@ export default function ContactPage() {
className="max-w-7xl mx-auto px-6 lg:px-8"
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{contactMethods.map((method, index) => {
{METHODS.map((method) => {
const IconComponent = method.icon;
return (
<motion.div
key={index}
key={method.key}
variants={itemVariants}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100"
>
@ -229,9 +191,9 @@ export default function ContactPage() {
>
<IconComponent className="w-6 h-6 text-white" />
</div>
<h3 className="text-lg font-bold text-brand-navy mb-1">{method.title}</h3>
<p className="text-gray-500 text-sm mb-2">{method.description}</p>
<p className="text-brand-turquoise font-medium">{method.value}</p>
<h3 className="text-lg font-bold text-brand-navy mb-1">{t(`methods.${method.key}.title`)}</h3>
<p className="text-gray-500 text-sm mb-2">{t(`methods.${method.key}.description`)}</p>
<p className="text-brand-turquoise font-medium">{t(`methods.${method.key}.value`)}</p>
</motion.div>
);
})}
@ -249,9 +211,9 @@ export default function ContactPage() {
animate={isFormInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.8 }}
>
<h2 className="text-3xl font-bold text-brand-navy mb-6">Envoyez-nous un message</h2>
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('form.title')}</h2>
<p className="text-gray-600 mb-8">
Remplissez le formulaire ci-dessous et nous vous répondrons dans les plus brefs délais.
{t('form.description')}
</p>
{isSubmitted ? (
@ -263,9 +225,9 @@ export default function ContactPage() {
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle2 className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-2xl font-bold text-green-800 mb-2">Message envoyé !</h3>
<h3 className="text-2xl font-bold text-green-800 mb-2">{t('form.successTitle')}</h3>
<p className="text-green-700 mb-6">
Merci pour votre message. Notre équipe vous répondra dans les 24 heures.
{t('form.successBody')}
</p>
<button
onClick={() => {
@ -282,7 +244,7 @@ export default function ContactPage() {
}}
className="text-brand-turquoise font-medium hover:underline"
>
Envoyer un autre message
{t('form.sendAnother')}
</button>
</motion.div>
) : (
@ -290,7 +252,7 @@ export default function ContactPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
Prénom *
{t('form.firstName')} *
</label>
<input
type="text"
@ -300,12 +262,12 @@ export default function ContactPage() {
value={formData.firstName}
onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder="Jean"
placeholder={t('form.firstNamePlaceholder')}
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
Nom *
{t('form.lastName')} *
</label>
<input
type="text"
@ -315,7 +277,7 @@ export default function ContactPage() {
value={formData.lastName}
onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder="Dupont"
placeholder={t('form.lastNamePlaceholder')}
/>
</div>
</div>
@ -323,7 +285,7 @@ export default function ContactPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email *
{t('form.email')} *
</label>
<input
type="email"
@ -333,12 +295,12 @@ export default function ContactPage() {
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder="jean.dupont@exemple.com"
placeholder={t('form.emailPlaceholder')}
/>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2">
Téléphone
{t('form.phone')}
</label>
<input
type="tel"
@ -347,14 +309,14 @@ export default function ContactPage() {
value={formData.phone}
onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder="+33 6 12 34 56 78"
placeholder={t('form.phonePlaceholder')}
/>
</div>
</div>
<div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-2">
Entreprise
{t('form.company')}
</label>
<input
type="text"
@ -363,13 +325,13 @@ export default function ContactPage() {
value={formData.company}
onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder="Votre entreprise"
placeholder={t('form.companyPlaceholder')}
/>
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2">
Sujet *
{t('form.subject')} *
</label>
<select
id="subject"
@ -379,9 +341,10 @@ export default function ContactPage() {
onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
>
{subjects.map((subject) => (
<option key={subject.value} value={subject.value}>
{subject.label}
<option value="">{t('subjects.placeholder')}</option>
{SUBJECTS.map((key) => (
<option key={key} value={key}>
{t(`subjects.${key}`)}
</option>
))}
</select>
@ -389,7 +352,7 @@ export default function ContactPage() {
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2">
Message *
{t('form.message')} *
</label>
<textarea
id="message"
@ -399,7 +362,7 @@ export default function ContactPage() {
value={formData.message}
onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all resize-none"
placeholder="Comment pouvons-nous vous aider ?"
placeholder={t('form.messagePlaceholder')}
/>
</div>
@ -417,12 +380,12 @@ export default function ContactPage() {
{isSubmitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>Envoi en cours...</span>
<span>{t('form.submitting')}</span>
</>
) : (
<>
<Send className="w-5 h-5" />
<span>Envoyer le message</span>
<span>{t('form.submit')}</span>
</>
)}
</button>
@ -436,87 +399,72 @@ export default function ContactPage() {
animate={isFormInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.8, delay: 0.2 }}
>
<h2 className="text-3xl font-bold text-brand-navy mb-6">Notre bureau</h2>
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('office.title')}</h2>
<p className="text-gray-600 mb-8">
Retrouvez-nous à Paris ou contactez-nous par email.
{t('office.subtitle')}
</p>
<div className="space-y-6">
{offices.map((office, index) => (
<div
key={index}
className={`bg-white p-6 rounded-2xl border-2 transition-all ${
office.isHQ
? 'border-brand-turquoise shadow-lg'
: 'border-gray-200 hover:border-brand-turquoise/50'
}`}
>
<div className="bg-white p-6 rounded-2xl border-2 border-brand-turquoise shadow-lg">
<div className="flex items-start space-x-4">
<div
className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${
office.isHQ ? 'bg-brand-turquoise' : 'bg-gray-100'
}`}
>
<Building2 className={`w-6 h-6 ${office.isHQ ? 'text-white' : 'text-gray-600'}`} />
<div className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 bg-brand-turquoise">
<Building2 className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<h3 className="text-xl font-bold text-brand-navy">{office.city}</h3>
{office.isHQ && (
<h3 className="text-xl font-bold text-brand-navy">{t('office.city')}</h3>
<span className="px-2 py-1 bg-brand-turquoise/10 text-brand-turquoise text-xs font-medium rounded-full">
Siège social
{t('office.hqBadge')}
</span>
)}
</div>
<div className="space-y-2 text-gray-600">
<div className="flex items-center space-x-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span>{office.address}</span>
<span>{t('office.address')}</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-gray-400 ml-6">{office.postalCode}</span>
<span className="text-gray-400 ml-6">{t('office.postalCode')}</span>
</div>
<div className="flex items-center space-x-2">
<Phone className="w-4 h-4 text-gray-400" />
<a href={`tel:${office.phone.replace(/\s/g, '')}`} className="hover:text-brand-turquoise">
{office.phone}
<a href={`tel:${t('office.phone').replace(/\s/g, '')}`} className="hover:text-brand-turquoise">
{t('office.phone')}
</a>
</div>
<div className="flex items-center space-x-2">
<Mail className="w-4 h-4 text-gray-400" />
<a href={`mailto:${office.email}`} className="hover:text-brand-turquoise">
{office.email}
<a href={`mailto:${t('office.email')}`} className="hover:text-brand-turquoise">
{t('office.email')}
</a>
</div>
</div>
</div>
</div>
</div>
))}
</div>
{/* Hours */}
<div className="mt-8 bg-gray-50 p-6 rounded-2xl">
<div className="flex items-center space-x-3 mb-4">
<Clock className="w-6 h-6 text-brand-turquoise" />
<h3 className="text-lg font-bold text-brand-navy">Horaires d'ouverture</h3>
<h3 className="text-lg font-bold text-brand-navy">{t('hours.title')}</h3>
</div>
<div className="space-y-2 text-gray-600">
<div className="flex justify-between">
<span>Lundi - Vendredi</span>
<span className="font-medium">9h00 - 18h00</span>
<span>{t('hours.weekdays')}</span>
<span className="font-medium">{t('hours.weekdaysHours')}</span>
</div>
<div className="flex justify-between">
<span>Samedi</span>
<span className="font-medium">10h00 - 14h00</span>
<span>{t('hours.saturday')}</span>
<span className="font-medium">{t('hours.saturdayHours')}</span>
</div>
<div className="flex justify-between">
<span>Dimanche</span>
<span className="font-medium text-gray-400">Fermé</span>
<span>{t('hours.sunday')}</span>
<span className="font-medium text-gray-400">{t('hours.closed')}</span>
</div>
</div>
<p className="mt-4 text-sm text-gray-500">
* Support technique disponible 24/7 pour les clients Enterprise
{t('hours.supportNote')}
</p>
</div>
</motion.div>
@ -524,7 +472,7 @@ export default function ContactPage() {
</div>
</section>
{/* Section 1 : Ce qui se passe après l'envoi */}
{/* Section 1 : After submit */}
<section ref={afterSubmitRef} className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
@ -533,7 +481,6 @@ export default function ContactPage() {
transition={{ duration: 0.8 }}
className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden p-8 lg:p-12"
>
{/* Decorative blobs */}
<div className="absolute inset-0 opacity-10 pointer-events-none">
<div className="absolute -top-10 -left-10 w-64 h-64 bg-brand-turquoise rounded-full blur-3xl" />
<div className="absolute -bottom-10 -right-10 w-64 h-64 bg-brand-green rounded-full blur-3xl" />
@ -545,15 +492,15 @@ export default function ContactPage() {
<Mail className="w-5 h-5 text-brand-turquoise" />
</div>
<span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs">
Après votre envoi
{t('afterSubmit.badge')}
</span>
</div>
<h2 className="text-2xl lg:text-3xl font-bold text-white mb-8">
Que se passe-t-il après l'envoi de votre message ?
{t('afterSubmit.title')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Notre engagement */}
{/* Commitment */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isAfterSubmitInView ? { opacity: 1, y: 0 } : {}}
@ -564,19 +511,17 @@ export default function ContactPage() {
<div className="w-10 h-10 bg-brand-turquoise/30 rounded-xl flex items-center justify-center flex-shrink-0">
<CheckCircle2 className="w-5 h-5 text-brand-turquoise" />
</div>
<h3 className="text-lg font-bold text-white">Notre engagement</h3>
<h3 className="text-lg font-bold text-white">{t('afterSubmit.commitmentTitle')}</h3>
</div>
<p className="text-white/80 leading-relaxed">
Dès réception de votre demande, un de nos experts logistiques analyse votre
profil et vos besoins. Vous recevrez une réponse personnalisée ou une invitation
pour une démonstration de la plateforme{' '}
{t('afterSubmit.commitmentBody1')}
<span className="text-brand-turquoise font-semibold">
sous 48 heures ouvrées.
{t('afterSubmit.commitmentHighlight')}
</span>
</p>
</motion.div>
{/* Sécurité */}
{/* Security */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isAfterSubmitInView ? { opacity: 1, y: 0 } : {}}
@ -587,14 +532,14 @@ export default function ContactPage() {
<div className="w-10 h-10 bg-brand-green/30 rounded-xl flex items-center justify-center flex-shrink-0">
<Shield className="w-5 h-5 text-brand-green" />
</div>
<h3 className="text-lg font-bold text-white">Sécurité</h3>
<h3 className="text-lg font-bold text-white">{t('afterSubmit.securityTitle')}</h3>
</div>
<p className="text-white/80 leading-relaxed">
Vos informations sont protégées et traitées conformément à notre{' '}
<a href="/privacy" className="text-brand-turquoise font-semibold hover:underline">
politique de confidentialité
</a>
. Aucune donnée n'est partagée avec des tiers sans votre accord.
{t('afterSubmit.securityBody1')}
<Link href="/privacy" className="text-brand-turquoise font-semibold hover:underline">
{t('afterSubmit.privacyLink')}
</Link>
{t('afterSubmit.securityBody2')}
</p>
</motion.div>
</div>
@ -603,7 +548,7 @@ export default function ContactPage() {
</div>
</section>
{/* Section 2 : Accès Rapide */}
{/* Section 2: Quick access */}
<section ref={quickAccessRef} className="py-16 bg-gray-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
@ -613,15 +558,15 @@ export default function ContactPage() {
>
<div className="text-center mb-10">
<span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs">
Accès rapide
{t('quickAccess.badge')}
</span>
<h2 className="text-2xl lg:text-3xl font-bold text-brand-navy mt-2">
Besoin d'une réponse immédiate ?
{t('quickAccess.title')}
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{/* Tarification instantanée */}
{/* Instant pricing */}
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}}
@ -632,22 +577,22 @@ export default function ContactPage() {
<div className="w-14 h-14 bg-gradient-to-br from-brand-turquoise to-cyan-400 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
<Zap className="w-7 h-7 text-white" />
</div>
<h3 className="text-xl font-bold text-brand-navy mb-3">Tarification instantanée</h3>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t('quickAccess.pricingTitle')}</h3>
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
N'attendez pas notre retour pour vos prix. Utilisez notre moteur{' '}
<span className="font-semibold text-brand-navy">Click&amp;Ship</span> pour obtenir
une cotation de fret maritime en moins de 60 secondes.
{t('quickAccess.pricingBody1')}
<span className="font-semibold text-brand-navy">{t('quickAccess.pricingHighlight')}</span>
{t('quickAccess.pricingBody2')}
</p>
<a
<Link
href="/dashboard"
className="inline-flex items-center justify-center space-x-2 px-6 py-3 bg-brand-turquoise text-white rounded-xl font-semibold hover:bg-brand-turquoise/90 transition-all group"
>
<span>Accéder au Dashboard</span>
<span>{t('quickAccess.pricingCta')}</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</a>
</Link>
</motion.div>
{/* Wiki Maritime */}
{/* Wiki */}
<motion.div
initial={{ opacity: 0, x: 30 }}
animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}}
@ -658,19 +603,19 @@ export default function ContactPage() {
<div className="w-14 h-14 bg-gradient-to-br from-brand-navy to-brand-navy/80 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
<BookOpen className="w-7 h-7 text-brand-turquoise" />
</div>
<h3 className="text-xl font-bold text-brand-navy mb-3">Aide rapide</h3>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t('quickAccess.wikiTitle')}</h3>
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
Une question sur les Incoterms ou la documentation export ? Notre{' '}
<span className="font-semibold text-brand-navy">Wiki Maritime</span> contient déjà
les réponses aux questions les plus fréquentes.
{t('quickAccess.wikiBody1')}
<span className="font-semibold text-brand-navy">{t('quickAccess.wikiHighlight')}</span>
{t('quickAccess.wikiBody2')}
</p>
<a
<Link
href="/dashboard/wiki"
className="inline-flex items-center justify-center space-x-2 px-6 py-3 border-2 border-brand-navy text-brand-navy rounded-xl font-semibold hover:bg-brand-navy hover:text-white transition-all group"
>
<span>Consulter le Wiki</span>
<span>{t('quickAccess.wikiCta')}</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</a>
</Link>
</motion.div>
</div>
</motion.div>

View File

@ -2,63 +2,77 @@
import { useRef } from 'react';
import { motion, useInView } from 'framer-motion';
import { Cookie, Settings, BarChart3, Target, Shield, ToggleLeft, Mail } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Cookie, Settings, BarChart3, Target, Shield, ToggleLeft, Mail, type LucideIcon } from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout';
type CookieTypeKey = 'essential' | 'analytics' | 'marketing' | 'functional';
interface CookieRow {
name: string;
purposeKey: string;
durationKey: string;
}
interface CookieTypeConfig {
key: CookieTypeKey;
icon: LucideIcon;
required: boolean;
cookies: CookieRow[];
}
const COOKIE_TYPES: CookieTypeConfig[] = [
{
key: 'essential',
icon: Shield,
required: true,
cookies: [
{ name: 'session_id', purposeKey: 'session_id', durationKey: 'session' },
{ name: 'csrf_token', purposeKey: 'csrf_token', durationKey: 'session' },
{ name: 'cookie_consent', purposeKey: 'cookie_consent', durationKey: 'year1' },
],
},
{
key: 'analytics',
icon: BarChart3,
required: false,
cookies: [
{ name: '_ga', purposeKey: '_ga', durationKey: 'years2' },
{ name: '_gid', purposeKey: '_gid', durationKey: 'hours24' },
{ name: '_gat', purposeKey: '_gat', durationKey: 'minute1' },
],
},
{
key: 'marketing',
icon: Target,
required: false,
cookies: [
{ name: '_fbp', purposeKey: '_fbp', durationKey: 'months3' },
{ name: 'li_fat_id', purposeKey: 'li_fat_id', durationKey: 'days30' },
{ name: 'hubspotutk', purposeKey: 'hubspotutk', durationKey: 'months13' },
],
},
{
key: 'functional',
icon: Settings,
required: false,
cookies: [
{ name: 'language', purposeKey: 'language', durationKey: 'year1' },
{ name: 'theme', purposeKey: 'theme', durationKey: 'year1' },
{ name: 'recent_searches', purposeKey: 'recent_searches', durationKey: 'days30' },
],
},
];
export default function CookiesPage() {
const t = useTranslations('marketing.cookies');
const tCommon = useTranslations('marketing.common');
const heroRef = useRef(null);
const contentRef = useRef(null);
const isHeroInView = useInView(heroRef, { once: true });
const isContentInView = useInView(contentRef, { once: true });
const cookieTypes = [
{
icon: Shield,
title: 'Cookies essentiels',
description: 'Nécessaires au fonctionnement du site',
required: true,
cookies: [
{ name: 'session_id', purpose: 'Maintien de votre session de connexion', duration: 'Session' },
{ name: 'csrf_token', purpose: 'Protection contre les attaques CSRF', duration: 'Session' },
{ name: 'cookie_consent', purpose: 'Mémorisation de vos préférences cookies', duration: '1 an' },
],
},
{
icon: BarChart3,
title: 'Cookies analytiques',
description: 'Nous aident à améliorer notre plateforme',
required: false,
cookies: [
{ name: '_ga', purpose: 'Google Analytics - Identification des visiteurs', duration: '2 ans' },
{ name: '_gid', purpose: 'Google Analytics - Identification des sessions', duration: '24 heures' },
{ name: '_gat', purpose: 'Google Analytics - Limitation du taux de requêtes', duration: '1 minute' },
],
},
{
icon: Target,
title: 'Cookies marketing',
description: 'Permettent de personnaliser les publicités',
required: false,
cookies: [
{ name: '_fbp', purpose: 'Facebook Pixel - Suivi des conversions', duration: '3 mois' },
{ name: 'li_fat_id', purpose: 'LinkedIn Insight - Attribution marketing', duration: '30 jours' },
{ name: 'hubspotutk', purpose: 'HubSpot - Identification des visiteurs', duration: '13 mois' },
],
},
{
icon: Settings,
title: 'Cookies fonctionnels',
description: 'Améliorent votre expérience utilisateur',
required: false,
cookies: [
{ name: 'language', purpose: 'Mémorisation de votre langue préférée', duration: '1 an' },
{ name: 'theme', purpose: 'Mémorisation du thème (clair/sombre)', duration: '1 an' },
{ name: 'recent_searches', purpose: 'Historique de vos recherches récentes', duration: '30 jours' },
],
},
];
const containerVariants = {
hidden: { opacity: 0, y: 50 },
visible: {
@ -105,29 +119,25 @@ export default function CookiesPage() {
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
>
<Cookie className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">Transparence</span>
<span className="text-white/90 text-sm font-medium">{t('badge')}</span>
</motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
Politique de
{t('title1')}
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
Cookies
{t('title2')}
</span>
</h1>
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
Découvrez comment nous utilisons les cookies pour améliorer votre expérience
sur Xpeditis et comment vous pouvez gérer vos préférences.
{t('intro')}
</p>
<p className="text-white/60 text-sm">
Dernière mise à jour : Janvier 2025
</p>
<p className="text-white/60 text-sm">{tCommon('lastUpdated')}</p>
</motion.div>
</div>
{/* Wave */}
<div className="absolute bottom-0 left-0 right-0">
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
<path
@ -148,16 +158,9 @@ export default function CookiesPage() {
transition={{ duration: 0.8 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
>
<h2 className="text-2xl font-bold text-brand-navy mb-4">Qu'est-ce qu'un cookie ?</h2>
<p className="text-gray-600 leading-relaxed mb-4">
Un cookie est un petit fichier texte stocké sur votre appareil (ordinateur, tablette, smartphone)
lorsque vous visitez un site web. Les cookies permettent au site de mémoriser vos actions et
préférences sur une période donnée.
</p>
<p className="text-gray-600 leading-relaxed">
Les cookies ne contiennent pas d'informations personnellement identifiables et ne peuvent pas
accéder aux données stockées sur votre appareil.
</p>
<h2 className="text-2xl font-bold text-brand-navy mb-4">{t('introBoxTitle')}</h2>
<p className="text-gray-600 leading-relaxed mb-4">{t('introBoxBody1')}</p>
<p className="text-gray-600 leading-relaxed">{t('introBoxBody2')}</p>
</motion.div>
</div>
</section>
@ -171,10 +174,8 @@ export default function CookiesPage() {
transition={{ duration: 0.8 }}
className="text-center mb-12"
>
<h2 className="text-3xl font-bold text-brand-navy mb-4">Types de cookies utilisés</h2>
<p className="text-gray-600">
Nous utilisons différents types de cookies sur notre plateforme
</p>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('typesTitle')}</h2>
<p className="text-gray-600">{t('typesSubtitle')}</p>
</motion.div>
<motion.div
@ -183,11 +184,11 @@ export default function CookiesPage() {
animate={isContentInView ? 'visible' : 'hidden'}
className="space-y-8"
>
{cookieTypes.map((type, index) => {
{COOKIE_TYPES.map((type) => {
const IconComponent = type.icon;
return (
<motion.div
key={index}
key={type.key}
variants={itemVariants}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
>
@ -197,18 +198,22 @@ export default function CookiesPage() {
<IconComponent className="w-6 h-6 text-brand-turquoise" />
</div>
<div>
<h3 className="text-xl font-bold text-brand-navy">{type.title}</h3>
<p className="text-gray-500 text-sm">{type.description}</p>
<h3 className="text-xl font-bold text-brand-navy">
{t(`types.${type.key}.title`)}
</h3>
<p className="text-gray-500 text-sm">
{t(`types.${type.key}.description`)}
</p>
</div>
</div>
{type.required ? (
<span className="px-3 py-1 bg-brand-navy/10 text-brand-navy text-xs font-medium rounded-full">
Requis
{t('required')}
</span>
) : (
<div className="flex items-center space-x-2">
<ToggleLeft className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-500">Optionnel</span>
<span className="text-sm text-gray-500">{t('optional')}</span>
</div>
)}
</div>
@ -217,17 +222,27 @@ export default function CookiesPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-semibold text-brand-navy">Nom</th>
<th className="text-left py-3 px-4 font-semibold text-brand-navy">Finalité</th>
<th className="text-left py-3 px-4 font-semibold text-brand-navy">Durée</th>
<th className="text-left py-3 px-4 font-semibold text-brand-navy">
{t('tableHeaders.name')}
</th>
<th className="text-left py-3 px-4 font-semibold text-brand-navy">
{t('tableHeaders.purpose')}
</th>
<th className="text-left py-3 px-4 font-semibold text-brand-navy">
{t('tableHeaders.duration')}
</th>
</tr>
</thead>
<tbody>
{type.cookies.map((cookie, i) => (
<tr key={i} className="border-b border-gray-100 last:border-0">
{type.cookies.map((cookie) => (
<tr key={cookie.name} className="border-b border-gray-100 last:border-0">
<td className="py-3 px-4 font-mono text-brand-turquoise">{cookie.name}</td>
<td className="py-3 px-4 text-gray-600">{cookie.purpose}</td>
<td className="py-3 px-4 text-gray-500">{cookie.duration}</td>
<td className="py-3 px-4 text-gray-600">
{t(`purposes.${cookie.purposeKey}` as any)}
</td>
<td className="py-3 px-4 text-gray-500">
{t(`durations.${cookie.durationKey}` as any)}
</td>
</tr>
))}
</tbody>
@ -245,19 +260,15 @@ export default function CookiesPage() {
transition={{ duration: 0.8, delay: 0.4 }}
className="mt-12 bg-gradient-to-br from-gray-50 to-white p-8 rounded-2xl border border-gray-200"
>
<h3 className="text-2xl font-bold text-brand-navy mb-4">Comment gérer vos cookies ?</h3>
<h3 className="text-2xl font-bold text-brand-navy mb-4">{t('manageTitle')}</h3>
<div className="space-y-4 text-gray-600">
<p>
Vous pouvez à tout moment modifier vos préférences en matière de cookies :
</p>
<p>{t('manageIntro')}</p>
<ul className="list-disc pl-6 space-y-2">
<li>Via notre bandeau de consentement accessible en bas de chaque page</li>
<li>Dans les paramètres de votre navigateur (Chrome, Firefox, Safari, Edge)</li>
<li>En utilisant des outils tiers de gestion des cookies</li>
<li>{t('manageBullet1')}</li>
<li>{t('manageBullet2')}</li>
<li>{t('manageBullet3')}</li>
</ul>
<p className="text-sm text-gray-500 mt-4">
Note : La désactivation de certains cookies peut affecter votre expérience sur notre plateforme.
</p>
<p className="text-sm text-gray-500 mt-4">{t('manageNote')}</p>
</div>
</motion.div>
@ -269,11 +280,8 @@ export default function CookiesPage() {
className="mt-16 bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
>
<Mail className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
<h3 className="text-2xl font-bold text-white mb-4">Des questions sur les cookies ?</h3>
<p className="text-white/80 mb-6">
Notre équipe est disponible pour répondre à toutes vos questions
concernant l'utilisation des cookies sur notre plateforme.
</p>
<h3 className="text-2xl font-bold text-white mb-4">{t('contact.title')}</h3>
<p className="text-white/80 mb-6">{t('contact.body')}</p>
<a
href="mailto:privacy@xpeditis.com"
className="inline-flex items-center space-x-2 px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium"

View File

@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin';
interface Booking {
@ -26,6 +27,10 @@ interface Booking {
}
export default function AdminBookingsPage() {
const t = useTranslations('dashboard.admin.bookings');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const [bookings, setBookings] = useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -43,26 +48,26 @@ export default function AdminBookingsPage() {
}, []);
const handleDeleteBooking = async (bookingId: string) => {
if (!window.confirm('Supprimer définitivement cette réservation ?')) return;
if (!window.confirm(t('confirmDelete'))) return;
setDeletingId(bookingId);
try {
await deleteAdminBooking(bookingId);
setBookings(prev => prev.filter(b => b.id !== bookingId));
} catch (err: any) {
setError(err.message || 'Erreur lors de la suppression');
setError(err.message || t('deleteError'));
} finally {
setDeletingId(null);
}
};
const handleValidateTransfer = async (bookingId: string) => {
if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return;
if (!window.confirm(t('confirmValidate'))) return;
setValidatingId(bookingId);
try {
await validateBankTransfer(bookingId);
await fetchBookings();
} catch (err: any) {
setError(err.message || 'Erreur lors de la validation du virement');
setError(err.message || t('validateError'));
} finally {
setValidatingId(null);
}
@ -75,7 +80,7 @@ export default function AdminBookingsPage() {
setBookings(response.bookings || []);
setError(null);
} catch (err: any) {
setError(err.message || 'Impossible de charger les réservations');
setError(err.message || t('loadError'));
} finally {
setLoading(false);
}
@ -94,15 +99,12 @@ export default function AdminBookingsPage() {
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
PENDING_PAYMENT: 'Paiement en attente',
PENDING_BANK_TRANSFER: 'Virement à valider',
PENDING: 'En attente transporteur',
ACCEPTED: 'Accepté',
REJECTED: 'Rejeté',
CANCELLED: 'Annulé',
};
return labels[status.toUpperCase()] || status;
const key = status.toUpperCase();
const allowed = ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'];
if (allowed.includes(key)) {
return t(`status.${key}` as any);
}
return status;
};
const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`;
@ -130,7 +132,7 @@ export default function AdminBookingsPage() {
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Chargement des réservations...</p>
<p className="mt-4 text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -140,38 +142,38 @@ export default function AdminBookingsPage() {
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestion des réservations</h1>
<h1 className="text-2xl font-bold text-gray-900">{t('title')}</h1>
<p className="mt-1 text-sm text-gray-500">
Toutes les réservations de la plateforme
{t('subtitle')}
</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">Total</div>
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.total')}</div>
<div className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div>
</div>
<div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4">
<div className="text-xs text-amber-700 uppercase tracking-wide">Virements à valider</div>
<div className="text-xs text-amber-700 uppercase tracking-wide">{t('stats.pendingBankTransfer')}</div>
<div className="text-2xl font-bold text-amber-700 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">En attente transporteur</div>
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.pendingCarrier')}</div>
<div className="text-2xl font-bold text-yellow-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">Acceptées</div>
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.accepted')}</div>
<div className="text-2xl font-bold text-green-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">Rejetées</div>
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.rejected')}</div>
<div className="text-2xl font-bold text-red-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
</div>
@ -182,29 +184,29 @@ export default function AdminBookingsPage() {
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Recherche</label>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('search.label')}</label>
<input
type="text"
placeholder="N° booking, transporteur, route, palettes, poids, CBM..."
placeholder={t('search.placeholder')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Statut</label>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('filter.label')}</label>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none text-sm"
>
<option value="all">Tous les statuts</option>
<option value="pending_bank_transfer">Virement à valider</option>
<option value="pending_payment">Paiement en attente</option>
<option value="pending">En attente transporteur</option>
<option value="accepted">Accepté</option>
<option value="rejected">Rejeté</option>
<option value="cancelled">Annulé</option>
<option value="all">{t('filter.all')}</option>
<option value="pending_bank_transfer">{t('status.PENDING_BANK_TRANSFER')}</option>
<option value="pending_payment">{t('status.PENDING_PAYMENT')}</option>
<option value="pending">{t('status.PENDING')}</option>
<option value="accepted">{t('status.ACCEPTED')}</option>
<option value="rejected">{t('status.REJECTED')}</option>
<option value="cancelled">{t('status.CANCELLED')}</option>
</select>
</div>
</div>
@ -224,25 +226,25 @@ export default function AdminBookingsPage() {
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Booking
{t('table.bookingNumber')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Route
{t('table.route')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Cargo
{t('table.cargo')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Transporteur
{t('table.carrier')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
{t('table.status')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
{t('table.date')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
{t('table.actions')}
</th>
</tr>
</thead>
@ -250,7 +252,7 @@ export default function AdminBookingsPage() {
{filteredBookings.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500">
Aucune réservation trouvée
{t('table.empty')}
</td>
</tr>
) : (
@ -276,11 +278,11 @@ export default function AdminBookingsPage() {
<div className="text-sm text-gray-900">
{booking.containerType}
{booking.palletCount != null && (
<span className="ml-1 text-gray-500">· {booking.palletCount} pal.</span>
<span className="ml-1 text-gray-500">· {booking.palletCount} {t('table.pallets')}</span>
)}
</div>
<div className="text-xs text-gray-500 space-x-2">
{booking.weightKG != null && <span>{booking.weightKG.toLocaleString()} kg</span>}
{booking.weightKG != null && <span>{booking.weightKG.toLocaleString(dateLocale)} kg</span>}
{booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>}
</div>
</td>
@ -299,7 +301,7 @@ export default function AdminBookingsPage() {
{/* Date */}
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString('fr-FR')}
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString(dateLocale)}
</td>
{/* Actions */}
@ -357,7 +359,7 @@ export default function AdminBookingsPage() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span className="text-sm font-medium text-gray-700">Voir les détails</span>
<span className="text-sm font-medium text-gray-700">{t('menu.viewDetails')}</span>
</button>
{(() => {
const booking = bookings.find(b => b.id === openMenuId);
@ -375,7 +377,7 @@ export default function AdminBookingsPage() {
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium text-green-700">Valider virement</span>
<span className="text-sm font-medium text-green-700">{t('menu.validateTransfer')}</span>
</button>
) : null;
})()}
@ -392,7 +394,7 @@ export default function AdminBookingsPage() {
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span className="text-sm font-medium text-red-600">Supprimer</span>
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
</button>
</div>
</div>
@ -404,7 +406,7 @@ export default function AdminBookingsPage() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto p-4">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">Détails de la réservation</h2>
<h2 className="text-xl font-bold text-gray-900">{t('modal.title')}</h2>
<button
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
className="text-gray-400 hover:text-gray-600"
@ -418,13 +420,13 @@ export default function AdminBookingsPage() {
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">N° Booking</label>
<label className="block text-sm font-medium text-gray-500">{t('modal.bookingNumber')}</label>
<div className="mt-1 text-lg font-semibold text-gray-900">
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Statut</label>
<label className="block text-sm font-medium text-gray-500">{t('modal.status')}</label>
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
{getStatusLabel(selectedBooking.status)}
</span>
@ -432,45 +434,45 @@ export default function AdminBookingsPage() {
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Route</h3>
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.routeSection')}</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Origine</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || '—'}</div>
<label className="block text-sm font-medium text-gray-500">{t('modal.origin')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || t('modal.none')}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Destination</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || '—'}</div>
<label className="block text-sm font-medium text-gray-500">{t('modal.destination')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || t('modal.none')}</div>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Cargo & Transporteur</h3>
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.cargoSection')}</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Transporteur</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || '—'}</div>
<label className="block text-sm font-medium text-gray-500">{t('modal.carrier')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || t('modal.none')}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Type conteneur</label>
<label className="block text-sm font-medium text-gray-500">{t('modal.containerType')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.containerType}</div>
</div>
{selectedBooking.palletCount != null && (
<div>
<label className="block text-sm font-medium text-gray-500">Palettes</label>
<label className="block text-sm font-medium text-gray-500">{t('modal.pallets')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.palletCount}</div>
</div>
)}
{selectedBooking.weightKG != null && (
<div>
<label className="block text-sm font-medium text-gray-500">Poids</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString()} kg</div>
<label className="block text-sm font-medium text-gray-500">{t('modal.weight')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString(dateLocale)} kg</div>
</div>
)}
{selectedBooking.volumeCBM != null && (
<div>
<label className="block text-sm font-medium text-gray-500">Volume</label>
<label className="block text-sm font-medium text-gray-500">{t('modal.volume')}</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.volumeCBM} CBM</div>
</div>
)}
@ -479,18 +481,18 @@ export default function AdminBookingsPage() {
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Prix</h3>
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.priceSection')}</h3>
<div className="grid grid-cols-2 gap-4">
{selectedBooking.priceEUR != null && (
<div>
<label className="block text-sm font-medium text-gray-500">EUR</label>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceEUR.toLocaleString()} </div>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceEUR.toLocaleString(dateLocale)} </div>
</div>
)}
{selectedBooking.priceUSD != null && (
<div>
<label className="block text-sm font-medium text-gray-500">USD</label>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceUSD.toLocaleString()} $</div>
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceUSD.toLocaleString(dateLocale)} $</div>
</div>
)}
</div>
@ -498,18 +500,18 @@ export default function AdminBookingsPage() {
)}
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Dates</h3>
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.datesSection')}</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<label className="block text-gray-500">Créée le</label>
<label className="block text-gray-500">{t('modal.createdAt')}</label>
<div className="mt-1 text-gray-900">
{new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString('fr-FR')}
{new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString(dateLocale)}
</div>
</div>
{selectedBooking.updatedAt && (
<div>
<label className="block text-gray-500">Mise à jour</label>
<div className="mt-1 text-gray-900">{new Date(selectedBooking.updatedAt).toLocaleString('fr-FR')}</div>
<label className="block text-gray-500">{t('modal.updatedAt')}</label>
<div className="mt-1 text-gray-900">{new Date(selectedBooking.updatedAt).toLocaleString(dateLocale)}</div>
</div>
)}
</div>
@ -526,7 +528,7 @@ export default function AdminBookingsPage() {
disabled={validatingId === selectedBooking.id}
className="w-full px-4 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{validatingId === selectedBooking.id ? 'Validation...' : '✓ Valider le virement'}
{validatingId === selectedBooking.id ? t('modal.validating') : t('modal.validateButton')}
</button>
</div>
)}
@ -537,7 +539,7 @@ export default function AdminBookingsPage() {
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Fermer
{t('modal.close')}
</button>
</div>
</div>

View File

@ -10,6 +10,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -27,6 +28,10 @@ import {
} from '@/components/ui/table';
export default function AdminCsvRatesPage() {
const t = useTranslations('dashboard.admin.csvRates');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const [files, setFiles] = useState<CsvFileInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -39,7 +44,7 @@ export default function AdminCsvRatesPage() {
const data = await listCsvFiles();
setFiles(data.files || []);
} catch (err: any) {
setError(err?.message || 'Erreur lors du chargement des fichiers');
setError(err?.message || t('loadError'));
} finally {
setLoading(false);
}
@ -50,16 +55,16 @@ export default function AdminCsvRatesPage() {
}, []);
const handleDelete = async (filename: string) => {
if (!confirm(`Êtes-vous sûr de vouloir supprimer le fichier ${filename} ?`)) {
if (!confirm(t('confirmDelete', { filename }))) {
return;
}
try {
await deleteCsvFile(filename);
alert(`Fichier supprimé: ${filename}`);
alert(t('deleteSuccess', { filename }));
fetchFiles(); // Refresh list
} catch (err: any) {
alert(`Erreur: ${err?.message || 'Impossible de supprimer le fichier'}`);
alert(t('deleteError', { message: err?.message || t('deleteFailedFallback') }));
}
};
@ -67,12 +72,12 @@ export default function AdminCsvRatesPage() {
<div className="container mx-auto py-8 space-y-6">
{/* Page Header */}
<div>
<h1 className="text-3xl font-bold tracking-tight">Gestion des tarifs CSV</h1>
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground mt-2">
Interface d'administration pour gérer les fichiers CSV de tarifs maritimes
{t('subtitle')}
</p>
<Badge variant="destructive" className="mt-2">
ADMIN SEULEMENT
{t('adminBadge')}
</Badge>
</div>
@ -84,9 +89,9 @@ export default function AdminCsvRatesPage() {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Configurations CSV actives</CardTitle>
<CardTitle>{t('cardTitle')}</CardTitle>
<CardDescription>
Liste de toutes les compagnies avec fichiers CSV configurés
{t('cardDescription')}
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}>
@ -111,19 +116,19 @@ export default function AdminCsvRatesPage() {
</div>
) : files.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
Aucun fichier trouvé. Uploadez un fichier CSV pour commencer.
{t('empty')}
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Fichier</TableHead>
<TableHead>Taille</TableHead>
<TableHead>Lignes</TableHead>
<TableHead>Date d'upload</TableHead>
<TableHead>Email</TableHead>
<TableHead>Actions</TableHead>
<TableHead>{t('table.filename')}</TableHead>
<TableHead>{t('table.size')}</TableHead>
<TableHead>{t('table.rows')}</TableHead>
<TableHead>{t('table.uploadedAt')}</TableHead>
<TableHead>{t('table.email')}</TableHead>
<TableHead>{t('table.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -135,14 +140,14 @@ export default function AdminCsvRatesPage() {
</TableCell>
<TableCell>
{file.rowCount ? (
<span className="font-semibold">{file.rowCount} lignes</span>
<span className="font-semibold">{t('table.rowCount', { count: file.rowCount })}</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div className="text-xs text-muted-foreground">
{new Date(file.uploadedAt).toLocaleDateString('fr-FR')}
{new Date(file.uploadedAt).toLocaleDateString(dateLocale)}
</div>
</TableCell>
<TableCell>
@ -171,23 +176,20 @@ export default function AdminCsvRatesPage() {
{/* Info Card */}
<Card>
<CardHeader>
<CardTitle>Informations</CardTitle>
<CardTitle>{t('infoTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<p>
<strong>Format CSV requis :</strong> Consultez la documentation pour la liste complète
des colonnes obligatoires.
<strong>{t('info.formatLabel')}</strong> {t('info.formatBody')}
</p>
<p>
<strong>Taille maximale :</strong> 10 MB par fichier
<strong>{t('info.sizeLabel')}</strong> {t('info.sizeBody')}
</p>
<p>
<strong>Mise à jour :</strong> Uploader un nouveau fichier pour une compagnie existante
écrasera l'ancien fichier.
<strong>{t('info.updateLabel')}</strong> {t('info.updateBody')}
</p>
<p>
<strong>Validation :</strong> Le système valide automatiquement la structure du CSV lors
de l'upload.
<strong>{t('info.validationLabel')}</strong> {t('info.validationBody')}
</p>
</CardContent>
</Card>

View File

@ -1,9 +1,11 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { getAllBookings, getAllUsers, deleteAdminDocument } from '@/lib/api/admin';
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
import type { ReactNode } from 'react';
import { PageHeader } from '@/components/ui/PageHeader';
interface Document {
id: string;
@ -45,6 +47,10 @@ interface DocumentWithBooking extends Document {
}
export default function AdminDocumentsPage() {
const t = useTranslations('dashboard.admin.documents');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const [bookings, setBookings] = useState<Booking[]>([]);
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
const [loading, setLoading] = useState(true);
@ -66,19 +72,6 @@ export default function AdminDocumentsPage() {
return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`;
};
// Extract filename from MinIO URL
const extractFileName = (url: string): string => {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const parts = pathname.split('/');
const fileName = parts[parts.length - 1];
return decodeURIComponent(fileName);
} catch {
return 'document';
}
};
// Get file extension and type
const getFileType = (fileName: string): string => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
@ -105,7 +98,6 @@ export default function AdminDocumentsPage() {
const allBookings = response.bookings || [];
setBookings(allBookings);
// Extract all documents from all bookings
const allDocuments: DocumentWithBooking[] = [];
const userIds = new Set<string>();
@ -113,28 +105,15 @@ export default function AdminDocumentsPage() {
userIds.add(booking.userId);
if (booking.documents && booking.documents.length > 0) {
booking.documents.forEach((doc: Document) => {
// Debug: Log document structure
console.log('Document structure:', doc);
// Use the correct field names from the backend
const actualFileName = doc.fileName || doc.name || 'document';
const actualFilePath = doc.filePath || doc.url || '';
const actualMimeType = doc.mimeType || doc.type || '';
console.log('Extracted:', {
fileName: actualFileName,
filePath: actualFilePath,
mimeType: actualMimeType,
});
// Extract clean file type from mimeType or fileName
let fileType = '';
if (actualMimeType.includes('/')) {
// It's a MIME type like "application/pdf"
const parts = actualMimeType.split('/');
fileType = getFileType(parts[1]);
} else {
// It's already a type or we extract from filename
fileType = getFileType(actualFileName);
}
@ -154,10 +133,8 @@ export default function AdminDocumentsPage() {
}
});
// Fetch user names using the API client
try {
const usersData = await getAllUsers();
console.log('Users data:', usersData);
if (usersData && usersData.users) {
const usersMap = new Map(
@ -167,18 +144,13 @@ export default function AdminDocumentsPage() {
})
);
console.log('Users map:', usersMap);
// Enrich documents with user names
allDocuments.forEach(doc => {
const userName = usersMap.get(doc.userId);
doc.userName = userName || doc.userId.substring(0, 8) + '...';
console.log(`User ${doc.userId} mapped to: ${doc.userName}`);
});
}
} catch (userError) {
console.error('Failed to fetch user names:', userError);
// If user fetch fails, keep the userId as fallback
allDocuments.forEach(doc => {
doc.userName = doc.userId.substring(0, 8) + '...';
});
@ -187,24 +159,22 @@ export default function AdminDocumentsPage() {
setDocuments(allDocuments);
setError(null);
} catch (err: any) {
setError(err.message || 'Failed to load documents');
setError(err.message || t('loadError'));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
fetchBookingsAndDocuments();
}, [fetchBookingsAndDocuments]);
// Get unique users for filter (with names)
const uniqueUsers = Array.from(
new Map(
documents.map(doc => [doc.userId, { id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' }])
).values()
);
// Filter documents
const filteredDocuments = documents.filter(doc => {
const matchesSearch = searchTerm === '' ||
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
@ -220,13 +190,11 @@ export default function AdminDocumentsPage() {
return matchesSearch && matchesUser && matchesQuote;
});
// Pagination
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
// Reset to page 1 when filters change
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, filterUserId, filterQuoteNumber]);
@ -269,13 +237,13 @@ export default function AdminDocumentsPage() {
};
const handleDeleteDocument = async (bookingId: string, documentId: string) => {
if (!window.confirm('Supprimer définitivement ce document ?')) return;
if (!window.confirm(t('confirmDelete'))) return;
setDeletingId(documentId);
try {
await deleteAdminDocument(bookingId, documentId);
setDocuments(prev => prev.filter(d => d.id !== documentId));
} catch (err: any) {
setError(err.message || 'Erreur lors de la suppression');
setError(err.message || t('deleteError'));
} finally {
setDeletingId(null);
}
@ -283,7 +251,6 @@ export default function AdminDocumentsPage() {
const handleDownload = async (url: string, fileName: string) => {
try {
// Try direct download first
const link = document.createElement('a');
link.href = url;
link.download = fileName;
@ -293,7 +260,6 @@ export default function AdminDocumentsPage() {
link.click();
document.body.removeChild(link);
// If direct download doesn't work, try fetch with blob
setTimeout(async () => {
try {
const response = await fetch(url, {
@ -320,7 +286,8 @@ export default function AdminDocumentsPage() {
}, 100);
} catch (error) {
console.error('Error downloading file:', error);
alert(`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
const message = error instanceof Error ? error.message : t('unknownError');
alert(t('downloadError', { message }));
}
};
@ -329,7 +296,7 @@ export default function AdminDocumentsPage() {
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Chargement des documents...</p>
<p className="mt-4 text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -337,30 +304,25 @@ export default function AdminDocumentsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestion des Documents</h1>
<p className="mt-1 text-sm text-gray-500">
Liste de tous les documents des devis CSV
</p>
</div>
</div>
<PageHeader
title={t('title')}
description={t('subtitle')}
/>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Total Documents</div>
<div className="text-sm text-gray-500">{t('stats.totalDocs')}</div>
<div className="text-2xl font-bold text-gray-900">{documents.length}</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Devis avec Documents</div>
<div className="text-sm text-gray-500">{t('stats.bookingsWithDocs')}</div>
<div className="text-2xl font-bold text-blue-600">
{bookings.filter(b => b.documents && b.documents.length > 0).length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Documents Filtrés</div>
<div className="text-sm text-gray-500">{t('stats.filtered')}</div>
<div className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div>
</div>
</div>
@ -370,11 +332,11 @@ export default function AdminDocumentsPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Recherche
{t('filters.search')}
</label>
<input
type="text"
placeholder="Nom, type, route..."
placeholder={t('filters.searchPlaceholder')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
@ -382,11 +344,11 @@ export default function AdminDocumentsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Numéro de Devis
{t('filters.quoteNumber')}
</label>
<input
type="text"
placeholder="Ex: #F2CAD5E1"
placeholder={t('filters.quoteNumberPlaceholder')}
value={filterQuoteNumber}
onChange={e => setFilterQuoteNumber(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
@ -394,14 +356,14 @@ export default function AdminDocumentsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Utilisateur
{t('filters.user')}
</label>
<select
value={filterUserId}
onChange={e => setFilterUserId(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="all">Tous les utilisateurs</option>
<option value="all">{t('filters.allUsers')}</option>
{uniqueUsers.map(user => (
<option key={user.id} value={user.id}>
{user.name}
@ -425,25 +387,25 @@ export default function AdminDocumentsPage() {
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Nom du Document
{t('table.name')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
{t('table.type')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Numéro de Devis
{t('table.quoteNumber')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Route
{t('table.route')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
{t('table.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Utilisateur
{t('table.user')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
{t('table.actions')}
</th>
</tr>
</thead>
@ -451,7 +413,7 @@ export default function AdminDocumentsPage() {
{paginatedDocuments.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
Aucun document trouvé
{t('table.empty')}
</td>
</tr>
) : (
@ -519,27 +481,27 @@ export default function AdminDocumentsPage() {
disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Précédent
{t('pagination.previous')}
</button>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Suivant
{t('pagination.next')}
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
<span className="font-medium">{Math.min(endIndex, filteredDocuments.length)}</span> sur{' '}
<span className="font-medium">{filteredDocuments.length}</span> résultats
{t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span> {t('pagination.to')}{' '}
<span className="font-medium">{Math.min(endIndex, filteredDocuments.length)}</span> {t('pagination.on')}{' '}
<span className="font-medium">{filteredDocuments.length}</span> {t('pagination.results')}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700">Par page:</label>
<label className="text-sm text-gray-700">{t('pagination.perPage')}</label>
<select
value={itemsPerPage}
onChange={(e) => {
@ -561,7 +523,7 @@ export default function AdminDocumentsPage() {
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">Précédent</span>
<span className="sr-only">{t('pagination.previous')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
@ -600,7 +562,7 @@ export default function AdminDocumentsPage() {
disabled={currentPage === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">Suivant</span>
<span className="sr-only">{t('pagination.next')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
@ -640,7 +602,7 @@ export default function AdminDocumentsPage() {
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span className="text-sm font-medium text-gray-700">Télécharger</span>
<span className="text-sm font-medium text-gray-700">{t('menu.download')}</span>
</button>
<button
onClick={() => {
@ -656,7 +618,7 @@ export default function AdminDocumentsPage() {
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span className="text-sm font-medium text-red-600">Supprimer</span>
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
</button>
</>
);

View File

@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import {
Download,
RefreshCw,
@ -12,6 +13,7 @@ import {
Server,
} from 'lucide-react';
import { get, download } from '@/lib/api/client';
import { PageHeader } from '@/components/ui/PageHeader';
const LOGS_PREFIX = '/api/v1/logs';
@ -103,6 +105,10 @@ function StatCard({
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AdminLogsPage() {
const t = useTranslations('dashboard.admin.logs');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const [logs, setLogs] = useState<LogEntry[]>([]);
const [services, setServices] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
@ -189,71 +195,68 @@ export default function AdminLogsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Logs système</h1>
<p className="mt-1 text-sm text-gray-500">
Visualisation et export des logs applicatifs en temps réel
</p>
</div>
<PageHeader
title={t('title')}
description={t('subtitle')}
actions={
<div className="flex items-center gap-2">
<button
onClick={fetchLogs}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Actualiser
<span className="hidden sm:inline">{t('refresh')}</span>
</button>
<div className="relative group">
<button
disabled={exportLoading || loading}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-[#10183A] rounded-lg hover:bg-[#1a2550] transition-colors disabled:opacity-50"
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-white bg-[#10183A] rounded-lg hover:bg-[#1a2550] transition-colors disabled:opacity-50"
>
<Download className="h-4 w-4" />
{exportLoading ? 'Export...' : 'Exporter'}
<span className="hidden sm:inline">{exportLoading ? t('exporting') : t('export')}</span>
</button>
<div className="absolute right-0 mt-1 w-36 bg-white rounded-lg shadow-lg border border-gray-200 z-10 hidden group-hover:block">
<button
onClick={() => handleExport('csv')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Télécharger CSV
{t('downloadCsv')}
</button>
<button
onClick={() => handleExport('json')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Télécharger JSON
{t('downloadJson')}
</button>
</div>
</div>
</div>
</div>
}
/>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Total logs"
label={t('stats.total')}
value={total}
icon={Activity}
color="bg-blue-100 text-blue-600"
/>
<StatCard
label="Erreurs"
label={t('stats.errors')}
value={countByLevel('error') + countByLevel('fatal')}
icon={AlertTriangle}
color="bg-red-100 text-red-600"
/>
<StatCard
label="Warnings"
label={t('stats.warnings')}
value={countByLevel('warn')}
icon={AlertTriangle}
color="bg-yellow-100 text-yellow-600"
/>
<StatCard
label="Info"
label={t('stats.info')}
value={countByLevel('info')}
icon={Info}
color="bg-green-100 text-green-600"
@ -264,18 +267,18 @@ export default function AdminLogsPage() {
<div className="bg-white rounded-lg border p-4">
<div className="flex items-center gap-2 mb-4">
<Filter className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700">Filtres</h2>
<h2 className="text-sm font-semibold text-gray-700">{t('filters.title')}</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
{/* Service */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Service</label>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.service')}</label>
<select
value={filters.service}
onChange={e => setFilter('service', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
>
<option value="all">Tous</option>
<option value="all">{t('filters.all')}</option>
{services.map(s => (
<option key={s} value={s}>
{s}
@ -286,13 +289,13 @@ export default function AdminLogsPage() {
{/* Level */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Niveau</label>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.level')}</label>
<select
value={filters.level}
onChange={e => setFilter('level', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
>
<option value="all">Tous</option>
<option value="all">{t('filters.all')}</option>
<option value="error">Error</option>
<option value="fatal">Fatal</option>
<option value="warn">Warn</option>
@ -303,10 +306,10 @@ export default function AdminLogsPage() {
{/* Search */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Recherche</label>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.search')}</label>
<input
type="text"
placeholder="Texte libre..."
placeholder={t('filters.searchPlaceholder')}
value={filters.search}
onChange={e => setFilter('search', e.target.value)}
onKeyDown={e => e.key === 'Enter' && fetchLogs()}
@ -316,7 +319,7 @@ export default function AdminLogsPage() {
{/* Start */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Début</label>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.start')}</label>
<input
type="datetime-local"
value={filters.startDate}
@ -327,7 +330,7 @@ export default function AdminLogsPage() {
{/* End */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Fin</label>
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.end')}</label>
<input
type="datetime-local"
value={filters.endDate}
@ -338,7 +341,7 @@ export default function AdminLogsPage() {
{/* Limit + Apply */}
<div className="flex flex-col justify-end gap-2">
<label className="block text-xs font-medium text-gray-500">Limite</label>
<label className="block text-xs font-medium text-gray-500">{t('filters.limit')}</label>
<div className="flex gap-2">
<select
value={filters.limit}
@ -355,7 +358,7 @@ export default function AdminLogsPage() {
disabled={loading}
className="px-3 py-2 text-sm font-medium text-white bg-[#34CCCD] rounded-lg hover:bg-[#2bb8b9] transition-colors disabled:opacity-50 whitespace-nowrap"
>
Filtrer
{t('filters.apply')}
</button>
</div>
</div>
@ -367,10 +370,10 @@ export default function AdminLogsPage() {
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-center gap-2">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Impossible de contacter le log-exporter : <strong>{error}</strong>
{t('errorBanner')} <strong>{error}</strong>
<br />
<span className="text-xs text-red-500">
Vérifiez que le backend et le log-exporter sont démarrés.
{t('errorHint')}
</span>
</span>
</div>
@ -382,12 +385,12 @@ export default function AdminLogsPage() {
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700">
{loading ? 'Chargement...' : `${total} entrée${total !== 1 ? 's' : ''}`}
{loading ? t('loading') : t('entries', { count: total })}
</span>
</div>
{!loading && logs.length > 0 && (
<span className="text-xs text-gray-400">
Cliquer sur une ligne pour les détails
{t('clickHint')}
</span>
)}
</div>
@ -399,7 +402,7 @@ export default function AdminLogsPage() {
) : logs.length === 0 && !error ? (
<div className="flex flex-col items-center justify-center h-40 text-gray-400 gap-2">
<Bug className="h-8 w-8" />
<p className="text-sm">Aucun log trouvé pour ces filtres</p>
<p className="text-sm">{t('empty')}</p>
</div>
) : (
<div className="overflow-x-auto">
@ -407,22 +410,22 @@ export default function AdminLogsPage() {
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
Timestamp
{t('table.timestamp')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Service
{t('table.service')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Niveau
{t('table.level')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Contexte
{t('table.context')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Message
{t('table.message')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
Req / Status
{t('table.req')}
</th>
</tr>
</thead>
@ -435,7 +438,7 @@ export default function AdminLogsPage() {
className={`cursor-pointer hover:bg-gray-50 transition-colors ${LEVEL_ROW_BG[log.level] || ''}`}
>
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
{new Date(log.timestamp).toLocaleString('fr-FR', {
{new Date(log.timestamp).toLocaleString(dateLocale, {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
@ -492,25 +495,25 @@ export default function AdminLogsPage() {
<td colSpan={6} className="px-4 py-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<span className="font-semibold text-gray-600">Timestamp</span>
<span className="font-semibold text-gray-600">{t('detail.timestamp')}</span>
<p className="font-mono text-gray-800 mt-0.5">{log.timestamp}</p>
</div>
{log.reqId && (
<div>
<span className="font-semibold text-gray-600">Request ID</span>
<span className="font-semibold text-gray-600">{t('detail.requestId')}</span>
<p className="font-mono text-gray-800 mt-0.5 truncate">{log.reqId}</p>
</div>
)}
{log.response_time_ms && (
<div>
<span className="font-semibold text-gray-600">Durée</span>
<span className="font-semibold text-gray-600">{t('detail.duration')}</span>
<p className="font-mono text-gray-800 mt-0.5">
{log.response_time_ms} ms
</p>
</div>
)}
<div className="col-span-2 md:col-span-4">
<span className="font-semibold text-gray-600">Message complet</span>
<span className="font-semibold text-gray-600">{t('detail.fullMessage')}</span>
<pre className="mt-0.5 p-2 bg-white rounded border font-mono text-gray-800 overflow-x-auto whitespace-pre-wrap break-all">
{log.error
? `[ERROR] ${log.error}\n\n${log.message}`

View File

@ -1,8 +1,10 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin';
import { createOrganization, updateOrganization } from '@/lib/api/organizations';
import { PageHeader } from '@/components/ui/PageHeader';
interface Organization {
id: string;
@ -29,6 +31,8 @@ interface Organization {
}
export default function AdminOrganizationsPage() {
const t = useTranslations('dashboard.admin.organizations');
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -85,7 +89,7 @@ export default function AdminOrganizationsPage() {
setOrganizations(response.organizations || []);
setError(null);
} catch (err: any) {
setError(err.message || 'Failed to load organizations');
setError(err.message || t('loadError'));
} finally {
setLoading(false);
}
@ -94,10 +98,9 @@ export default function AdminOrganizationsPage() {
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Transform formData to match API expected format
const apiData = {
name: formData.name,
type: formData.type as any, // OrganizationType
type: formData.type as any,
address_street: formData.address.street,
address_city: formData.address.city,
address_postal_code: formData.address.postalCode,
@ -111,7 +114,7 @@ export default function AdminOrganizationsPage() {
setShowCreateModal(false);
resetForm();
} catch (err: any) {
alert(err.message || 'Failed to create organization');
alert(err.message || t('createError'));
}
};
@ -126,7 +129,7 @@ export default function AdminOrganizationsPage() {
setSelectedOrg(null);
resetForm();
} catch (err: any) {
alert(err.message || 'Failed to update organization');
alert(err.message || t('updateError'));
}
};
@ -156,41 +159,41 @@ export default function AdminOrganizationsPage() {
setVerifyingId(orgId);
const result = await verifySiret(orgId);
if (result.verified) {
alert(`SIRET verifie avec succes !\nEntreprise: ${result.companyName || 'N/A'}\nAdresse: ${result.address || 'N/A'}`);
alert(t('siretVerified', { companyName: result.companyName || 'N/A', address: result.address || 'N/A' }));
await fetchOrganizations();
} else {
alert(result.message || 'SIRET invalide ou introuvable.');
alert(result.message || t('siretInvalid'));
}
} catch (err: any) {
alert(err.message || 'Erreur lors de la verification du SIRET');
alert(err.message || t('siretError'));
} finally {
setVerifyingId(null);
}
};
const handleApproveSiret = async (orgId: string) => {
if (!confirm('Confirmer l\'approbation manuelle du SIRET/SIREN de cette organisation ?')) return;
if (!confirm(t('confirmApprove'))) return;
try {
setVerifyingId(orgId);
const result = await approveSiret(orgId);
alert(result.message);
await fetchOrganizations();
} catch (err: any) {
alert(err.message || 'Erreur lors de l\'approbation');
alert(err.message || t('siretApproveError'));
} finally {
setVerifyingId(null);
}
};
const handleRejectSiret = async (orgId: string) => {
if (!confirm('Confirmer le refus du SIRET/SIREN ? L\'organisation ne pourra plus effectuer d\'achats.')) return;
if (!confirm(t('confirmReject'))) return;
try {
setVerifyingId(orgId);
const result = await rejectSiret(orgId);
alert(result.message);
await fetchOrganizations();
} catch (err: any) {
alert(err.message || 'Erreur lors du refus');
alert(err.message || t('siretRejectError'));
} finally {
setVerifyingId(null);
}
@ -213,12 +216,20 @@ export default function AdminOrganizationsPage() {
setShowEditModal(true);
};
const getTypeLabel = (type: string) => {
const allowed = ['FREIGHT_FORWARDER', 'CARRIER', 'SHIPPER'];
if (allowed.includes(type)) {
return t(`types.${type}` as any);
}
return type.replace('_', ' ');
};
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading organizations...</p>
<p className="mt-4 text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -226,21 +237,18 @@ export default function AdminOrganizationsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Organization Management</h1>
<p className="mt-1 text-sm text-gray-500">
Manage all organizations in the system
</p>
</div>
<PageHeader
title={t('title')}
description={t('subtitle')}
actions={
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
+ Create Organization
{t('create')}
</button>
</div>
}
/>
{/* Error Message */}
{error && (
@ -261,53 +269,53 @@ export default function AdminOrganizationsPage() {
org.type === 'CARRIER' ? 'bg-green-100 text-green-800' :
'bg-purple-100 text-purple-800'
}`}>
{org.type.replace('_', ' ')}
{getTypeLabel(org.type)}
</span>
</div>
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${
org.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{org.isActive ? 'Active' : 'Inactive'}
{org.isActive ? t('active') : t('inactive')}
</span>
</div>
<div className="space-y-2 text-sm text-gray-600 mb-4">
{org.scac && (
<div>
<span className="font-medium">SCAC:</span> {org.scac}
<span className="font-medium">{t('scac')}:</span> {org.scac}
</div>
)}
{org.siren && (
<div>
<span className="font-medium">SIREN:</span> {org.siren}
<span className="font-medium">{t('siren')}:</span> {org.siren}
</div>
)}
<div className="flex items-center gap-2">
<span className="font-medium">SIRET:</span>
<span className="font-medium">{t('siret')}:</span>
{org.siret ? (
<>
<span>{org.siret}</span>
{org.siretVerified ? (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">
Verifie
{t('verified')}
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
Non verifie
{t('notVerified')}
</span>
)}
</>
) : (
<span className="text-gray-400">Non renseigne</span>
<span className="text-gray-400">{t('notProvided')}</span>
)}
</div>
{org.contact_email && (
<div>
<span className="font-medium">Email:</span> {org.contact_email}
<span className="font-medium">{t('email')}:</span> {org.contact_email}
</div>
)}
<div>
<span className="font-medium">Location:</span> {org.address.city}, {org.address.country}
<span className="font-medium">{t('location')}:</span> {org.address.city}, {org.address.country}
</div>
</div>
@ -317,7 +325,7 @@ export default function AdminOrganizationsPage() {
onClick={() => openEditModal(org)}
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors text-sm font-medium"
>
Edit
{t('edit')}
</button>
{org.siret && !org.siretVerified && (
<button
@ -325,7 +333,7 @@ export default function AdminOrganizationsPage() {
disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-purple-50 text-purple-700 rounded-md hover:bg-purple-100 transition-colors text-sm font-medium disabled:opacity-50"
>
{verifyingId === org.id ? '...' : 'Verifier API'}
{verifyingId === org.id ? t('verifying') : t('verifyApi')}
</button>
)}
</div>
@ -337,7 +345,7 @@ export default function AdminOrganizationsPage() {
disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-green-50 text-green-700 rounded-md hover:bg-green-100 transition-colors text-sm font-medium disabled:opacity-50"
>
Approuver SIRET
{t('approveSiret')}
</button>
) : (
<button
@ -345,7 +353,7 @@ export default function AdminOrganizationsPage() {
disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors text-sm font-medium disabled:opacity-50"
>
Rejeter SIRET
{t('rejectSiret')}
</button>
)}
</div>
@ -360,12 +368,12 @@ export default function AdminOrganizationsPage() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4 max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">
{showCreateModal ? 'Create New Organization' : 'Edit Organization'}
{showCreateModal ? t('modal.createTitle') : t('modal.editTitle')}
</h2>
<form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Organization Name *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.name')}</label>
<input
type="text"
required
@ -376,21 +384,21 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Type *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.type')}</label>
<select
value={formData.type}
onChange={e => setFormData({ ...formData, type: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="FREIGHT_FORWARDER">Freight Forwarder</option>
<option value="CARRIER">Carrier</option>
<option value="SHIPPER">Shipper</option>
<option value="FREIGHT_FORWARDER">{t('types.FREIGHT_FORWARDER')}</option>
<option value="CARRIER">{t('types.CARRIER')}</option>
<option value="SHIPPER">{t('types.SHIPPER')}</option>
</select>
</div>
{formData.type === 'CARRIER' && (
<div>
<label className="block text-sm font-medium text-gray-700">SCAC Code *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.scacLabel')}</label>
<input
type="text"
required={formData.type === 'CARRIER'}
@ -403,7 +411,7 @@ export default function AdminOrganizationsPage() {
)}
<div>
<label className="block text-sm font-medium text-gray-700">SIREN</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.sirenLabel')}</label>
<input
type="text"
maxLength={9}
@ -414,19 +422,19 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">SIRET (14 chiffres)</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.siretLabel')}</label>
<input
type="text"
maxLength={14}
value={formData.siret}
onChange={e => setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
placeholder="12345678901234"
placeholder={t('modal.siretPlaceholder')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">EORI</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.eoriLabel')}</label>
<input
type="text"
value={formData.eori}
@ -436,7 +444,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Contact Phone</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.contactPhone')}</label>
<input
type="tel"
value={formData.contact_phone}
@ -446,7 +454,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Contact Email</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.contactEmail')}</label>
<input
type="email"
value={formData.contact_email}
@ -456,7 +464,7 @@ export default function AdminOrganizationsPage() {
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Street Address *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.street')}</label>
<input
type="text"
required
@ -470,7 +478,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">City *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.city')}</label>
<input
type="text"
required
@ -484,7 +492,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Postal Code *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.postalCode')}</label>
<input
type="text"
required
@ -498,7 +506,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">State/Region</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.state')}</label>
<input
type="text"
value={formData.address.state}
@ -511,7 +519,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Country *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.country')}</label>
<input
type="text"
required
@ -526,7 +534,7 @@ export default function AdminOrganizationsPage() {
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Logo URL</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.logoUrl')}</label>
<input
type="url"
value={formData.logoUrl}
@ -547,13 +555,13 @@ export default function AdminOrganizationsPage() {
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Cancel
{t('modal.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{showCreateModal ? 'Create' : 'Update'}
{showCreateModal ? t('modal.create') : t('modal.update')}
</button>
</div>
</form>

View File

@ -1,10 +1,12 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin';
import { createUser } from '@/lib/api/users';
import { getAllOrganizations } from '@/lib/api/admin';
import type { UserRole } from '@/types/api';
import { PageHeader } from '@/components/ui/PageHeader';
interface User {
id: string;
@ -24,6 +26,8 @@ interface Organization {
}
export default function AdminUsersPage() {
const t = useTranslations('dashboard.admin.users');
const [users, setUsers] = useState<User[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
@ -50,7 +54,6 @@ export default function AdminUsersPage() {
password: '',
});
// Fetch users and organizations
useEffect(() => {
fetchData();
}, []);
@ -67,7 +70,7 @@ export default function AdminUsersPage() {
setOrganizations(orgsResponse.organizations || []);
setError(null);
} catch (err: any) {
setError(err.message || 'Failed to load data');
setError(err.message || t('loadError'));
} finally {
setLoading(false);
}
@ -81,7 +84,7 @@ export default function AdminUsersPage() {
setShowCreateModal(false);
resetForm();
} catch (err: any) {
alert(err.message || 'Failed to create user');
alert(err.message || t('createError'));
}
};
@ -101,7 +104,7 @@ export default function AdminUsersPage() {
setSelectedUser(null);
resetForm();
} catch (err: any) {
alert(err.message || 'Failed to update user');
alert(err.message || t('updateError'));
}
};
@ -114,7 +117,7 @@ export default function AdminUsersPage() {
setShowDeleteConfirm(false);
setSelectedUser(null);
} catch (err: any) {
alert(err.message || 'Failed to delete user');
alert(err.message || t('deleteError'));
}
};
@ -147,12 +150,20 @@ export default function AdminUsersPage() {
setShowDeleteConfirm(true);
};
const getRoleLabel = (role: string) => {
const allowed = ['USER', 'MANAGER', 'ADMIN', 'VIEWER'];
if (allowed.includes(role)) {
return t(`roles.${role}` as any);
}
return role;
};
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading users...</p>
<p className="mt-4 text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -160,21 +171,18 @@ export default function AdminUsersPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<p className="mt-1 text-sm text-gray-500">
Manage all users in the system
</p>
</div>
<PageHeader
title={t('title')}
description={t('subtitle')}
actions={
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
+ Create User
{t('create')}
</button>
</div>
}
/>
{/* Error Message */}
{error && (
@ -189,22 +197,22 @@ export default function AdminUsersPage() {
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
{t('table.user')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
{t('table.email')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
{t('table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Organization
{t('table.organization')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
{t('table.status')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
{t('table.actions')}
</th>
</tr>
</thead>
@ -225,7 +233,7 @@ export default function AdminUsersPage() {
user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{user.role}
{getRoleLabel(user.role)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@ -235,7 +243,7 @@ export default function AdminUsersPage() {
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{user.isActive ? 'Active' : 'Inactive'}
{user.isActive ? t('active') : t('inactive')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
@ -243,13 +251,13 @@ export default function AdminUsersPage() {
onClick={() => openEditModal(user)}
className="text-blue-600 hover:text-blue-900"
>
Edit
{t('edit')}
</button>
<button
onClick={() => openDeleteConfirm(user)}
className="text-red-600 hover:text-red-900"
>
Delete
{t('delete')}
</button>
</td>
</tr>
@ -262,10 +270,10 @@ export default function AdminUsersPage() {
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4">Create New User</h2>
<h2 className="text-xl font-bold mb-4">{t('modal.createTitle')}</h2>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.email')}</label>
<input
type="email"
required
@ -275,7 +283,7 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">First Name</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label>
<input
type="text"
required
@ -285,7 +293,7 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Last Name</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label>
<input
type="text"
required
@ -295,27 +303,27 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Role</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.role')}</label>
<select
value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="USER">User</option>
<option value="MANAGER">Manager</option>
<option value="ADMIN">Admin</option>
<option value="VIEWER">Viewer</option>
<option value="USER">{t('roles.USER')}</option>
<option value="MANAGER">{t('roles.MANAGER')}</option>
<option value="ADMIN">{t('roles.ADMIN')}</option>
<option value="VIEWER">{t('roles.VIEWER')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Organization</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.organization')}</label>
<select
required
value={formData.organizationId}
onChange={e => setFormData({ ...formData, organizationId: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="">Select Organization</option>
<option value="">{t('modal.selectOrganization')}</option>
{organizations.map(org => (
<option key={org.id} value={org.id}>
{org.name}
@ -325,7 +333,7 @@ export default function AdminUsersPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Password (leave empty for auto-generated)
{t('modal.password')}
</label>
<input
type="password"
@ -343,13 +351,13 @@ export default function AdminUsersPage() {
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Cancel
{t('modal.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Create
{t('modal.create')}
</button>
</div>
</form>
@ -361,10 +369,10 @@ export default function AdminUsersPage() {
{showEditModal && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4">Edit User</h2>
<h2 className="text-xl font-bold mb-4">{t('modal.editTitle')}</h2>
<form onSubmit={handleUpdate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Email (read-only)</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.emailReadOnly')}</label>
<input
type="email"
disabled
@ -373,7 +381,7 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">First Name</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label>
<input
type="text"
required
@ -383,7 +391,7 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Last Name</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label>
<input
type="text"
required
@ -393,16 +401,16 @@ export default function AdminUsersPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Role</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.role')}</label>
<select
value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="USER">User</option>
<option value="MANAGER">Manager</option>
<option value="ADMIN">Admin</option>
<option value="VIEWER">Viewer</option>
<option value="USER">{t('roles.USER')}</option>
<option value="MANAGER">{t('roles.MANAGER')}</option>
<option value="ADMIN">{t('roles.ADMIN')}</option>
<option value="VIEWER">{t('roles.VIEWER')}</option>
</select>
</div>
<div className="flex justify-end space-x-2 pt-4">
@ -415,13 +423,13 @@ export default function AdminUsersPage() {
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Cancel
{t('modal.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Update
{t('modal.update')}
</button>
</div>
</form>
@ -433,10 +441,9 @@ export default function AdminUsersPage() {
{showDeleteConfirm && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4 text-red-600">Confirm Delete</h2>
<h2 className="text-xl font-bold mb-4 text-red-600">{t('deleteConfirm.title')}</h2>
<p className="text-gray-700 mb-6">
Are you sure you want to delete user <strong>{selectedUser.firstName} {selectedUser.lastName}</strong>?
This action cannot be undone.
{t('deleteConfirm.message', { firstName: selectedUser.firstName, lastName: selectedUser.lastName })}
</p>
<div className="flex justify-end space-x-2">
<button
@ -446,13 +453,13 @@ export default function AdminUsersPage() {
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Cancel
{t('deleteConfirm.cancel')}
</button>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
Delete
{t('deleteConfirm.confirm')}
</button>
</div>
</div>

View File

@ -21,9 +21,12 @@ interface BookingForm {
volumeCBM: number;
weightKG: number;
palletCount: number;
priceUSD: number;
priceEUR: number;
primaryCurrency: 'USD' | 'EUR';
freightTotal: number;
freightCurrency: string;
fobTotal: number;
fobCurrency: string;
primaryCurrency: string;
totalPriceForSorting: number;
transitDays: number;
containerType: string;
@ -61,9 +64,12 @@ function NewBookingPageContent() {
volumeCBM: 0,
weightKG: 0,
palletCount: 0,
priceUSD: 0,
priceEUR: 0,
primaryCurrency: 'EUR',
freightTotal: 0,
freightCurrency: 'USD',
fobTotal: 0,
fobCurrency: 'EUR',
primaryCurrency: 'USD',
totalPriceForSorting: 0,
transitDays: 0,
containerType: '',
documents: [],
@ -85,9 +91,12 @@ function NewBookingPageContent() {
volumeCBM: parseFloat(searchParams.get('volumeCBM') || '0'),
weightKG: parseFloat(searchParams.get('weightKG') || '0'),
palletCount: parseInt(searchParams.get('palletCount') || '0'),
priceUSD: rateData.priceUSD,
priceEUR: rateData.priceEUR,
primaryCurrency: rateData.primaryCurrency as 'USD' | 'EUR',
freightTotal: rateData.priceBreakdown.totalFreight,
freightCurrency: rateData.priceBreakdown.freightCurrency,
fobTotal: rateData.priceBreakdown.totalFob,
fobCurrency: rateData.priceBreakdown.fobCurrency,
primaryCurrency: rateData.priceBreakdown.primaryCurrency || 'USD',
totalPriceForSorting: rateData.priceBreakdown.totalPriceForSorting,
transitDays: rateData.transitDays,
containerType: rateData.containerType,
}));
@ -151,6 +160,14 @@ function NewBookingPageContent() {
// Create FormData for multipart upload
const formDataToSend = new FormData();
// Map price breakdown to backend-expected fields — sum all charges per currency
const priceUSD =
(formData.freightCurrency === 'USD' ? formData.freightTotal : 0) +
(formData.fobCurrency === 'USD' ? formData.fobTotal : 0);
const priceEUR =
(formData.freightCurrency === 'EUR' ? formData.freightTotal : 0) +
(formData.fobCurrency === 'EUR' ? formData.fobTotal : 0);
// Append all booking data
formDataToSend.append('carrierName', formData.carrierName);
formDataToSend.append('carrierEmail', formData.carrierEmail);
@ -159,8 +176,8 @@ function NewBookingPageContent() {
formDataToSend.append('volumeCBM', formData.volumeCBM.toString());
formDataToSend.append('weightKG', formData.weightKG.toString());
formDataToSend.append('palletCount', formData.palletCount.toString());
formDataToSend.append('priceUSD', formData.priceUSD.toString());
formDataToSend.append('priceEUR', formData.priceEUR.toString());
formDataToSend.append('priceUSD', priceUSD.toString());
formDataToSend.append('priceEUR', priceEUR.toString());
formDataToSend.append('primaryCurrency', formData.primaryCurrency);
formDataToSend.append('transitDays', formData.transitDays.toString());
formDataToSend.append('containerType', formData.containerType);
@ -346,22 +363,28 @@ function NewBookingPageContent() {
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Prix estimé
</h3>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-sm text-gray-600">Prix en EUR</p>
<p className="text-3xl font-bold text-green-600">
{formatPrice(formData.priceEUR, 'EUR')}
<p className="text-sm text-gray-600">Fret ({formData.freightCurrency})</p>
<p className="text-2xl font-bold text-gray-800">
{formatPrice(formData.freightTotal, formData.freightCurrency)}
</p>
</div>
{formData.priceUSD > 0 && (
{formData.fobTotal > 0 && (
<div className="text-right">
<p className="text-sm text-gray-600">Prix en USD</p>
<p className="text-sm text-gray-600">FOB ({formData.fobCurrency})</p>
<p className="text-xl font-semibold text-gray-700">
{formatPrice(formData.priceUSD, 'USD')}
{formatPrice(formData.fobTotal, formData.fobCurrency)}
</p>
</div>
)}
</div>
<div className="border-t border-green-200 pt-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-700">Prix total</p>
<p className="text-3xl font-bold text-green-600">
{formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')}
</p>
</div>
</div>
</div>
@ -562,10 +585,24 @@ function NewBookingPageContent() {
{formData.documents.length} fichier(s)
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Fret :</span>
<span className="font-semibold text-gray-900">
{formatPrice(formData.freightTotal, formData.freightCurrency)}
</span>
</div>
{formData.fobTotal > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">FOB :</span>
<span className="font-semibold text-gray-900">
{formatPrice(formData.fobTotal, formData.fobCurrency)}
</span>
</div>
)}
<div className="flex justify-between border-t pt-3 mt-3">
<span className="text-gray-900 font-semibold">Prix total :</span>
<span className="text-2xl font-bold text-green-600">
{formatPrice(formData.priceEUR, 'EUR')}
{formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')}
</span>
</div>
</div>

View File

@ -1,17 +1,15 @@
/**
* Booking Detail Page
*
* Display full booking information
*/
'use client';
import { useQuery } from '@tanstack/react-query';
import { getBooking } from '@/lib/api';
import Link from 'next/link';
import { Link } from '@/i18n/navigation';
import { useParams } from 'next/navigation';
import { useTranslations, useLocale } from 'next-intl';
export default function BookingDetailPage() {
const t = useTranslations('dashboard.bookingDetail');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const params = useParams();
const bookingId = params.id as string;
@ -33,10 +31,18 @@ export default function BookingDetailPage() {
return colors[status] || 'bg-gray-100 text-gray-800';
};
const getStatusLabel = (status: string) => {
const key = `status.${status}`;
try {
return t(key as any);
} catch {
return status;
}
};
const downloadPDF = async () => {
try {
// TODO: Implement PDF download functionality
alert('PDF download functionality is not yet implemented');
alert(t('pdfNotImplemented'));
console.log('Download PDF for booking:', bookingId);
} catch (error) {
console.error('Failed to download PDF:', error);
@ -54,12 +60,12 @@ export default function BookingDetailPage() {
if (!booking) {
return (
<div className="text-center py-12">
<h2 className="text-2xl font-semibold text-gray-900">Booking not found</h2>
<h2 className="text-2xl font-semibold text-gray-900">{t('notFound')}</h2>
<Link
href="/dashboard/bookings"
className="mt-4 inline-block text-blue-600 hover:text-blue-700"
>
Back to bookings
{t('back')}
</Link>
</div>
);
@ -67,14 +73,13 @@ export default function BookingDetailPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<Link
href="/dashboard/bookings"
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
>
Back to bookings
{t('back')}
</Link>
<div className="flex items-center space-x-4">
<h1 className="text-2xl font-bold text-gray-900">{booking.bookingNumber}</h1>
@ -83,11 +88,11 @@ export default function BookingDetailPage() {
booking.status
)}`}
>
{booking.status}
{getStatusLabel(booking.status)}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">
Created on {new Date(booking.createdAt).toLocaleDateString()}
{t('createdOn', { date: new Date(booking.createdAt).toLocaleDateString(dateLocale) })}
</p>
</div>
<div className="flex space-x-3">
@ -103,59 +108,56 @@ export default function BookingDetailPage() {
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Download PDF
{t('downloadPdf')}
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Cargo Details */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Cargo Details</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('cargo.title')}</h2>
<dl className="grid grid-cols-1 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Description</dt>
<dt className="text-sm font-medium text-gray-500">{t('cargo.description')}</dt>
<dd className="mt-1 text-sm text-gray-900">{booking.cargoDescription}</dd>
</div>
{booking.specialInstructions && (
<div>
<dt className="text-sm font-medium text-gray-500">Special Instructions</dt>
<dt className="text-sm font-medium text-gray-500">{t('cargo.specialInstructions')}</dt>
<dd className="mt-1 text-sm text-gray-900">{booking.specialInstructions}</dd>
</div>
)}
</dl>
</div>
{/* Containers */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Containers ({booking.containers?.length || 0})
{t('containers.title', { count: booking.containers?.length || 0 })}
</h2>
<div className="space-y-3">
{booking.containers?.map((container, index) => (
<div key={container.id || index} className="border rounded-lg p-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-gray-500">Type</p>
<p className="text-sm font-medium text-gray-500">{t('containers.type')}</p>
<p className="text-sm text-gray-900">{container.type}</p>
</div>
{container.containerNumber && (
<div>
<p className="text-sm font-medium text-gray-500">Container Number</p>
<p className="text-sm font-medium text-gray-500">{t('containers.number')}</p>
<p className="text-sm text-gray-900">{container.containerNumber}</p>
</div>
)}
{container.sealNumber && (
<div>
<p className="text-sm font-medium text-gray-500">Seal Number</p>
<p className="text-sm font-medium text-gray-500">{t('containers.seal')}</p>
<p className="text-sm text-gray-900">{container.sealNumber}</p>
</div>
)}
{container.vgm && (
<div>
<p className="text-sm font-medium text-gray-500">VGM (kg)</p>
<p className="text-sm font-medium text-gray-500">{t('containers.vgm')}</p>
<p className="text-sm text-gray-900">{container.vgm}</p>
</div>
)}
@ -165,47 +167,46 @@ export default function BookingDetailPage() {
</div>
</div>
{/* Shipper & Consignee */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Shipper</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('shipper.title')}</h2>
<dl className="space-y-2">
<div>
<dt className="text-sm font-medium text-gray-500">Name</dt>
<dt className="text-sm font-medium text-gray-500">{t('shipper.name')}</dt>
<dd className="text-sm text-gray-900">{booking.shipper.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Contact</dt>
<dt className="text-sm font-medium text-gray-500">{t('shipper.contact')}</dt>
<dd className="text-sm text-gray-900">{booking.shipper.contactName}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dt className="text-sm font-medium text-gray-500">{t('shipper.email')}</dt>
<dd className="text-sm text-gray-900">{booking.shipper.contactEmail}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Phone</dt>
<dt className="text-sm font-medium text-gray-500">{t('shipper.phone')}</dt>
<dd className="text-sm text-gray-900">{booking.shipper.contactPhone}</dd>
</div>
</dl>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Consignee</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('consignee.title')}</h2>
<dl className="space-y-2">
<div>
<dt className="text-sm font-medium text-gray-500">Name</dt>
<dt className="text-sm font-medium text-gray-500">{t('consignee.name')}</dt>
<dd className="text-sm text-gray-900">{booking.consignee.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Contact</dt>
<dt className="text-sm font-medium text-gray-500">{t('consignee.contact')}</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactName}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dt className="text-sm font-medium text-gray-500">{t('consignee.email')}</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactEmail}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Phone</dt>
<dt className="text-sm font-medium text-gray-500">{t('consignee.phone')}</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactPhone}</dd>
</div>
</dl>
@ -213,11 +214,9 @@ export default function BookingDetailPage() {
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Timeline */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Timeline</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('timeline.title')}</h2>
<div className="flow-root">
<ul className="-mb-8">
<li>
@ -244,9 +243,9 @@ export default function BookingDetailPage() {
</div>
<div className="min-w-0 flex-1 pt-1.5">
<div>
<p className="text-sm text-gray-900 font-medium">Booking Created</p>
<p className="text-sm text-gray-900 font-medium">{t('timeline.created')}</p>
<p className="text-sm text-gray-500">
{new Date(booking.createdAt).toLocaleString()}
{new Date(booking.createdAt).toLocaleString(dateLocale)}
</p>
</div>
</div>
@ -257,18 +256,17 @@ export default function BookingDetailPage() {
</div>
</div>
{/* Quick Info */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Information</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('info.title')}</h2>
<dl className="space-y-3">
<div>
<dt className="text-sm font-medium text-gray-500">Booking ID</dt>
<dt className="text-sm font-medium text-gray-500">{t('info.bookingId')}</dt>
<dd className="mt-1 text-sm text-gray-900">{booking.id}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Last Updated</dt>
<dt className="text-sm font-medium text-gray-500">{t('info.lastUpdated')}</dt>
<dd className="mt-1 text-sm text-gray-900">
{new Date(booking.updatedAt).toLocaleString()}
{new Date(booking.updatedAt).toLocaleString(dateLocale)}
</dd>
</div>
</dl>

View File

@ -1,22 +1,21 @@
/**
* Bookings List Page
*
* Display all bookings (standard + CSV) with filters and search
*/
'use client';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listBookings, listCsvBookings } from '@/lib/api';
import Link from 'next/link';
import { listCsvBookings } from '@/lib/api';
import { Link } from '@/i18n/navigation';
import { Plus, Clock } from 'lucide-react';
import ExportButton from '@/components/ExportButton';
import { useSearchParams } from 'next/navigation';
import { PageHeader } from '@/components/ui/PageHeader';
import { useTranslations, useLocale } from 'next-intl';
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
export default function BookingsListPage() {
const t = useTranslations('dashboard.bookingsList');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState('');
const [searchType, setSearchType] = useState<SearchType>('route');
@ -31,29 +30,24 @@ export default function BookingsListPage() {
}
}, [searchParams]);
// Fetch CSV bookings (fetch all for client-side filtering and pagination)
const { data: csvData, isLoading, error: csvError } = useQuery({
queryKey: ['csv-bookings'],
queryFn: () =>
listCsvBookings({
page: 1,
limit: 1000, // Fetch all bookings for client-side filtering
limit: 1000,
}),
});
// Log errors for debugging
if (csvError) console.error('CSV bookings error:', csvError);
// Filter bookings based on search term, search type, and status
const filterBookings = (bookings: any[]) => {
let filtered = bookings;
// Filter by status first (if status filter is active)
if (statusFilter) {
filtered = filtered.filter((booking: any) => booking.status === statusFilter);
}
// Then filter by search term if provided
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase();
@ -70,7 +64,7 @@ export default function BookingsListPage() {
case 'status':
return booking.status?.toLowerCase().includes(term);
case 'date':
const date = new Date(booking.requestedPickupDate || booking.requestedAt).toLocaleDateString('fr-FR');
const date = new Date(booking.requestedPickupDate || booking.requestedAt).toLocaleDateString(dateLocale);
return date.includes(term);
case 'quote':
return booking.id?.toLowerCase().includes(term) || booking.quoteNumber?.toLowerCase().includes(term);
@ -83,52 +77,42 @@ export default function BookingsListPage() {
return filtered;
};
// Get all filtered bookings
const filteredBookings = filterBookings((csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const })));
// Calculate pagination
const totalBookings = filteredBookings.length;
const totalPages = Math.ceil(totalBookings / ITEMS_PER_PAGE);
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
// Reset page to 1 when filters change
const resetPage = () => setPage(1);
const statusOptions = [
{ value: '', label: 'Tous les statuts' },
{ value: 'PENDING', label: 'En attente' },
{ value: 'ACCEPTED', label: 'Accepté' },
{ value: 'REJECTED', label: 'Refusé' },
{ value: '', label: t('statusFilter.all') },
{ value: 'PENDING', label: t('status.pending') },
{ value: 'ACCEPTED', label: t('status.accepted') },
{ value: 'REJECTED', label: t('status.rejected') },
];
const searchTypeOptions = [
{ value: 'route', label: 'Route (Origine/Destination)' },
{ value: 'pallets', label: 'Palettes/Colis' },
{ value: 'weight', label: 'Poids (kg)' },
{ value: 'status', label: 'Statut' },
{ value: 'date', label: 'Date' },
{ value: 'quote', label: 'N° Devis' },
const searchTypeOptions: { value: SearchType; label: string }[] = [
{ value: 'route', label: t('searchType.route') },
{ value: 'pallets', label: t('searchType.pallets') },
{ value: 'weight', label: t('searchType.weight') },
{ value: 'status', label: t('searchType.status') },
{ value: 'date', label: t('searchType.date') },
{ value: 'quote', label: t('searchType.quote') },
];
const getPlaceholder = () => {
switch (searchType) {
case 'pallets':
return 'Rechercher par nombre de palettes...';
case 'weight':
return 'Rechercher par poids en kg...';
case 'route':
return 'Rechercher par ville (origine ou destination)...';
case 'status':
return 'Rechercher par statut...';
case 'date':
return 'Rechercher par date (JJ/MM/AAAA)...';
case 'quote':
return 'Rechercher par numéro de devis...';
default:
return 'Rechercher...';
}
const keyMap: Record<SearchType, string> = {
route: 'searchPlaceholder.route',
pallets: 'searchPlaceholder.pallets',
weight: 'searchPlaceholder.weight',
status: 'searchPlaceholder.status',
date: 'searchPlaceholder.date',
quote: 'searchPlaceholder.quote',
};
return t(keyMap[searchType] as any);
};
const getStatusColor = (status: string) => {
@ -141,76 +125,66 @@ export default function BookingsListPage() {
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
const map: Record<string, string> = {
PENDING: t('status.pending'),
ACCEPTED: t('status.accepted'),
REJECTED: t('status.rejected'),
};
return labels[status] || status;
return map[status] || status;
};
return (
<div className="space-y-6">
{/* Bank transfer declared banner */}
{showTransferBanner && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start justify-between">
<div className="flex items-start space-x-3">
<Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-800">Virement déclaré</p>
<p className="font-medium text-amber-800">{t('transferBanner.title')}</p>
<p className="text-sm text-amber-700 mt-0.5">
Votre virement a é enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation.
{t('transferBanner.message')}
</p>
</div>
</div>
<button onClick={() => setShowTransferBanner(false)} className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0"></button>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Réservations</h1>
<p className="text-sm text-gray-500 mt-1">Gérez et suivez vos envois</p>
</div>
<div className="flex items-center space-x-3">
<PageHeader
title={t('title')}
description={t('description')}
actions={
<>
<ExportButton
data={filteredBookings}
filename="reservations"
filename={t('exportFilename')}
columns={[
{ key: 'id', label: 'ID' },
{ key: 'palletCount', label: 'Palettes', format: (v) => `${v || 0}` },
{ key: 'weightKG', label: 'Poids (kg)', format: (v) => `${v || 0}` },
{ key: 'volumeCBM', label: 'Volume (CBM)', format: (v) => `${v || 0}` },
{ key: 'origin', label: 'Origine' },
{ key: 'destination', label: 'Destination' },
{ key: 'carrierName', label: 'Transporteur' },
{ key: 'status', label: 'Statut', format: (v) => {
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
};
return labels[v] || v;
}},
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
{ key: 'id', label: t('export.id') },
{ key: 'palletCount', label: t('export.pallets'), format: (v) => `${v || 0}` },
{ key: 'weightKG', label: t('export.weight'), format: (v) => `${v || 0}` },
{ key: 'volumeCBM', label: t('export.volume'), format: (v) => `${v || 0}` },
{ key: 'origin', label: t('export.origin') },
{ key: 'destination', label: t('export.destination') },
{ key: 'carrierName', label: t('export.carrier') },
{ key: 'status', label: t('export.status'), format: (v) => getStatusLabel(v) },
{ key: 'createdAt', label: t('export.createdAt'), format: (v) => v ? new Date(v).toLocaleDateString(dateLocale) : '' },
]}
/>
<Link
href="/dashboard/search-advanced"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
className="inline-flex items-center px-3 sm:px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<Plus className="mr-2 h-4 w-4" />
Nouvelle Réservation
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">{t('new')}</span>
</Link>
</div>
</div>
</>
}
/>
{/* Filters */}
<div className="bg-white rounded-lg shadow p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label htmlFor="searchType" className="sr-only">
Type de recherche
{t('searchType.label')}
</label>
<select
id="searchType"
@ -230,7 +204,7 @@ export default function BookingsListPage() {
</div>
<div className="md:col-span-2">
<label htmlFor="search" className="sr-only">
Rechercher
{t('search')}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@ -263,7 +237,7 @@ export default function BookingsListPage() {
</div>
<div>
<label htmlFor="status" className="sr-only">
Statut
{t('statusFilter.label')}
</label>
<select
id="status"
@ -284,39 +258,91 @@ export default function BookingsListPage() {
</div>
</div>
{/* Bookings Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
{isLoading ? (
<div className="px-6 py-12 text-center text-gray-500">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
Chargement des réservations...
{t('loading')}
</div>
) : paginatedBookings && paginatedBookings.length > 0 ? (
<>
<div className="overflow-x-auto">
<div className="md:hidden divide-y divide-gray-200">
{paginatedBookings.map((booking: any) => (
<div key={`${booking.type}-${booking.id}`} className="p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<div className="text-sm font-semibold text-gray-900">
{booking.type === 'csv'
? `${booking.origin}${booking.destination}`
: booking.route || 'N/A'}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{booking.type === 'csv' ? booking.carrierName : booking.carrier || ''}
</div>
</div>
<span className={`px-2 py-1 text-xs font-semibold rounded-full flex-shrink-0 ${getStatusColor(booking.status)}`}>
{getStatusLabel(booking.status)}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.pallets')}</div>
<div className="font-medium text-gray-900 mt-0.5">
{booking.type === 'csv'
? t('units.palletsShort', { count: booking.palletCount })
: t('units.containersShort', { count: booking.containers?.length || 0 })}
</div>
</div>
<div>
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.weight')}</div>
<div className="font-medium text-gray-900 mt-0.5">
{booking.type === 'csv'
? t('units.kg', { value: booking.weightKG })
: booking.totalWeight ? t('units.kg', { value: booking.totalWeight }) : 'N/A'}
</div>
</div>
<div>
<div className="text-gray-400 uppercase tracking-wide">{t('mobile.date')}</div>
<div className="font-medium text-gray-900 mt-0.5">
{(booking.createdAt || booking.requestedAt)
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(dateLocale, { day: '2-digit', month: '2-digit', year: '2-digit' })
: 'N/A'}
</div>
</div>
</div>
<div className="text-xs text-gray-400">
{booking.type === 'csv'
? t('mobile.ref', { id: booking.bookingId || booking.id.slice(0, 8).toUpperCase() })
: t('mobile.booking', { number: booking.bookingNumber || '-' })}
</div>
</div>
))}
</div>
<div className="hidden md:block overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Palettes/Colis
{t('columns.palletsPackages')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Poids
{t('columns.weight')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Route
{t('columns.route')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
{t('columns.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
{t('columns.date')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Devis
{t('columns.quoteNumber')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Booking
{t('columns.bookingNumber')}
</th>
</tr>
</thead>
@ -326,8 +352,8 @@ export default function BookingsListPage() {
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{booking.type === 'csv'
? `${booking.palletCount} palette${booking.palletCount > 1 ? 's' : ''}`
: `${booking.containers?.length || 0} conteneur${booking.containers?.length > 1 ? 's' : ''}`}
? t('units.palletsCount', { count: booking.palletCount })
: t('units.containersCount', { count: booking.containers?.length || 0 })}
</div>
<div className="text-xs text-gray-500">
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
@ -336,16 +362,16 @@ export default function BookingsListPage() {
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{booking.type === 'csv'
? `${booking.weightKG} kg`
? t('units.kg', { value: booking.weightKG })
: booking.totalWeight
? `${booking.totalWeight} kg`
? t('units.kg', { value: booking.totalWeight })
: 'N/A'}
</div>
<div className="text-xs text-gray-500">
{booking.type === 'csv'
? `${booking.volumeCBM} CBM`
? t('units.cbm', { value: booking.volumeCBM })
: booking.totalVolume
? `${booking.totalVolume} CBM`
? t('units.cbm', { value: booking.totalVolume })
: ''}
</div>
</td>
@ -372,7 +398,7 @@ export default function BookingsListPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{(booking.createdAt || booking.requestedAt)
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString('fr-FR', {
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(dateLocale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
@ -393,7 +419,6 @@ export default function BookingsListPage() {
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
@ -402,22 +427,25 @@ export default function BookingsListPage() {
disabled={page === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
>
Précédent
{t('pagination.previous')}
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page >= totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
>
Suivant
{t('pagination.next')}
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
<span className="font-medium">{Math.min(endIndex, totalBookings)}</span> sur{' '}
<span className="font-medium">{totalBookings}</span> résultat{totalBookings > 1 ? 's' : ''}
{t.rich('pagination.showing', {
start: startIndex + 1,
end: Math.min(endIndex, totalBookings),
total: totalBookings,
b: (chunks) => <span className="font-medium">{chunks}</span>,
})}
</p>
</div>
<div>
@ -427,16 +455,14 @@ export default function BookingsListPage() {
disabled={page === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
>
<span className="sr-only">Précédent</span>
<span className="sr-only">{t('pagination.previous')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</button>
{/* Page numbers */}
{[...Array(totalPages)].map((_, idx) => {
const pageNum = idx + 1;
// Show first page, last page, current page, and pages around current
const showPage = pageNum === 1 ||
pageNum === totalPages ||
(pageNum >= page - 1 && pageNum <= page + 1);
@ -469,7 +495,7 @@ export default function BookingsListPage() {
disabled={page >= totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
>
<span className="sr-only">Suivant</span>
<span className="sr-only">{t('pagination.next')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
@ -495,11 +521,11 @@ export default function BookingsListPage() {
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucune réservation trouvée</h3>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('empty.title')}</h3>
<p className="mt-1 text-sm text-gray-500">
{searchTerm || statusFilter
? 'Essayez d\'ajuster vos filtres'
: 'Commencez par créer votre première réservation'}
? t('empty.hasFilters')
: t('empty.noBookings')}
</p>
<div className="mt-6">
<Link
@ -507,7 +533,7 @@ export default function BookingsListPage() {
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<Plus className="mr-2 h-4 w-4" />
Nouvelle Réservation
{t('new')}
</Link>
</div>
</div>

View File

@ -1,10 +1,12 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
import type { ReactNode } from 'react';
import ExportButton from '@/components/ExportButton';
import { PageHeader } from '@/components/ui/PageHeader';
interface Document {
id: string;
@ -14,7 +16,6 @@ interface Document {
mimeType: string;
size: number;
uploadedAt?: Date;
// Legacy fields for compatibility
name?: string;
url?: string;
}
@ -29,6 +30,8 @@ interface DocumentWithBooking extends Document {
}
export default function UserDocumentsPage() {
const t = useTranslations('dashboard.userDocuments');
const locale = useLocale();
const [bookings, setBookings] = useState<CsvBookingResponse[]>([]);
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
const [loading, setLoading] = useState(true);
@ -39,41 +42,27 @@ export default function UserDocumentsPage() {
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
// Modal state for adding documents
const [showAddModal, setShowAddModal] = useState(false);
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
const [uploadingFiles, setUploadingFiles] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Modal state for replacing documents
const [showReplaceModal, setShowReplaceModal] = useState(false);
const [documentToReplace, setDocumentToReplace] = useState<DocumentWithBooking | null>(null);
const [replacingFile, setReplacingFile] = useState(false);
const replaceFileInputRef = useRef<HTMLInputElement>(null);
// Dropdown menu state
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
// Helper function to get formatted quote number
const getQuoteNumber = (booking: CsvBookingResponse): string => {
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
};
// Get file extension and type
const getFileType = (fileName: string): string => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const typeMap: Record<string, string> = {
pdf: 'PDF',
doc: 'Word',
docx: 'Word',
xls: 'Excel',
xlsx: 'Excel',
jpg: 'Image',
jpeg: 'Image',
png: 'Image',
gif: 'Image',
txt: 'Text',
csv: 'CSV',
pdf: 'PDF', doc: 'Word', docx: 'Word', xls: 'Excel', xlsx: 'Excel',
jpg: 'Image', jpeg: 'Image', png: 'Image', gif: 'Image', txt: 'Text', csv: 'CSV',
};
return typeMap[ext] || ext.toUpperCase();
};
@ -81,23 +70,19 @@ export default function UserDocumentsPage() {
const fetchBookingsAndDocuments = useCallback(async () => {
try {
setLoading(true);
// Fetch all user's bookings (paginated, get all pages)
const response = await listCsvBookings({ page: 1, limit: 1000 });
const allBookings = response.bookings || [];
setBookings(allBookings);
// Extract all documents from all bookings
const allDocuments: DocumentWithBooking[] = [];
allBookings.forEach((booking: CsvBookingResponse) => {
if (booking.documents && booking.documents.length > 0) {
booking.documents.forEach((doc: any, index: number) => {
// Use the correct field names from the backend
const actualFileName = doc.fileName || doc.name || 'document';
const actualFilePath = doc.filePath || doc.url || '';
const actualMimeType = doc.mimeType || doc.type || '';
// Extract clean file type from mimeType or fileName
let fileType = '';
if (actualMimeType.includes('/')) {
const parts = actualMimeType.split('/');
@ -128,17 +113,16 @@ export default function UserDocumentsPage() {
setDocuments(allDocuments);
setError(null);
} catch (err: any) {
setError(err.message || 'Erreur lors du chargement des documents');
setError(err.message || t('error'));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
fetchBookingsAndDocuments();
}, [fetchBookingsAndDocuments]);
// Filter documents
const filteredDocuments = documents.filter(doc => {
const matchesSearch =
searchTerm === '' ||
@ -156,13 +140,11 @@ export default function UserDocumentsPage() {
return matchesSearch && matchesStatus && matchesQuote;
});
// Pagination
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
// Reset to page 1 when filters change
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, filterStatus, filterQuoteNumber]);
@ -205,18 +187,12 @@ export default function UserDocumentsPage() {
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
CANCELLED: 'Annulé',
};
return labels[status] || status;
const key = status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
return t(`statuses.${key}` as any) || status;
};
const handleDownload = async (url: string, fileName: string) => {
try {
// Try direct download first
const link = document.createElement('a');
link.href = url;
link.download = fileName;
@ -226,18 +202,10 @@ export default function UserDocumentsPage() {
link.click();
document.body.removeChild(link);
// If direct download doesn't work, try fetch with blob
setTimeout(async () => {
try {
const response = await fetch(url, {
mode: 'cors',
credentials: 'include',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const response = await fetch(url, { mode: 'cors', credentials: 'include' });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link2 = document.createElement('a');
@ -251,15 +219,12 @@ export default function UserDocumentsPage() {
console.error('Fetch download failed:', fetchError);
}
}, 100);
} catch (error) {
console.error('Error downloading file:', error);
alert(
`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
);
} catch (err) {
console.error('Error downloading file:', err);
alert(`${t('downloadError')}: ${err instanceof Error ? err.message : ''}`);
}
};
// Get bookings available for adding documents (PENDING or ACCEPTED)
const bookingsAvailableForDocuments = bookings.filter(
b => b.status === 'PENDING' || b.status === 'ACCEPTED'
);
@ -271,14 +236,12 @@ export default function UserDocumentsPage() {
const handleCloseModal = () => {
setShowAddModal(false);
setSelectedBookingId(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleFileUpload = async () => {
if (!selectedBookingId || !fileInputRef.current?.files?.length) {
alert('Veuillez sélectionner une réservation et au moins un fichier');
alert(t('addDocument.noBookingError'));
return;
}
@ -293,50 +256,34 @@ export default function UserDocumentsPage() {
const token = localStorage.getItem('access_token');
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
}
{ method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData }
);
if (!response.ok) {
throw new Error('Erreur lors de l\'ajout des documents');
}
if (!response.ok) throw new Error(t('addDocument.errorMessage'));
alert('Documents ajoutés avec succès!');
alert(t('addDocument.successMessage'));
handleCloseModal();
fetchBookingsAndDocuments(); // Refresh the list
} catch (error) {
console.error('Error uploading documents:', error);
alert(
`Erreur lors de l'ajout des documents: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
);
fetchBookingsAndDocuments();
} catch (err) {
console.error('Error uploading documents:', err);
alert(`${t('addDocument.errorMessage')}: ${err instanceof Error ? err.message : ''}`);
} finally {
setUploadingFiles(false);
}
};
// Toggle dropdown menu
const toggleDropdown = (docId: string) => {
setOpenDropdownId(openDropdownId === docId ? null : docId);
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = () => {
setOpenDropdownId(null);
};
const handleClickOutside = () => setOpenDropdownId(null);
if (openDropdownId) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [openDropdownId]);
// Replace document handlers
const handleReplaceClick = (doc: DocumentWithBooking) => {
setOpenDropdownId(null);
setDocumentToReplace(doc);
@ -346,14 +293,12 @@ export default function UserDocumentsPage() {
const handleCloseReplaceModal = () => {
setShowReplaceModal(false);
setDocumentToReplace(null);
if (replaceFileInputRef.current) {
replaceFileInputRef.current.value = '';
}
if (replaceFileInputRef.current) replaceFileInputRef.current.value = '';
};
const handleReplaceDocument = async () => {
if (!documentToReplace || !replaceFileInputRef.current?.files?.length) {
alert('Veuillez sélectionner un fichier de remplacement');
alert(t('replaceDocument.noFileError'));
return;
}
@ -365,28 +310,20 @@ export default function UserDocumentsPage() {
const token = localStorage.getItem('access_token');
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
}
{ method: 'PATCH', headers: { Authorization: `Bearer ${token}` }, body: formData }
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Erreur lors du remplacement du document');
throw new Error(errorData.message || t('replaceDocument.errorMessage'));
}
alert('Document remplacé avec succès!');
alert(t('replaceDocument.successMessage'));
handleCloseReplaceModal();
fetchBookingsAndDocuments(); // Refresh the list
} catch (error) {
console.error('Error replacing document:', error);
alert(
`Erreur lors du remplacement: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
);
fetchBookingsAndDocuments();
} catch (err) {
console.error('Error replacing document:', err);
alert(`${t('replaceDocument.errorMessage')}: ${err instanceof Error ? err.message : ''}`);
} finally {
setReplacingFile(false);
}
@ -397,7 +334,7 @@ export default function UserDocumentsPage() {
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Chargement des documents...</p>
<p className="mt-4 text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -405,68 +342,52 @@ export default function UserDocumentsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Mes Documents</h1>
<p className="mt-1 text-sm text-gray-500">
Gérez tous les documents de vos réservations
</p>
</div>
<div className="flex items-center space-x-3">
<PageHeader
title={t('title')}
description={t('description')}
actions={
<>
<ExportButton
data={filteredDocuments}
filename="documents"
columns={[
{ key: 'fileName', label: 'Nom du fichier' },
{ key: 'fileType', label: 'Type' },
{ key: 'quoteNumber', label: 'N° de Devis' },
{ key: 'route', label: 'Route' },
{ key: 'carrierName', label: 'Transporteur' },
{ key: 'status', label: 'Statut', format: (v) => {
const labels: Record<string, string> = {
PENDING: 'En attente',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
CANCELLED: 'Annulé',
};
return labels[v] || v;
}},
{ key: 'uploadedAt', label: 'Date d\'ajout', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
{ key: 'fileName', label: t('export.fileName') },
{ key: 'fileType', label: t('export.type') },
{ key: 'quoteNumber', label: t('export.quoteNumber') },
{ key: 'route', label: t('export.route') },
{ key: 'carrierName', label: t('export.carrier') },
{ key: 'status', label: t('export.status'), format: (v) => getStatusLabel(v) },
{ key: 'uploadedAt', label: t('export.uploadedAt'), format: (v) => v ? new Date(v).toLocaleDateString(locale) : '' },
]}
/>
<button
onClick={handleAddDocumentClick}
disabled={bookingsAvailableForDocuments.length === 0}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="inline-flex items-center px-3 sm:px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
<svg className="w-4 h-4 sm:mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Ajouter un document
<span className="hidden sm:inline">{t('addDocument.buttonLabel')}</span>
</button>
</div>
</div>
</>
}
/>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Total Documents</div>
<div className="text-sm text-gray-500">{t('stats.total')}</div>
<div className="text-2xl font-bold text-gray-900">{documents.length}</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Réservations avec Documents</div>
<div className="text-sm text-gray-500">{t('stats.withDocuments')}</div>
<div className="text-2xl font-bold text-blue-600">
{bookings.filter(b => b.documents && b.documents.length > 0).length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Documents Filtrés</div>
<div className="text-sm text-gray-500">{t('stats.filtered')}</div>
<div className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div>
</div>
</div>
@ -475,17 +396,17 @@ export default function UserDocumentsPage() {
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Recherche</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.search')}</label>
<input
type="text"
placeholder="Nom, type, route, transporteur..."
placeholder={t('filters.searchPlaceholder')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Numéro de Devis</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.quoteNumber')}</label>
<input
type="text"
placeholder="Ex: #F2CAD5E1"
@ -495,16 +416,16 @@ export default function UserDocumentsPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Statut</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('filters.status')}</label>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="all">Tous les statuts</option>
<option value="PENDING">En attente</option>
<option value="ACCEPTED">Accepté</option>
<option value="REJECTED">Refusé</option>
<option value="all">{t('filters.allStatuses')}</option>
<option value="PENDING">{t('statuses.PENDING')}</option>
<option value="ACCEPTED">{t('statuses.ACCEPTED')}</option>
<option value="REJECTED">{t('statuses.REJECTED')}</option>
</select>
</div>
</div>
@ -522,36 +443,20 @@ export default function UserDocumentsPage() {
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Nom du Document
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N° de Devis
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Route
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Transporteur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.documentName')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.type')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.quoteNumber')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.route')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.carrier')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.status')}</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.actions')}</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedDocuments.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
{documents.length === 0
? 'Aucun document trouvé. Ajoutez des documents à vos réservations.'
: 'Aucun document ne correspond aux filtres sélectionnés.'}
{documents.length === 0 ? t('empty.noDocuments') : t('empty.noMatch')}
</td>
</tr>
) : (
@ -562,9 +467,7 @@ export default function UserDocumentsPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="mr-2">
{getDocumentIcon(doc.fileType || doc.type)}
</span>
<span className="mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
</div>
</td>
@ -578,9 +481,7 @@ export default function UserDocumentsPage() {
<div className="text-sm text-gray-900">{doc.carrierName}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}
>
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}>
{getStatusLabel(doc.status)}
</span>
</td>
@ -592,20 +493,15 @@ export default function UserDocumentsPage() {
toggleDropdown(`${doc.bookingId}-${doc.id}`);
}}
className="inline-flex items-center justify-center w-8 h-8 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title="Actions"
>
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
title={t('table.actions')}
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="5" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="12" cy="19" r="2" />
</svg>
</button>
{/* Dropdown Menu */}
{openDropdownId === `${doc.bookingId}-${doc.id}` && (
<div
className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"
@ -619,39 +515,19 @@ export default function UserDocumentsPage() {
}}
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<svg
className="w-4 h-4 mr-3 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
<svg className="w-4 h-4 mr-3 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Télécharger
{t('actions.download')}
</button>
<button
onClick={() => handleReplaceClick(doc)}
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<svg
className="w-4 h-4 mr-3 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
<svg className="w-4 h-4 mr-3 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Remplacer
{t('actions.replace')}
</button>
</div>
</div>
@ -673,29 +549,29 @@ export default function UserDocumentsPage() {
disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Précédent
{t('pagination.previous')}
</button>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Suivant
{t('pagination.next')}
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
<span className="font-medium">
{Math.min(endIndex, filteredDocuments.length)}
</span>{' '}
sur <span className="font-medium">{filteredDocuments.length}</span> résultats
{t('pagination.showing', {
from: startIndex + 1,
to: Math.min(endIndex, filteredDocuments.length),
total: filteredDocuments.length,
})}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700">Par page:</label>
<label className="text-sm text-gray-700">{t('pagination.perPage')}</label>
<select
value={itemsPerPage}
onChange={e => {
@ -711,26 +587,18 @@ export default function UserDocumentsPage() {
<option value={100}>100</option>
</select>
</div>
<nav
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">Précédent</span>
<span className="sr-only">{t('pagination.previous')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clipRule="evenodd"
/>
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</button>
{/* Page numbers */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
@ -763,13 +631,9 @@ export default function UserDocumentsPage() {
disabled={currentPage === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">Suivant</span>
<span className="sr-only">{t('pagination.next')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</button>
</nav>
@ -783,57 +647,35 @@ export default function UserDocumentsPage() {
{showAddModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
{/* Background overlay */}
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={handleCloseModal}
/>
{/* Modal panel */}
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={handleCloseModal} />
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<svg
className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Ajouter un document
</h3>
<h3 className="text-lg leading-6 font-medium text-gray-900">{t('addDocument.modalTitle')}</h3>
<div className="mt-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sélectionner une réservation
</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('addDocument.selectBooking')}</label>
<select
value={selectedBookingId || ''}
onChange={e => setSelectedBookingId(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="">-- Choisir une réservation --</option>
<option value="">{t('addDocument.selectBookingPlaceholder')}</option>
{bookingsAvailableForDocuments.map(booking => (
<option key={booking.id} value={booking.id}>
{getQuoteNumber(booking)} - {booking.origin} {booking.destination} ({booking.status === 'PENDING' ? 'En attente' : 'Accepté'})
{getQuoteNumber(booking)} - {booking.origin} {booking.destination} ({booking.status === 'PENDING' ? t('statuses.PENDING') : t('statuses.ACCEPTED')})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Fichiers à ajouter
</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('addDocument.filesToAdd')}</label>
<input
ref={fileInputRef}
type="file"
@ -841,9 +683,7 @@ export default function UserDocumentsPage() {
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
<p className="mt-1 text-xs text-gray-500">
Formats acceptés: PDF, Word, Excel, Images (max 10 fichiers)
</p>
<p className="mt-1 text-xs text-gray-500">{t('addDocument.acceptedFormats')}</p>
</div>
</div>
</div>
@ -858,37 +698,20 @@ export default function UserDocumentsPage() {
>
{uploadingFiles ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Envoi en cours...
{t('addDocument.uploading')}
</>
) : (
'Ajouter'
)}
) : t('addDocument.add')}
</button>
<button
type="button"
onClick={handleCloseModal}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Annuler
{t('addDocument.cancel')}
</button>
</div>
</div>
@ -900,60 +723,34 @@ export default function UserDocumentsPage() {
{showReplaceModal && documentToReplace && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
{/* Background overlay */}
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={handleCloseReplaceModal}
/>
{/* Modal panel */}
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={handleCloseReplaceModal} />
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<svg
className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Remplacer le document
</h3>
<h3 className="text-lg leading-6 font-medium text-gray-900">{t('replaceDocument.modalTitle')}</h3>
<div className="mt-4 space-y-4">
{/* Current document info */}
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-sm text-gray-500">Document actuel:</p>
<p className="text-sm font-medium text-gray-900 mt-1">
{documentToReplace.fileName}
</p>
<p className="text-sm text-gray-500">{t('replaceDocument.currentDocument')}</p>
<p className="text-sm font-medium text-gray-900 mt-1">{documentToReplace.fileName}</p>
<p className="text-xs text-gray-500 mt-1">
Réservation: {documentToReplace.quoteNumber} - {documentToReplace.route}
{t('replaceDocument.booking')}: {documentToReplace.quoteNumber} - {documentToReplace.route}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nouveau fichier
</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('replaceDocument.newFile')}</label>
<input
ref={replaceFileInputRef}
type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
<p className="mt-1 text-xs text-gray-500">
Formats acceptés: PDF, Word, Excel, Images
</p>
<p className="mt-1 text-xs text-gray-500">{t('replaceDocument.acceptedFormats')}</p>
</div>
</div>
</div>
@ -968,37 +765,20 @@ export default function UserDocumentsPage() {
>
{replacingFile ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Remplacement en cours...
{t('replaceDocument.replacing')}
</>
) : (
'Remplacer'
)}
) : t('replaceDocument.replace')}
</button>
<button
type="button"
onClick={handleCloseReplaceModal}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Annuler
{t('replaceDocument.cancel')}
</button>
</div>
</div>

View File

@ -1,16 +1,11 @@
/**
* Dashboard Layout
*
* Layout with sidebar navigation for dashboard pages
*/
'use client';
import { useAuth } from '@/lib/context/auth-context';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { Link, usePathname, useRouter } from '@/i18n/navigation';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import NotificationDropdown from '@/components/NotificationDropdown';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
import Image from 'next/image';
import {
@ -24,6 +19,8 @@ import {
LogOut,
Lock,
Key,
Home,
User,
} from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context';
import StatusBadge from '@/components/ui/StatusBadge';
@ -34,6 +31,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const { hasFeature, subscription } = useSubscription();
const pathname = usePathname();
const router = useRouter();
const t = useTranslations('dashboard');
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
@ -55,16 +53,15 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
}
const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: PlanFeature }> = [
{ name: 'Tableau de bord', href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
{ name: 'Réservations', href: '/dashboard/bookings', icon: Package },
{ name: 'Documents', href: '/dashboard/documents', icon: FileText },
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' },
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
{ name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 },
{ name: 'Clés API', href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature },
// ADMIN and MANAGER only navigation items
{ name: t('nav.dashboard'), href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
{ name: t('nav.bookings'), href: '/dashboard/bookings', icon: Package },
{ name: t('nav.documents'), href: '/dashboard/documents', icon: FileText },
{ name: t('nav.tracking'), href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' },
{ name: t('nav.wiki'), href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
{ name: t('nav.organization'), href: '/dashboard/settings/organization', icon: Building2 },
{ name: t('nav.apiKeys'), href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature },
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
{ name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature },
{ name: t('nav.users'), href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature },
] : []),
];
@ -77,7 +74,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return (
<div className="min-h-screen bg-gray-50">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
@ -85,14 +81,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
/>
)}
{/* Sidebar */}
<div
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex flex-col h-full">
{/* Logo */}
<div className="flex items-center justify-between h-16 px-6 border-b">
<Link href="/dashboard" className="text-2xl font-bold text-blue-600">
<Image
@ -119,7 +113,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto">
{navigation.map(item => {
const locked = item.requiredFeature && !hasFeature(item.requiredFeature);
@ -142,7 +135,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
);
})}
{/* Admin Panel - ADMIN role only */}
{user?.role === 'ADMIN' && (
<div className="pt-4 mt-4 border-t">
<AdminPanelDropdown />
@ -150,7 +142,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
)}
</nav>
{/* User section */}
<div className="border-t p-4">
<div className="flex items-center space-x-3 mb-4">
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-semibold">
@ -174,18 +165,16 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
>
<LogOut className="w-4 h-4 mr-2" />
Déconnexion
{t('logout')}
</button>
</div>
</div>
</div>
{/* Main content */}
<div className="lg:pl-64">
{/* Top bar */}
<div className="sticky top-0 z-10 flex items-center justify-between h-16 px-6 bg-white border-b">
<div className="sticky top-0 z-10 flex items-center justify-between h-14 lg:h-16 px-4 lg:px-6 bg-white border-b">
<button
className="lg:hidden text-gray-500 hover:text-gray-700"
className="lg:hidden text-gray-500 hover:text-gray-700 p-1"
onClick={() => setSidebarOpen(true)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -198,24 +187,48 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</svg>
</button>
<div className="flex-1 lg:flex-none">
<h1 className="text-xl font-semibold text-gray-900">
{navigation.find(item => isActive(item.href))?.name || 'Tableau de bord'}
<h1 className="text-base lg:text-xl font-semibold text-gray-900 ml-3 lg:ml-0">
{navigation.find(item => isActive(item.href))?.name || t('topbar.defaultTitle')}
</h1>
</div>
<div className="flex items-center space-x-4">
{/* Notifications */}
<div className="flex items-center space-x-3 lg:space-x-4">
<LanguageSwitcher variant="light" />
<NotificationDropdown />
{/* User Initials */}
<Link href="/dashboard/profile" className="w-9 h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors">
<Link href="/dashboard/profile" className="w-8 h-8 lg:w-9 lg:h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors">
{user?.firstName?.[0]}{user?.lastName?.[0]}
</Link>
</div>
</div>
{/* Page content */}
<main className="p-6">{children}</main>
<main className="p-4 lg:p-6 pb-24 lg:pb-6">{children}</main>
</div>
<nav className="fixed bottom-0 left-0 right-0 z-30 bg-white border-t border-gray-200 lg:hidden">
<div className="grid grid-cols-5 h-16">
{[
{ href: '/dashboard', icon: Home, label: t('bottomNav.home') },
{ href: '/dashboard/bookings', icon: Package, label: t('bottomNav.bookings') },
{ href: '/dashboard/documents', icon: FileText, label: t('bottomNav.documents') },
{ href: '/dashboard/track-trace', icon: Search, label: t('bottomNav.tracking') },
{ href: '/dashboard/profile', icon: User, label: t('bottomNav.profile') },
].map((item) => {
const active = item.href === '/dashboard' ? pathname === item.href : pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center justify-center space-y-0.5 transition-colors ${
active ? 'text-blue-600' : 'text-gray-500 hover:text-gray-700'
}`}
>
<item.icon className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{item.label}</span>
</Link>
);
})}
</div>
</nav>
</div>
);
}

View File

@ -1,14 +1,9 @@
/**
* Notifications Page
*
* Full page view for managing all user notifications
*/
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useTranslations, useLocale } from 'next-intl';
import {
listNotifications,
markNotificationAsRead,
@ -20,12 +15,14 @@ import { Trash2, CheckCheck, Filter, Bell, ChevronLeft, ChevronRight, Package, R
import type { ReactNode } from 'react';
export default function NotificationsPage() {
const t = useTranslations('dashboard.notificationsPage');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
const [currentPage, setCurrentPage] = useState(1);
const queryClient = useQueryClient();
const router = useRouter();
// Fetch notifications with pagination
const { data, isLoading } = useQuery({
queryKey: ['notifications', 'page', selectedFilter, currentPage],
queryFn: () =>
@ -41,7 +38,6 @@ export default function NotificationsPage() {
const totalPages = Math.ceil(total / 20);
const unreadCount = notifications.filter((n: NotificationResponse) => !n.read).length;
// Mark single notification as read
const markAsReadMutation = useMutation({
mutationFn: markNotificationAsRead,
onSuccess: () => {
@ -49,7 +45,6 @@ export default function NotificationsPage() {
},
});
// Mark all as read
const markAllAsReadMutation = useMutation({
mutationFn: markAllNotificationsAsRead,
onSuccess: () => {
@ -57,7 +52,6 @@ export default function NotificationsPage() {
},
});
// Delete notification
const deleteNotificationMutation = useMutation({
mutationFn: deleteNotification,
onSuccess: () => {
@ -70,7 +64,6 @@ export default function NotificationsPage() {
markAsReadMutation.mutate(notification.id);
}
// Navigate to actionUrl if available
if (notification.actionUrl) {
router.push(notification.actionUrl);
}
@ -78,7 +71,7 @@ export default function NotificationsPage() {
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
e.stopPropagation();
if (confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')) {
if (confirm(t('deleteConfirm'))) {
deleteNotificationMutation.mutate(notificationId);
}
};
@ -93,6 +86,16 @@ export default function NotificationsPage() {
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-100';
};
const getPriorityLabel = (priority: string) => {
const map: Record<string, string> = {
urgent: t('priority.urgent'),
high: t('priority.high'),
medium: t('priority.medium'),
low: t('priority.low'),
};
return map[priority] || priority.toUpperCase();
};
const getNotificationIcon = (type: string): ReactNode => {
const iconClass = "h-8 w-8";
const icons: Record<string, ReactNode> = {
@ -120,11 +123,11 @@ export default function NotificationsPage() {
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'A l\'instant';
if (diffMins < 60) return `Il y a ${diffMins}min`;
if (diffHours < 24) return `Il y a ${diffHours}h`;
if (diffDays < 7) return `Il y a ${diffDays}j`;
return date.toLocaleDateString('fr-FR', {
if (diffMins < 1) return t('time.now');
if (diffMins < 60) return t('time.minutes', { count: diffMins });
if (diffHours < 24) return t('time.hours', { count: diffHours });
if (diffDays < 7) return t('time.days', { count: diffDays });
return date.toLocaleDateString(dateLocale, {
month: 'long',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
@ -135,7 +138,6 @@ export default function NotificationsPage() {
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
@ -144,10 +146,10 @@ export default function NotificationsPage() {
<Bell className="w-8 h-8 text-blue-600" />
</div>
<div>
<h1 className="text-3xl font-bold text-gray-900">Notifications</h1>
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<p className="text-sm text-gray-600 mt-1">
{total} notification{total !== 1 ? 's' : ''} au total
{unreadCount > 0 && `${unreadCount} non lue${unreadCount > 1 ? 's' : ''}`}
{t('totalLabel', { count: total })}
{unreadCount > 0 && t('unreadSuffix', { count: unreadCount })}
</p>
</div>
</div>
@ -158,20 +160,18 @@ export default function NotificationsPage() {
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<CheckCheck className="w-5 h-5" />
<span>Tout marquer comme lu</span>
<span>{t('markAllRead')}</span>
</button>
)}
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Filter Bar */}
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex items-center space-x-3">
<Filter className="w-5 h-5 text-gray-500" />
<span className="text-sm font-medium text-gray-700">Filtrer :</span>
<span className="text-sm font-medium text-gray-700">{t('filter.label')}</span>
<div className="flex space-x-2">
{(['all', 'unread', 'read'] as const).map((filter) => (
<button
@ -186,7 +186,7 @@ export default function NotificationsPage() {
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{filter === 'all' ? 'Toutes' : filter === 'unread' ? 'Non lues' : 'Lues'}
{t(`filter.${filter}` as any)}
{filter === 'unread' && unreadCount > 0 && (
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-full text-xs">
{unreadCount}
@ -198,24 +198,21 @@ export default function NotificationsPage() {
</div>
</div>
{/* Notifications List */}
<div className="bg-white rounded-lg shadow-sm border">
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4" />
<p className="text-gray-500">Chargement des notifications...</p>
<p className="text-gray-500">{t('loading')}</p>
</div>
</div>
) : notifications.length === 0 ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="mb-4 flex justify-center"><Bell className="h-16 w-16 text-gray-300" /></div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Aucune notification</h3>
<h3 className="text-xl font-semibold text-gray-900 mb-2">{t('empty.title')}</h3>
<p className="text-gray-500">
{selectedFilter === 'unread'
? 'Vous êtes à jour !'
: 'Aucune notification à afficher'}
{selectedFilter === 'unread' ? t('empty.upToDate') : t('empty.none')}
</p>
</div>
</div>
@ -230,12 +227,10 @@ export default function NotificationsPage() {
} ${getPriorityColor(notification.priority || 'low')}`}
>
<div className="flex items-start space-x-4">
{/* Icon */}
<div className="flex-shrink-0 flex items-center justify-center w-12 h-12">
{getNotificationIcon(notification.type)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-3">
@ -245,14 +240,14 @@ export default function NotificationsPage() {
{!notification.read && (
<span className="flex items-center space-x-1 px-2 py-1 bg-blue-600 text-white text-xs font-medium rounded-full">
<span className="w-2 h-2 bg-white rounded-full animate-pulse" />
<span>NOUVEAU</span>
<span>{t('new')}</span>
</span>
)}
</div>
<button
onClick={(e) => handleDelete(e, notification.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg"
title="Supprimer la notification"
title={t('deleteTitle')}
>
<Trash2 className="w-5 h-5 text-red-600" />
</button>
@ -262,7 +257,6 @@ export default function NotificationsPage() {
{notification.message}
</p>
{/* Metadata */}
<div className="flex items-center justify-between">
<div className="flex items-center flex-wrap gap-3 text-xs">
<span className="flex items-center space-x-1 text-gray-600">
@ -296,13 +290,13 @@ export default function NotificationsPage() {
: 'bg-blue-100 text-blue-700'
}`}
>
{notification.priority.toUpperCase()}
{getPriorityLabel(notification.priority)}
</span>
)}
</div>
{notification.actionUrl && (
<span className="text-sm text-blue-600 font-medium group-hover:underline flex items-center space-x-1">
<span>Voir les détails</span>
<span>{t('viewDetails')}</span>
<svg
className="w-4 h-4"
fill="none"
@ -327,15 +321,16 @@ export default function NotificationsPage() {
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-6 bg-white rounded-lg shadow-sm border p-4">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
Page <span className="font-semibold">{currentPage}</span> sur{' '}
<span className="font-semibold">{totalPages}</span>
{' • '}
<span className="font-semibold">{total}</span> notification{total !== 1 ? 's' : ''} au total
{t.rich('pagination.info', {
current: currentPage,
total: totalPages,
items: total,
b: (chunks) => <span className="font-semibold">{chunks}</span>,
})}
</div>
<div className="flex items-center space-x-2">
<button
@ -344,7 +339,7 @@ export default function NotificationsPage() {
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
<span>Précédent</span>
<span>{t('pagination.previous')}</span>
</button>
<div className="flex items-center space-x-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
@ -379,7 +374,7 @@ export default function NotificationsPage() {
disabled={currentPage === totalPages}
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span>Suivant</span>
<span>{t('pagination.next')}</span>
<ChevronRight className="w-4 h-4" />
</button>
</div>

View File

@ -1,18 +1,13 @@
/**
* Dashboard Home Page - Clean & Colorful with Charts
* Professional design with data visualization
*/
'use client';
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { dashboardApi } from '@/lib/api';
import Link from 'next/link';
import { Link, useRouter } from '@/i18n/navigation';
import { useTranslations, useLocale } from 'next-intl';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useRouter } from 'next/navigation';
import {
Package,
PackageCheck,
@ -36,96 +31,92 @@ import {
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
Legend,
} from 'recharts';
export default function DashboardPage() {
const router = useRouter();
const { hasFeature, loading: subLoading } = useSubscription();
const t = useTranslations('dashboard.home');
const locale = useLocale();
// Redirect Bronze users (no dashboard feature) to bookings
useEffect(() => {
if (!subLoading && !hasFeature('dashboard')) {
router.replace('/dashboard/bookings');
}
}, [subLoading, hasFeature, router]);
// Fetch CSV booking KPIs
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
queryKey: ['dashboard', 'csv-booking-kpis'],
queryFn: () => dashboardApi.getCsvBookingKPIs(),
});
// Fetch top carriers
const { data: topCarriers, isLoading: carriersLoading } = useQuery({
queryKey: ['dashboard', 'top-carriers'],
queryFn: () => dashboardApi.getTopCarriers(),
});
// Prepare data for charts
const numberFormat = new Intl.NumberFormat(locale === 'fr' ? 'fr-FR' : 'en-US');
const statusDistribution = csvKpis
? [
{ name: 'Acceptés', value: csvKpis.totalAccepted, color: '#10b981' },
{ name: 'Refusés', value: csvKpis.totalRejected, color: '#ef4444' },
{ name: 'En Attente', value: csvKpis.totalPending, color: '#f59e0b' },
{ name: t('charts.distribution.accepted'), value: csvKpis.totalAccepted, color: '#10b981' },
{ name: t('charts.distribution.rejected'), value: csvKpis.totalRejected, color: '#ef4444' },
{ name: t('charts.distribution.pending'), value: csvKpis.totalPending, color: '#f59e0b' },
]
: [];
const carrierWeightData = topCarriers
? topCarriers.slice(0, 5).map(c => ({
name: c.carrierName.length > 15 ? c.carrierName.substring(0, 15) + '...' : c.carrierName,
poids: Math.round(c.totalWeightKG),
[t('charts.weightByCarrier.weight')]: Math.round(c.totalWeightKG),
}))
: [];
const weightDataKey = t('charts.weightByCarrier.weight');
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-6 py-8 space-y-6">
{/* Header - Compact */}
<div className="flex items-center justify-between pb-4 border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-4 sm:py-8 space-y-4 sm:space-y-6">
<div className="flex items-center justify-between pb-3 sm:pb-4 border-b border-gray-200">
<div>
<h1 className="text-3xl font-semibold text-gray-900">Tableau de Bord</h1>
<p className="text-gray-600 mt-1 text-sm">
Vue d'ensemble de vos réservations et performances
<h1 className="text-xl sm:text-3xl font-semibold text-gray-900">{t('title')}</h1>
<p className="text-gray-600 mt-0.5 sm:mt-1 text-xs sm:text-sm">
{t('subtitle')}
</p>
</div>
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-2 sm:space-x-3">
<ExportButton
data={topCarriers || []}
filename="tableau-de-bord-transporteurs"
filename={t('exportFilename')}
columns={[
{ key: 'carrierName', label: 'Transporteur' },
{ key: 'totalBookings', label: 'Total Réservations' },
{ key: 'acceptedBookings', label: 'Acceptées' },
{ key: 'rejectedBookings', label: 'Refusées' },
{ key: 'totalWeightKG', label: 'Poids Total (KG)', format: (v) => v?.toLocaleString('fr-FR') || '0' },
{ key: 'totalVolumeCBM', label: 'Volume Total (CBM)', format: (v) => v?.toFixed(2) || '0' },
{ key: 'acceptanceRate', label: 'Taux d\'acceptation (%)', format: (v) => v?.toFixed(1) || '0' },
{ key: 'avgPriceUSD', label: 'Prix moyen ($)', format: (v) => v?.toFixed(2) || '0' },
{ key: 'carrierName', label: t('export.carrier') },
{ key: 'totalBookings', label: t('export.totalBookings') },
{ key: 'acceptedBookings', label: t('export.accepted') },
{ key: 'rejectedBookings', label: t('export.rejected') },
{ key: 'totalWeightKG', label: t('export.totalWeight'), format: (v) => v?.toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-US') || '0' },
{ key: 'totalVolumeCBM', label: t('export.totalVolume'), format: (v) => v?.toFixed(2) || '0' },
{ key: 'acceptanceRate', label: t('export.acceptanceRate'), format: (v) => v?.toFixed(1) || '0' },
{ key: 'avgPriceUSD', label: t('export.avgPrice'), format: (v) => v?.toFixed(2) || '0' },
]}
/>
<Link href="/dashboard/search-advanced">
<Button className="bg-blue-600 hover:bg-blue-700 text-white gap-2 shadow-lg text-base px-6 py-5 font-semibold">
<Plus className="h-5 w-5" />
Nouvelle Réservation
<Button className="bg-blue-600 hover:bg-blue-700 text-white gap-2 shadow-lg font-semibold px-3 sm:px-6 py-2 sm:py-5 text-sm sm:text-base">
<Plus className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
<span className="hidden sm:inline">{t('newBooking')}</span>
</Button>
</Link>
</div>
</div>
{/* KPI Cards - Compact with Color */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* Bookings Acceptés */}
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-green-100 flex items-center justify-center mb-2">
<PackageCheck className="h-5 w-5 text-green-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">Acceptés</p>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.accepted')}</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
@ -134,7 +125,7 @@ export default function DashboardPage() {
{csvKpis?.totalAccepted || 0}
</p>
<p className="text-xs text-gray-500 mt-1">
+{csvKpis?.acceptedThisMonth || 0} ce mois
{t('kpi.thisMonth', { count: csvKpis?.acceptedThisMonth || 0 })}
</p>
</>
)}
@ -142,14 +133,13 @@ export default function DashboardPage() {
</CardContent>
</Card>
{/* Bookings Refusés */}
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-red-100 flex items-center justify-center mb-2">
<PackageX className="h-5 w-5 text-red-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">Refusés</p>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.rejected')}</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
@ -158,7 +148,7 @@ export default function DashboardPage() {
{csvKpis?.totalRejected || 0}
</p>
<p className="text-xs text-gray-500 mt-1">
+{csvKpis?.rejectedThisMonth || 0} ce mois
{t('kpi.thisMonth', { count: csvKpis?.rejectedThisMonth || 0 })}
</p>
</>
)}
@ -166,14 +156,13 @@ export default function DashboardPage() {
</CardContent>
</Card>
{/* Bookings En Attente */}
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-amber-100 flex items-center justify-center mb-2">
<Clock className="h-5 w-5 text-amber-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">En Attente</p>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.pending')}</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
@ -182,7 +171,7 @@ export default function DashboardPage() {
{csvKpis?.totalPending || 0}
</p>
<p className="text-xs text-gray-500 mt-1">
{csvKpis?.acceptanceRate.toFixed(1)}% acceptés
{t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })}
</p>
</>
)}
@ -190,20 +179,19 @@ export default function DashboardPage() {
</CardContent>
</Card>
{/* Poids Total */}
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
<Weight className="h-5 w-5 text-blue-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">Poids Total</p>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.totalWeight')}</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
<>
<p className="text-2xl font-bold text-gray-900">
{(csvKpis?.totalWeightAcceptedKG || 0).toLocaleString()}
{numberFormat.format(csvKpis?.totalWeightAcceptedKG || 0)}
</p>
<p className="text-xs text-gray-500 mt-1">
KG {(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)} CBM
@ -215,16 +203,14 @@ export default function DashboardPage() {
</Card>
</div>
{/* Charts Section */}
<div className="grid gap-4 md:grid-cols-2">
{/* Distribution des Statuts - Pie Chart */}
<Card className="border border-gray-200 shadow-sm bg-white">
<CardHeader className="pb-4 border-b border-gray-100">
<CardTitle className="text-base font-semibold text-gray-900">
Distribution des Réservations
{t('charts.distribution.title')}
</CardTitle>
<CardDescription className="text-xs text-gray-600">
Répartition par statut
{t('charts.distribution.description')}
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
@ -256,14 +242,13 @@ export default function DashboardPage() {
</CardContent>
</Card>
{/* Poids par Transporteur - Bar Chart */}
<Card className="border border-gray-200 shadow-sm bg-white">
<CardHeader className="pb-4 border-b border-gray-100">
<CardTitle className="text-base font-semibold text-gray-900">
Poids par Transporteur
{t('charts.weightByCarrier.title')}
</CardTitle>
<CardDescription className="text-xs text-gray-600">
Top 5 transporteurs par poids (KG)
{t('charts.weightByCarrier.description')}
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
@ -282,7 +267,7 @@ export default function DashboardPage() {
/>
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="poids" fill="#3b82f6" radius={[4, 4, 0, 0]} />
<Bar dataKey={weightDataKey} fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
@ -290,7 +275,6 @@ export default function DashboardPage() {
</Card>
</div>
{/* Performance Overview - Compact */}
<div className="grid gap-4 md:grid-cols-3">
<Card className="border border-gray-200 shadow-sm bg-white">
<CardContent className="p-4">
@ -298,9 +282,9 @@ export default function DashboardPage() {
<div className="h-10 w-10 rounded-lg bg-green-100 flex items-center justify-center mb-2">
<TrendingUp className="h-5 w-5 text-green-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">Taux d'Acceptation</p>
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.acceptanceRate')}</p>
<p className="text-2xl font-bold text-gray-900">
{csvKpisLoading ? '--' : `${csvKpis?.acceptanceRate.toFixed(1)}%`}
{csvKpisLoading ? '--' : `${(csvKpis?.acceptanceRate ?? 0).toFixed(1)}%`}
</p>
</div>
</CardContent>
@ -312,7 +296,7 @@ export default function DashboardPage() {
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
<Package className="h-5 w-5 text-blue-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">Total Réservations</p>
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.totalBookings')}</p>
<p className="text-2xl font-bold text-gray-900">
{csvKpisLoading
? '--'
@ -330,7 +314,7 @@ export default function DashboardPage() {
<div className="h-10 w-10 rounded-lg bg-purple-100 flex items-center justify-center mb-2">
<Weight className="h-5 w-5 text-purple-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">Volume Total</p>
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.totalVolume')}</p>
<p className="text-2xl font-bold text-gray-900">
{csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`}
<span className="text-sm font-normal text-gray-500 ml-1">CBM</span>
@ -340,16 +324,15 @@ export default function DashboardPage() {
</Card>
</div>
{/* Top Carriers - Compact Table */}
<Card className="border border-gray-200 shadow-sm bg-white">
<CardHeader className="pb-3 border-b border-gray-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base font-semibold text-gray-900">
Top Transporteurs
{t('topCarriers.title')}
</CardTitle>
<CardDescription className="text-xs text-gray-600 mt-1">
Classement des meilleures compagnies
{t('topCarriers.description')}
</CardDescription>
</div>
<Link href="/dashboard/bookings">
@ -358,7 +341,7 @@ export default function DashboardPage() {
size="sm"
className="gap-2 text-gray-600 hover:text-gray-900 text-xs"
>
Voir tout
{t('topCarriers.viewAll')}
<ArrowRight className="h-3 w-3" />
</Button>
</Link>
@ -390,9 +373,9 @@ export default function DashboardPage() {
{carrier.carrierName}
</h3>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
<span>{carrier.totalBookings} réservations</span>
<span>{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}</span>
<span></span>
<span>{carrier.totalWeightKG.toLocaleString()} KG</span>
<span>{numberFormat.format(carrier.totalWeightKG)} KG</span>
</div>
</div>
</div>
@ -430,15 +413,15 @@ export default function DashboardPage() {
<Package className="h-6 w-6 text-gray-400" />
</div>
<h3 className="text-sm font-semibold text-gray-900 mb-1">
Aucune réservation
{t('topCarriers.empty.title')}
</h3>
<p className="text-xs text-gray-500 mb-4 max-w-sm mx-auto">
Créez votre première réservation pour voir vos statistiques
{t('topCarriers.empty.description')}
</p>
<Link href="/dashboard/bookings">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
<Plus className="mr-1.5 h-3 w-3" />
Créer une réservation
{t('topCarriers.empty.cta')}
</Button>
</Link>
</div>

View File

@ -1,9 +1,3 @@
/**
* User Profile Page
*
* Allows users to view and update their profile information
*/
'use client';
import { useState, useEffect } from 'react';
@ -12,45 +6,44 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslations } from 'next-intl';
import { updateUser, changePassword } from '@/lib/api';
// Password update schema
const passwordSchema = z
.object({
currentPassword: z.string().min(1, 'Le mot de passe actuel est requis'),
newPassword: z
.string()
.min(12, 'Le mot de passe doit contenir au moins 12 caractères')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
'Le mot de passe doit contenir une majuscule, une minuscule, un chiffre et un caractère spécial'
),
confirmPassword: z.string().min(1, 'Veuillez confirmer votre mot de passe'),
})
.refine(data => data.newPassword === data.confirmPassword, {
message: 'Les mots de passe ne correspondent pas',
path: ['confirmPassword'],
});
type PasswordFormData = z.infer<typeof passwordSchema>;
// Profile update schema
const profileSchema = z.object({
firstName: z.string().min(2, 'Le prénom doit contenir au moins 2 caractères'),
lastName: z.string().min(2, 'Le nom doit contenir au moins 2 caractères'),
email: z.string().email('Adresse email invalide'),
});
type ProfileFormData = z.infer<typeof profileSchema>;
export default function ProfilePage() {
const t = useTranslations('dashboard.profile');
const { user, refreshUser, loading } = useAuth();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
const [successMessage, setSuccessMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
// Profile form
const passwordSchema = z
.object({
currentPassword: z.string().min(1, t('passwordForm.errors.currentRequired')),
newPassword: z
.string()
.min(12, t('passwordForm.errors.newMin'))
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
t('passwordForm.errors.newComplexity')
),
confirmPassword: z.string().min(1, t('passwordForm.errors.confirmRequired')),
})
.refine(data => data.newPassword === data.confirmPassword, {
message: t('passwordForm.errors.mismatch'),
path: ['confirmPassword'],
});
type PasswordFormData = z.infer<typeof passwordSchema>;
const profileSchema = z.object({
firstName: z.string().min(2, t('passwordForm.fieldErrors.firstNameMin')),
lastName: z.string().min(2, t('passwordForm.fieldErrors.lastNameMin')),
email: z.string().email(t('passwordForm.fieldErrors.emailInvalid')),
});
type ProfileFormData = z.infer<typeof profileSchema>;
const profileForm = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
@ -60,7 +53,6 @@ export default function ProfilePage() {
},
});
// Password form
const passwordForm = useForm<PasswordFormData>({
resolver: zodResolver(passwordSchema),
defaultValues: {
@ -70,7 +62,6 @@ export default function ProfilePage() {
},
});
// Update form values when user data loads
useEffect(() => {
if (user) {
profileForm.reset({
@ -82,7 +73,6 @@ export default function ProfilePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);
// Reset password form when switching to password tab
useEffect(() => {
if (activeTab === 'password') {
passwordForm.reset({
@ -94,26 +84,24 @@ export default function ProfilePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]);
// Update profile mutation
const updateProfileMutation = useMutation({
mutationFn: (data: ProfileFormData) => {
if (!user?.id) throw new Error('User ID not found');
return updateUser(user.id, data);
},
onSuccess: () => {
setSuccessMessage('Profil mis à jour avec succès !');
setSuccessMessage(t('profileForm.successUpdate'));
setErrorMessage('');
refreshUser();
queryClient.invalidateQueries({ queryKey: ['user'] });
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setErrorMessage(error.message || 'Échec de la mise à jour du profil');
setErrorMessage(error.message || t('profileForm.errorUpdate'));
setSuccessMessage('');
},
});
// Update password mutation
const updatePasswordMutation = useMutation({
mutationFn: async (data: PasswordFormData) => {
return changePassword({
@ -122,7 +110,7 @@ export default function ProfilePage() {
});
},
onSuccess: () => {
setSuccessMessage('Mot de passe mis à jour avec succès !');
setSuccessMessage(t('passwordForm.successUpdate'));
setErrorMessage('');
passwordForm.reset({
currentPassword: '',
@ -132,7 +120,7 @@ export default function ProfilePage() {
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setErrorMessage(error.message || 'Échec de la mise à jour du mot de passe');
setErrorMessage(error.message || t('passwordForm.errorUpdate'));
setSuccessMessage('');
},
});
@ -145,29 +133,27 @@ export default function ProfilePage() {
updatePasswordMutation.mutate(data);
};
// Show loading state while user data is being fetched
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement du profil...</p>
<p className="text-gray-600">{t('loading')}</p>
</div>
</div>
);
}
// Show error if user is not found after loading
if (!loading && !user) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<p className="text-red-600 mb-4">Impossible de charger le profil</p>
<p className="text-red-600 mb-4">{t('loadError')}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Réessayer
{t('retry')}
</button>
</div>
</div>
@ -176,13 +162,11 @@ export default function ProfilePage() {
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg shadow-lg p-6 text-white">
<h1 className="text-3xl font-bold mb-2">Mon Profil</h1>
<p className="text-blue-100">Gérez vos paramètres de compte et préférences</p>
<h1 className="text-3xl font-bold mb-2">{t('header.title')}</h1>
<p className="text-blue-100">{t('header.subtitle')}</p>
</div>
{/* Success/Error Messages */}
{successMessage && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center">
@ -213,7 +197,6 @@ export default function ProfilePage() {
</div>
)}
{/* User Info Card */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center text-white text-2xl font-bold">
@ -230,14 +213,13 @@ export default function ProfilePage() {
{user?.role}
</span>
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
Actif
{t('active')}
</span>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white rounded-lg shadow">
<div className="border-b">
<nav className="flex space-x-8 px-6" aria-label="Tabs">
@ -249,7 +231,7 @@ export default function ProfilePage() {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Informations personnelles
{t('tabs.profile')}
</button>
<button
onClick={() => setActiveTab('password')}
@ -259,7 +241,7 @@ export default function ProfilePage() {
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Modifier le mot de passe
{t('tabs.password')}
</button>
</nav>
</div>
@ -268,13 +250,9 @@ export default function ProfilePage() {
{activeTab === 'profile' ? (
<form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* First Name */}
<div>
<label
htmlFor="firstName"
className="block text-sm font-medium text-gray-700 mb-2"
>
Prénom
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
{t('profileForm.firstName')}
</label>
<input
{...profileForm.register('firstName')}
@ -289,13 +267,9 @@ export default function ProfilePage() {
)}
</div>
{/* Last Name */}
<div>
<label
htmlFor="lastName"
className="block text-sm font-medium text-gray-700 mb-2"
>
Nom
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
{t('profileForm.lastName')}
</label>
<input
{...profileForm.register('lastName')}
@ -311,10 +285,9 @@ export default function ProfilePage() {
</div>
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Adresse email
{t('profileForm.email')}
</label>
<input
{...profileForm.register('email')}
@ -323,29 +296,24 @@ export default function ProfilePage() {
disabled
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
/>
<p className="mt-1 text-xs text-gray-500">L&apos;adresse email ne peut pas être modifiée</p>
<p className="mt-1 text-xs text-gray-500">{t('profileForm.emailHelp')}</p>
</div>
{/* Submit Button */}
<div className="flex justify-end">
<button
type="submit"
disabled={updateProfileMutation.isPending}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updateProfileMutation.isPending ? 'Enregistrement...' : 'Enregistrer'}
{updateProfileMutation.isPending ? t('profileForm.saving') : t('profileForm.save')}
</button>
</div>
</form>
) : (
<form onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)} className="space-y-6">
{/* Current Password */}
<div>
<label
htmlFor="currentPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Mot de passe actuel
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700 mb-2">
{t('passwordForm.current')}
</label>
<input
{...passwordForm.register('currentPassword')}
@ -361,13 +329,9 @@ export default function ProfilePage() {
)}
</div>
{/* New Password */}
<div>
<label
htmlFor="newPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Nouveau mot de passe
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-2">
{t('passwordForm.new')}
</label>
<input
{...passwordForm.register('newPassword')}
@ -381,18 +345,12 @@ export default function ProfilePage() {
{passwordForm.formState.errors.newPassword.message}
</p>
)}
<p className="mt-1 text-xs text-gray-500">
Au moins 12 caractères avec majuscule, minuscule, chiffre et caractère spécial
</p>
<p className="mt-1 text-xs text-gray-500">{t('passwordForm.newHint')}</p>
</div>
{/* Confirm Password */}
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-2"
>
Confirmer le nouveau mot de passe
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
{t('passwordForm.confirm')}
</label>
<input
{...passwordForm.register('confirmPassword')}
@ -408,14 +366,13 @@ export default function ProfilePage() {
)}
</div>
{/* Submit Button */}
<div className="flex justify-end">
<button
type="submit"
disabled={updatePasswordMutation.isPending}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updatePasswordMutation.isPending ? 'Mise à jour...' : 'Mettre à jour'}
{updatePasswordMutation.isPending ? t('passwordForm.submitting') : t('passwordForm.submit')}
</button>
</div>
</form>

View File

@ -1,16 +1,10 @@
/**
* Advanced Rate Search Page
*
* Complete search form with all filters and best options display
* Uses only ports available in CSV rates for origin/destination selection
*/
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter } from '@/i18n/navigation';
import { Search, Loader2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import {
getAvailableOrigins,
getAvailableDestinations,
@ -18,10 +12,14 @@ import {
} from '@/lib/api/rates';
import dynamic from 'next/dynamic';
// Import dynamique pour éviter les erreurs SSR avec Leaflet
const PortRouteMapLoader = () => {
const t = useTranslations('dashboard.rateSearch');
return <div className="h-80 bg-gray-100 rounded-lg flex items-center justify-center">{t('mapLoading')}</div>;
};
const PortRouteMap = dynamic(() => import('@/components/PortRouteMap'), {
ssr: false,
loading: () => <div className="h-80 bg-gray-100 rounded-lg flex items-center justify-center">Chargement de la carte...</div>,
loading: PortRouteMapLoader,
});
interface Package {
@ -35,28 +33,17 @@ interface Package {
}
interface SearchForm {
// General
origin: string;
destination: string;
// Conditionnement
packages: Package[];
// Douane
eurDocument: boolean;
customsStop: boolean;
exportAssistance: boolean;
// Marchandise
dangerousGoods: boolean;
specialHandling: boolean;
// Manutention
tailgate: boolean;
straps: boolean;
thermalCover: boolean;
// Autres
regulatedProducts: boolean;
appointment: boolean;
insurance: boolean;
@ -64,6 +51,7 @@ interface SearchForm {
}
export default function AdvancedSearchPage() {
const t = useTranslations('dashboard.rateSearch');
const router = useRouter();
const [searchForm, setSearchForm] = useState<SearchForm>({
origin: '',
@ -101,20 +89,17 @@ export default function AdvancedSearchPage() {
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(null);
// Fetch available origins from CSV rates
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
queryKey: ['available-origins'],
queryFn: getAvailableOrigins,
});
// Fetch available destinations based on selected origin
const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({
queryKey: ['available-destinations', searchForm.origin],
queryFn: () => getAvailableDestinations(searchForm.origin),
enabled: !!searchForm.origin,
});
// Filter origins based on search input
const filteredOrigins = (originsData?.origins || []).filter(port => {
if (!originSearch || originSearch.length < 1) return true;
const searchLower = originSearch.toLowerCase();
@ -126,7 +111,6 @@ export default function AdvancedSearchPage() {
);
});
// Filter destinations based on search input
const filteredDestinations = (destinationsData?.destinations || []).filter(port => {
if (!destinationSearch || destinationSearch.length < 1) return true;
const searchLower = destinationSearch.toLowerCase();
@ -138,10 +122,8 @@ export default function AdvancedSearchPage() {
);
});
// Reset destination when origin changes
useEffect(() => {
if (searchForm.origin && selectedDestinationPort) {
// Check if current destination is still valid for new origin
const isValidDestination = destinationsData?.destinations?.some(
d => d.code === searchForm.destination
);
@ -153,7 +135,6 @@ export default function AdvancedSearchPage() {
}
}, [searchForm.origin, destinationsData]);
// Calculate total volume and weight
const calculateTotals = () => {
let totalVolumeCBM = 0;
let totalWeightKG = 0;
@ -174,7 +155,6 @@ export default function AdvancedSearchPage() {
const handleSearch = () => {
const { totalVolumeCBM, totalWeightKG, totalPallets } = calculateTotals();
// Build query parameters
const params = new URLSearchParams({
origin: searchForm.origin,
destination: searchForm.destination,
@ -190,7 +170,6 @@ export default function AdvancedSearchPage() {
requiresAppointment: searchForm.appointment.toString(),
});
// Redirect to results page
router.push(`/dashboard/search-advanced/results?${params.toString()}`);
};
@ -227,13 +206,12 @@ export default function AdvancedSearchPage() {
const renderStep1 = () => (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">1. Informations Générales</h2>
<h2 className="text-xl font-semibold text-gray-900">{t('step1.title')}</h2>
<div className="grid grid-cols-2 gap-4">
{/* Origin Port with Autocomplete - Limited to CSV routes */}
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
Port d'origine * {searchForm.origin && <span className="text-green-600 text-xs"> Sélectionné</span>}
{t('step1.originLabel')} {searchForm.origin && <span className="text-green-600 text-xs">{t('step1.selected')}</span>}
</label>
<div className="relative">
<input
@ -242,7 +220,6 @@ export default function AdvancedSearchPage() {
onChange={e => {
setOriginSearch(e.target.value);
setShowOriginDropdown(true);
// Clear selection if user modifies the input
if (selectedOriginPort && e.target.value !== selectedOriginPort.displayName) {
setSearchForm({ ...searchForm, origin: '', destination: '' });
setSelectedOriginPort(null);
@ -252,7 +229,7 @@ export default function AdvancedSearchPage() {
}}
onFocus={() => setShowOriginDropdown(true)}
onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)}
placeholder="Rechercher un port d'origine..."
placeholder={t('step1.originPlaceholder')}
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
searchForm.origin ? 'border-green-500 bg-green-50' : 'border-gray-300'
}`}
@ -287,22 +264,21 @@ export default function AdvancedSearchPage() {
))}
{filteredOrigins.length > 15 && (
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
+{filteredOrigins.length - 15} autres résultats. Affinez votre recherche.
{t('step1.moreResults', { count: filteredOrigins.length - 15 })}
</div>
)}
</div>
)}
{showOriginDropdown && filteredOrigins.length === 0 && !isLoadingOrigins && originsData && (
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
<p className="text-sm text-gray-500">Aucun port d'origine trouvé pour "{originSearch}"</p>
<p className="text-sm text-gray-500">{t('step1.noOrigin', { query: originSearch })}</p>
</div>
)}
</div>
{/* Destination Port with Autocomplete - Limited to routes from selected origin */}
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
Port de destination * {searchForm.destination && <span className="text-green-600 text-xs"> Sélectionné</span>}
{t('step1.destinationLabel')} {searchForm.destination && <span className="text-green-600 text-xs">{t('step1.selected')}</span>}
</label>
<div className="relative">
<input
@ -311,7 +287,6 @@ export default function AdvancedSearchPage() {
onChange={e => {
setDestinationSearch(e.target.value);
setShowDestinationDropdown(true);
// Clear selection if user modifies the input
if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) {
setSearchForm({ ...searchForm, destination: '' });
setSelectedDestinationPort(null);
@ -320,7 +295,7 @@ export default function AdvancedSearchPage() {
onFocus={() => setShowDestinationDropdown(true)}
onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
disabled={!searchForm.origin}
placeholder={searchForm.origin ? 'Rechercher une destination...' : 'Sélectionnez d\'abord un port d\'origine'}
placeholder={searchForm.origin ? t('step1.destinationPlaceholder') : t('step1.destinationDisabled')}
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300'
} ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
@ -333,7 +308,7 @@ export default function AdvancedSearchPage() {
</div>
{searchForm.origin && destinationsData?.total !== undefined && (
<p className="text-xs text-gray-500 mt-1">
{destinationsData.total} destination{destinationsData.total > 1 ? 's' : ''} disponible{destinationsData.total > 1 ? 's' : ''} depuis {selectedOriginPort?.name || searchForm.origin}
{t('step1.availableDestinations', { count: destinationsData.total, port: selectedOriginPort?.name || searchForm.origin })}
</p>
)}
{showDestinationDropdown && filteredDestinations.length > 0 && (
@ -358,28 +333,27 @@ export default function AdvancedSearchPage() {
))}
{filteredDestinations.length > 15 && (
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
+{filteredDestinations.length - 15} autres résultats. Affinez votre recherche.
{t('step1.moreResults', { count: filteredDestinations.length - 15 })}
</div>
)}
</div>
)}
{showDestinationDropdown && filteredDestinations.length === 0 && !isLoadingDestinations && searchForm.origin && destinationsData && (
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
<p className="text-sm text-gray-500">Aucune destination trouvée pour "{destinationSearch}"</p>
<p className="text-sm text-gray-500">{t('step1.noDestination', { query: destinationSearch })}</p>
</div>
)}
</div>
</div>
{/* Carte interactive de la route maritime */}
{selectedOriginPort && selectedDestinationPort && selectedOriginPort.latitude && selectedDestinationPort.latitude && (
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h3 className="text-sm font-semibold text-gray-900">
Route maritime : {selectedOriginPort.name} {selectedDestinationPort.name}
{t('step1.routeTitle', { origin: selectedOriginPort.name, destination: selectedDestinationPort.name })}
</h3>
<p className="text-xs text-gray-500 mt-1">
Distance approximative et visualisation de la route
{t('step1.routeDescription')}
</p>
</div>
<PortRouteMap
@ -398,51 +372,53 @@ export default function AdvancedSearchPage() {
</div>
);
const renderStep2 = () => (
const renderStep2 = () => {
const totals = calculateTotals();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">2. Conditionnement</h2>
<h2 className="text-xl font-semibold text-gray-900">{t('step2.title')}</h2>
<button
type="button"
onClick={addPackage}
className="px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 rounded-md hover:bg-blue-100"
>
+ Ajouter un colis
{t('step2.addPackage')}
</button>
</div>
{searchForm.packages.map((pkg, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900">Colis #{index + 1}</h3>
<h3 className="font-medium text-gray-900">{t('step2.packageNumber', { number: index + 1 })}</h3>
{searchForm.packages.length > 1 && (
<button
type="button"
onClick={() => removePackage(index)}
className="text-sm text-red-600 hover:text-red-700"
>
Supprimer
{t('step2.remove')}
</button>
)}
</div>
<div className="grid grid-cols-5 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Type</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.type')}</label>
<select
value={pkg.type}
onChange={e => updatePackage(index, 'type', e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
>
<option value="caisse">Caisse</option>
<option value="colis">Colis</option>
<option value="palette">Palette</option>
<option value="autre">Autre</option>
<option value="caisse">{t('step2.packageTypes.caisse')}</option>
<option value="colis">{t('step2.packageTypes.colis')}</option>
<option value="palette">{t('step2.packageTypes.palette')}</option>
<option value="autre">{t('step2.packageTypes.autre')}</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Quantité</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.quantity')}</label>
<input
type="number"
min="1"
@ -453,7 +429,7 @@ export default function AdvancedSearchPage() {
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">L (cm)</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.length')}</label>
<input
type="number"
min="1"
@ -464,7 +440,7 @@ export default function AdvancedSearchPage() {
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">l (cm)</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.width')}</label>
<input
type="number"
min="1"
@ -475,7 +451,7 @@ export default function AdvancedSearchPage() {
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">H (cm)</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.height')}</label>
<input
type="number"
min="1"
@ -488,7 +464,7 @@ export default function AdvancedSearchPage() {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Poids (kg)</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('step2.weight')}</label>
<input
type="number"
min="1"
@ -505,30 +481,31 @@ export default function AdvancedSearchPage() {
onChange={e => updatePackage(index, 'stackable', e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<label className="ml-2 text-sm text-gray-700">Gerbable</label>
<label className="ml-2 text-sm text-gray-700">{t('step2.stackable')}</label>
</div>
</div>
</div>
))}
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<h3 className="text-sm font-medium text-blue-900 mb-2">Récapitulatif</h3>
<h3 className="text-sm font-medium text-blue-900 mb-2">{t('step2.summary.title')}</h3>
<div className="text-sm text-blue-800 space-y-1">
<div>Volume total: {calculateTotals().totalVolumeCBM.toFixed(2)} m³</div>
<div>Poids total: {calculateTotals().totalWeightKG} kg</div>
<div>Palettes: {calculateTotals().totalPallets}</div>
<div>{t('step2.summary.volume', { value: totals.totalVolumeCBM.toFixed(2) })}</div>
<div>{t('step2.summary.weight', { value: totals.totalWeightKG })}</div>
<div>{t('step2.summary.pallets', { value: totals.totalPallets })}</div>
</div>
</div>
</div>
);
};
const renderStep3 = () => (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">3. Options & Services</h2>
<h2 className="text-xl font-semibold text-gray-900">{t('step3.title')}</h2>
<div className="space-y-4">
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Douane Import / Export</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.customs.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
@ -537,7 +514,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, eurDocument: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">EUR 1</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.eurDocument')}</span>
</label>
<label className="flex items-center">
<input
@ -546,7 +523,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, t1Document: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">T1</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.t1Document')}</span>
</label>
<label className="flex items-center">
<input
@ -555,7 +532,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, customsStop: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Stop douane</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.customsStop')}</span>
</label>
<label className="flex items-center">
<input
@ -566,13 +543,13 @@ export default function AdvancedSearchPage() {
}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Assistance export</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.exportAssistance')}</span>
</label>
</div>
</div>
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Marchandise</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.goods.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
@ -581,7 +558,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, dangerousGoods: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Marchandise Dangereuse</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.goods.dangerous')}</span>
</label>
<label className="flex items-center">
<input
@ -592,13 +569,13 @@ export default function AdvancedSearchPage() {
}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Produits règlementés</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.goods.regulated')}</span>
</label>
</div>
</div>
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Manutention particulière</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.handling.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
@ -609,7 +586,7 @@ export default function AdvancedSearchPage() {
}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Manutention spéciale</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.special')}</span>
</label>
<label className="flex items-center">
<input
@ -618,7 +595,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, tailgate: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Hayon</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.tailgate')}</span>
</label>
<label className="flex items-center">
<input
@ -627,7 +604,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, straps: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Sangles</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.straps')}</span>
</label>
<label className="flex items-center">
<input
@ -636,13 +613,13 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, thermalCover: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Couverture thermique</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.thermalCover')}</span>
</label>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Autres options</h3>
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.other.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
@ -651,7 +628,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, appointment: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Rendez-vous livraison</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.other.appointment')}</span>
</label>
<label className="flex items-center">
<input
@ -660,7 +637,7 @@ export default function AdvancedSearchPage() {
onChange={e => setSearchForm({ ...searchForm, insurance: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Assurance</span>
<span className="ml-2 text-sm text-gray-700">{t('step3.other.insurance')}</span>
</label>
</div>
</div>
@ -670,15 +647,13 @@ export default function AdvancedSearchPage() {
return (
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900">Recherche Avancée de Tarifs</h1>
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<p className="text-sm text-gray-500 mt-1">
Formulaire complet avec toutes les options de transport
{t('subtitle')}
</p>
</div>
{/* Progress Steps */}
<div className="flex items-center justify-center space-x-4">
{[1, 2, 3].map(step => (
<div key={step} className="flex items-center">
@ -700,13 +675,11 @@ export default function AdvancedSearchPage() {
))}
</div>
{/* Form */}
<div className="bg-white rounded-lg shadow p-8">
{currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
{/* Navigation */}
<div className="mt-8 flex items-center justify-between pt-6 border-t">
<button
type="button"
@ -714,7 +687,7 @@ export default function AdvancedSearchPage() {
disabled={currentStep === 1}
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"
>
Précédent
{t('navigation.previous')}
</button>
{currentStep < 3 ? (
@ -724,7 +697,7 @@ export default function AdvancedSearchPage() {
disabled={!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"
>
Suivant
{t('navigation.next')}
</button>
) : (
<button
@ -733,7 +706,7 @@ export default function AdvancedSearchPage() {
disabled={!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" /> Rechercher les tarifs
<Search className="h-5 w-5 mr-2" /> {t('navigation.search')}
</button>
)}
</div>

View File

@ -1,7 +1,9 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useSearchParams } from 'next/navigation';
import { useRouter } from '@/i18n/navigation';
import { useTranslations, useLocale } from 'next-intl';
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
import type { CsvRateSearchResult } from '@/types/rates';
import { Search, Lightbulb, DollarSign, Scale, Zap, Trophy, XCircle, AlertTriangle } from 'lucide-react';
@ -13,13 +15,15 @@ interface BestOptions {
}
export default function SearchResultsPage() {
const t = useTranslations('dashboard.rateSearch.results');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const router = useRouter();
const searchParams = useSearchParams();
const [results, setResults] = useState<CsvRateSearchResult[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Parse search parameters from URL
const origin = searchParams.get('origin') || '';
const destination = searchParams.get('destination') || '';
const volumeCBM = parseFloat(searchParams.get('volumeCBM') || '0');
@ -36,24 +40,17 @@ export default function SearchResultsPage() {
destination,
volumeCBM,
weightKG,
palletCount,
hasDangerousGoods: searchParams.get('hasDangerousGoods') === 'true',
requiresSpecialHandling: searchParams.get('requiresSpecialHandling') === 'true',
requiresTailgate: searchParams.get('requiresTailgate') === 'true',
requiresStraps: searchParams.get('requiresStraps') === 'true',
requiresThermalCover: searchParams.get('requiresThermalCover') === 'true',
hasRegulatedProducts: searchParams.get('hasRegulatedProducts') === 'true',
requiresAppointment: searchParams.get('requiresAppointment') === 'true',
});
setResults(response.results);
} catch (err) {
console.error('Search error:', err);
setError(err instanceof Error ? err.message : 'Une erreur est survenue lors de la recherche');
setError(err instanceof Error ? err.message : t('errorGeneric'));
} finally {
setIsLoading(false);
}
}, [origin, destination, volumeCBM, weightKG, palletCount, searchParams]);
}, [origin, destination, volumeCBM, weightKG, palletCount, searchParams, t]);
useEffect(() => {
if (!origin || !destination || !volumeCBM || !weightKG) {
@ -67,12 +64,10 @@ export default function SearchResultsPage() {
const getBestOptions = (): BestOptions | null => {
if (results.length === 0) return null;
// Filter results by serviceLevel (backend generates 3 offers per rate)
const economic = results.find(r => r.serviceLevel === 'ECONOMIC');
const standard = results.find(r => r.serviceLevel === 'STANDARD');
const rapid = results.find(r => r.serviceLevel === 'RAPID');
// If we have all 3 service levels, return them
if (economic && standard && rapid) {
return {
eco: economic,
@ -81,9 +76,8 @@ export default function SearchResultsPage() {
};
}
// Fallback: if serviceLevel is not present (old endpoint), use sorting
const sorted = [...results].sort((a, b) => a.priceEUR - b.priceEUR);
const fastest = [...results].sort((a, b) => a.transitDays - b.transitDays);
const sorted = [...results].sort((a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting);
const fastest = [...results].sort((a, b) => (a.adjustedTransitDays ?? a.transitDays) - (b.adjustedTransitDays ?? b.transitDays));
return {
eco: sorted[0],
@ -95,7 +89,7 @@ export default function SearchResultsPage() {
const bestOptions = getBestOptions();
const formatPrice = (price: number) => {
return new Intl.NumberFormat('fr-FR', {
return new Intl.NumberFormat(dateLocale, {
style: 'currency',
currency: 'EUR',
}).format(price);
@ -107,7 +101,7 @@ export default function SearchResultsPage() {
<div className="max-w-7xl mx-auto">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-16 w-16 border-b-4 border-blue-600 mb-4"></div>
<p className="text-xl text-gray-700 font-medium">Recherche des meilleurs tarifs en cours...</p>
<p className="text-xl text-gray-700 font-medium">{t('loadingTitle')}</p>
<p className="text-gray-500 mt-2">
{origin} {destination}
</p>
@ -123,13 +117,13 @@ export default function SearchResultsPage() {
<div className="max-w-7xl mx-auto">
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
<div className="mb-4 flex justify-center"><XCircle className="h-16 w-16 text-red-500" /></div>
<h3 className="text-xl font-bold text-red-900 mb-2">Erreur</h3>
<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')}
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Retour à la recherche
{t('backToSearch')}
</button>
</div>
</div>
@ -145,35 +139,30 @@ export default function SearchResultsPage() {
onClick={() => router.back()}
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
Retour à la recherche
{t('backToSearch')}
</button>
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
<div className="mb-4 flex justify-center"><Search className="h-16 w-16 text-yellow-500" /></div>
<h3 className="text-xl font-bold text-gray-900 mb-2">Aucun résultat trouvé</h3>
<h3 className="text-xl font-bold text-gray-900 mb-2">{t('noResultsTitle')}</h3>
<p className="text-gray-600 mb-4">
Aucun tarif ne correspond à votre recherche pour le trajet {origin} {destination}
{t('noResultsMessage', { origin, destination })}
</p>
<div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6">
<h4 className="font-semibold text-gray-900 mb-2 flex items-center"><Lightbulb className="h-5 w-5 mr-2 text-yellow-500" /> Suggestions :</h4>
<h4 className="font-semibold text-gray-900 mb-2 flex items-center">
<Lightbulb className="h-5 w-5 mr-2 text-yellow-500" /> {t('suggestions')}
</h4>
<ul className="text-sm text-gray-700 space-y-2">
<li>
<strong>Ports disponibles :</strong> NLRTM, DEHAM, FRLEH, BEGNE (origine) USNYC, USLAX,
CNSHG, SGSIN (destination)
</li>
<li>
<strong>Volume :</strong> Essayez entre 1 et 200 CBM
</li>
<li>
<strong>Poids :</strong> Essayez entre 100 et 30000 kg
</li>
<li>{t('suggestionPorts')}</li>
<li>{t('suggestionVolume')}</li>
<li>{t('suggestionWeight')}</li>
</ul>
</div>
<button
onClick={() => router.back()}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Modifier la recherche
{t('modifySearch')}
</button>
</div>
</div>
@ -183,7 +172,7 @@ export default function SearchResultsPage() {
const optionCards = [
{
type: 'Économique',
type: t('options.economic'),
option: bestOptions?.eco,
colors: {
border: 'border-green-200',
@ -192,10 +181,10 @@ export default function SearchResultsPage() {
button: 'bg-green-600 hover:bg-green-700',
},
icon: <DollarSign className="h-10 w-10 text-green-600" />,
badge: 'Le moins cher',
badge: t('options.badgeCheapest'),
},
{
type: 'Standard',
type: t('options.standard'),
option: bestOptions?.standard,
colors: {
border: 'border-blue-200',
@ -204,10 +193,10 @@ export default function SearchResultsPage() {
button: 'bg-blue-600 hover:bg-blue-700',
},
icon: <Scale className="h-10 w-10 text-blue-600" />,
badge: 'Équilibré',
badge: t('options.badgeBalanced'),
},
{
type: 'Rapide',
type: t('options.fast'),
option: bestOptions?.fast,
colors: {
border: 'border-purple-200',
@ -216,7 +205,7 @@ export default function SearchResultsPage() {
button: 'bg-purple-600 hover:bg-purple-700',
},
icon: <Zap className="h-10 w-10 text-purple-600" />,
badge: 'Le plus rapide',
badge: t('options.badgeFastest'),
},
];
@ -229,21 +218,23 @@ export default function SearchResultsPage() {
onClick={() => router.back()}
className="mb-4 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
Retour à la recherche
{t('backToSearch')}
</button>
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Résultats de recherche</h1>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('resultsTitle')}</h1>
<p className="text-gray-600">
<span className="font-semibold">{origin}</span> <span className="font-semibold">{destination}</span>{' '}
{volumeCBM} CBM {weightKG} kg
{palletCount > 0 && `${palletCount} palette${palletCount > 1 ? 's' : ''}`}
{' '}
{palletCount > 0
? t('summaryWithPallets', { volume: volumeCBM, weight: weightKG, count: palletCount })
: t('summary', { volume: volumeCBM, weight: weightKG })}
</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">Tarifs trouvés</p>
<p className="text-sm text-gray-500">{t('ratesFound')}</p>
<p className="text-3xl font-bold text-blue-600">{results.length}</p>
</div>
</div>
@ -255,7 +246,7 @@ export default function SearchResultsPage() {
<div className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
<Trophy className="h-8 w-8 mr-3 text-yellow-500" />
Meilleurs choix pour votre recherche
{t('bestChoices')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
@ -282,21 +273,23 @@ export default function SearchResultsPage() {
<div className="bg-white rounded-lg p-4 mb-4">
<div className="text-center mb-3">
<p className="text-sm text-gray-600 mb-1">Prix total</p>
<p className="text-3xl font-bold text-gray-900">{formatPrice(card.option.priceEUR)}</p>
<p className="text-sm text-gray-600 mb-1">{t('totalPrice')}</p>
<p className="text-3xl font-bold text-gray-900">{formatPrice(card.option.priceBreakdown.totalPriceForSorting)}</p>
</div>
<div className="border-t border-gray-200 pt-3 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Transporteur :</span>
<span className="text-gray-600">{t('carrier')}</span>
<span className="font-semibold text-gray-900">{card.option.companyName}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Transit :</span>
<span className="font-semibold text-gray-900">{card.option.transitDays} jours</span>
<span className="text-gray-600">{t('transit')}</span>
<span className="font-semibold text-gray-900">
{t('transitDays', { days: card.option.adjustedTransitDays ?? card.option.transitDays })}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Type :</span>
<span className="text-gray-600">{t('type')}</span>
<span className="font-semibold text-gray-900">{card.option.containerType}</span>
</div>
</div>
@ -309,7 +302,7 @@ export default function SearchResultsPage() {
}}
className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors`}
>
Sélectionner cette option
{t('select')}
</button>
</div>
</div>
@ -321,7 +314,7 @@ export default function SearchResultsPage() {
{/* All Results */}
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">Tous les tarifs disponibles ({results.length})</h2>
<h2 className="text-2xl font-bold text-gray-900 mb-6">{t('allResults', { count: results.length })}</h2>
<div className="space-y-4">
{results.map((result, index) => (
@ -334,40 +327,49 @@ export default function SearchResultsPage() {
</p>
</div>
<div className="text-right">
<p className="text-3xl font-bold text-blue-600">{formatPrice(result.priceEUR)}</p>
<p className="text-sm text-gray-500">Prix total</p>
<p className="text-3xl font-bold text-blue-600">{formatPrice(result.priceBreakdown.totalPriceForSorting)}</p>
<p className="text-sm text-gray-500">{t('totalPrice')}</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">Prix de base</p>
<p className="text-xs text-gray-600 mb-1">Fret ({result.priceBreakdown.freightCurrency})</p>
<p className="font-semibold text-gray-900">
{formatPrice(result.priceBreakdown.basePrice)}
{formatPrice(result.priceBreakdown.totalFreight)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">Frais volume</p>
<p className="text-xs text-gray-600 mb-1">FOB ({result.priceBreakdown.fobCurrency})</p>
<p className="font-semibold text-gray-900">
{formatPrice(result.priceBreakdown.volumeCharge)}
{formatPrice(result.priceBreakdown.totalFob)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">Frais poids</p>
<p className="font-semibold text-gray-900">
{formatPrice(result.priceBreakdown.weightCharge)}
</p>
<p className="text-xs text-gray-600 mb-1">Routage</p>
<p className="font-semibold text-gray-900">{result.routing}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">Délai transit</p>
<p className="font-semibold text-gray-900">{result.transitDays} jours</p>
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.transit')}</p>
<p className="font-semibold text-gray-900">
{t('transitDays', { days: result.adjustedTransitDays ?? result.transitDays })}
</p>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-sm text-gray-600">
<span> Valide jusqu'au {new Date(result.validUntil).toLocaleDateString('fr-FR')}</span>
{result.hasSurcharges && <span className="text-orange-600 flex items-center"><AlertTriangle className="h-4 w-4 mr-1" /> Surcharges applicables</span>}
<span>{t('validUntil', { date: new Date(result.validUntil).toLocaleDateString(dateLocale) })}</span>
{result.dgSurchargeStatus === 'not_accepted' && (
<span className="text-orange-600 flex items-center">
<AlertTriangle className="h-4 w-4 mr-1" /> DG non accepté
</span>
)}
{result.dgSurchargeStatus === 'on_request' && (
<span className="text-blue-600 flex items-center">
<AlertTriangle className="h-4 w-4 mr-1" /> DG sur demande
</span>
)}
</div>
<button
onClick={() => {
@ -376,7 +378,7 @@ export default function SearchResultsPage() {
}}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Sélectionner
{t('selectShort')}
</button>
</div>
</div>

View File

@ -2,6 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslations, useLocale } from 'next-intl';
import { listApiKeys, createApiKey, revokeApiKey } from '@/lib/api/api-keys';
import type { ApiKeyDto, CreateApiKeyResultDto } from '@/lib/api/api-keys';
import { useSubscription } from '@/lib/context/subscription-context';
@ -17,40 +18,10 @@ import {
ShieldCheck,
Lock,
} from 'lucide-react';
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(iso));
}
function keyStatusBadge(key: ApiKeyDto) {
if (!key.isActive) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
Révoquée
</span>
);
}
if (key.expiresAt && new Date(key.expiresAt) < new Date()) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
<Clock className="w-3 h-3" />
Expirée
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
Active
</span>
);
}
// ─── Copy button ─────────────────────────────────────────────────────────────
import { PageHeader } from '@/components/ui/PageHeader';
function CopyButton({ text }: { text: string }) {
const t = useTranslations('dashboard.apiKeys.copy');
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(text);
@ -65,20 +36,18 @@ function CopyButton({ text }: { text: string }) {
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-green-500" />
<span className="text-green-600">Copié</span>
<span className="text-green-600">{t('copied')}</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5 text-gray-400" />
<span className="text-gray-600">Copier</span>
<span className="text-gray-600">{t('copy')}</span>
</>
)}
</button>
);
}
// ─── Creation success modal ──────────────────────────────────────────────────
function CreatedKeyModal({
result,
onClose,
@ -86,17 +55,17 @@ function CreatedKeyModal({
result: CreateApiKeyResultDto;
onClose: () => void;
}) {
const t = useTranslations('dashboard.apiKeys.createdModal');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
<ShieldCheck className="w-5 h-5 text-green-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Clé API créée</h2>
<h2 className="text-lg font-semibold text-gray-900">{t('title')}</h2>
<p className="text-sm text-gray-500">{result.name}</p>
</div>
</div>
@ -105,19 +74,16 @@ function CreatedKeyModal({
</button>
</div>
{/* Warning */}
<div className="mx-6 mt-6 p-4 bg-amber-50 border border-amber-200 rounded-xl flex gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
<strong>Copiez cette clé maintenant.</strong> Elle ne sera plus jamais affichée après
la fermeture de cette fenêtre.
<strong>{t('warning')}</strong> {t('warningRest')}
</p>
</div>
{/* Key */}
<div className="p-6">
<label className="block text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide">
Clé API complète
{t('fullKey')}
</label>
<div className="flex items-center gap-2 p-3 bg-gray-950 rounded-xl border border-gray-800">
<code className="flex-1 text-xs font-mono text-green-400 break-all">
@ -125,18 +91,15 @@ function CreatedKeyModal({
</code>
<CopyButton text={result.fullKey} />
</div>
<p className="mt-3 text-xs text-gray-500">
Stockez-la dans vos variables d&apos;environnement ou un gestionnaire de secrets.
</p>
<p className="mt-3 text-xs text-gray-500">{t('storeHint')}</p>
</div>
{/* Footer */}
<div className="p-6 pt-0">
<button
onClick={onClose}
className="w-full py-2.5 bg-[#10183A] hover:bg-[#1a2550] text-white text-sm font-medium rounded-xl transition-colors"
>
J&apos;ai copié ma clé, fermer
{t('close')}
</button>
</div>
</div>
@ -144,8 +107,6 @@ function CreatedKeyModal({
);
}
// ─── Create key form modal ───────────────────────────────────────────────────
function CreateKeyModal({
onSuccess,
onClose,
@ -153,6 +114,7 @@ function CreateKeyModal({
onSuccess: (result: CreateApiKeyResultDto) => void;
onClose: () => void;
}) {
const t = useTranslations('dashboard.apiKeys.createModal');
const [name, setName] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const queryClient = useQueryClient();
@ -176,13 +138,12 @@ function CreateKeyModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
<Key className="w-5 h-5 text-blue-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">Nouvelle clé API</h2>
<h2 className="text-lg font-semibold text-gray-900">{t('title')}</h2>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
<X className="w-5 h-5" />
@ -190,28 +151,26 @@ function CreateKeyModal({
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Nom de la clé <span className="text-red-500">*</span>
{t('name')} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="ex: Intégration ERP Production"
placeholder={t('namePlaceholder')}
maxLength={100}
required
className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#34CCCD] focus:border-transparent"
/>
<p className="mt-1 text-xs text-gray-400">{name.length}/100 caractères</p>
<p className="mt-1 text-xs text-gray-400">{t('nameCount', { count: name.length })}</p>
</div>
{/* Expiry */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Date d&apos;expiration{' '}
<span className="text-gray-400 font-normal">(optionnel)</span>
{t('expiry')}{' '}
<span className="text-gray-400 font-normal">{t('optional')}</span>
</label>
<input
type="date"
@ -220,34 +179,30 @@ function CreateKeyModal({
min={new Date().toISOString().split('T')[0]}
className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#34CCCD] focus:border-transparent"
/>
<p className="mt-1 text-xs text-gray-400">
Si vide, la clé n&apos;expire jamais.
</p>
<p className="mt-1 text-xs text-gray-400">{t('expiryHint')}</p>
</div>
{/* Error */}
{mutation.isError && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
Une erreur est survenue. Veuillez réessayer.
{t('errorGeneric')}
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-2.5 border border-gray-200 text-gray-700 text-sm font-medium rounded-xl hover:bg-gray-50 transition-colors"
>
Annuler
{t('cancel')}
</button>
<button
type="submit"
disabled={!name.trim() || mutation.isPending}
className="flex-1 py-2.5 bg-[#10183A] hover:bg-[#1a2550] disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-colors"
>
{mutation.isPending ? 'Création...' : 'Créer la clé'}
{mutation.isPending ? t('creating') : t('create')}
</button>
</div>
</form>
@ -256,8 +211,6 @@ function CreateKeyModal({
);
}
// ─── Revoke confirm modal ────────────────────────────────────────────────────
function RevokeConfirmModal({
apiKey,
onConfirm,
@ -267,6 +220,7 @@ function RevokeConfirmModal({
onConfirm: () => void;
onClose: () => void;
}) {
const t = useTranslations('dashboard.apiKeys.revokeModal');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
@ -274,15 +228,13 @@ function RevokeConfirmModal({
<div className="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center mx-auto mb-4">
<Trash2 className="w-6 h-6 text-red-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900 text-center mb-2">
Révoquer cette clé ?
</h2>
<h2 className="text-lg font-semibold text-gray-900 text-center mb-2">{t('title')}</h2>
<p className="text-sm text-gray-600 text-center mb-1">
<strong className="text-gray-900">{apiKey.name}</strong>
</p>
<p className="text-sm text-gray-500 text-center">
Cette action est <strong>immédiate et irréversible</strong>. Toute requête utilisant
cette clé sera refusée.
{t('description')} <strong>{t('descriptionEmphasis')}</strong>
{t('descriptionRest')}
</p>
</div>
<div className="px-6 pb-6 flex gap-3">
@ -290,13 +242,13 @@ function RevokeConfirmModal({
onClick={onClose}
className="flex-1 py-2.5 border border-gray-200 text-gray-700 text-sm font-medium rounded-xl hover:bg-gray-50 transition-colors"
>
Annuler
{t('cancel')}
</button>
<button
onClick={onConfirm}
className="flex-1 py-2.5 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-xl transition-colors"
>
Révoquer
{t('confirm')}
</button>
</div>
</div>
@ -304,9 +256,10 @@ function RevokeConfirmModal({
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function ApiKeysPage() {
const t = useTranslations('dashboard.apiKeys');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const { hasFeature } = useSubscription();
const queryClient = useQueryClient();
const hasApiAccess = hasFeature('api_access');
@ -329,23 +282,52 @@ export default function ApiKeysPage() {
},
});
// Plan upsell screen
const formatDate = (iso: string | null): string => {
if (!iso) return '—';
return new Intl.DateTimeFormat(dateLocale, { dateStyle: 'medium' }).format(new Date(iso));
};
const keyStatusBadge = (key: ApiKeyDto) => {
if (!key.isActive) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
{t('status.revoked')}
</span>
);
}
if (key.expiresAt && new Date(key.expiresAt) < new Date()) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
<Clock className="w-3 h-3" />
{t('status.expired')}
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
{t('status.active')}
</span>
);
};
if (!hasApiAccess) {
return (
<div className="max-w-lg mx-auto mt-16 text-center">
<div className="w-16 h-16 rounded-2xl bg-gray-100 flex items-center justify-center mx-auto mb-6">
<Lock className="w-8 h-8 text-gray-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-3">Accès API</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-3">{t('noAccess.title')}</h1>
<p className="text-gray-600 mb-8">
L&apos;accès programmatique à l&apos;API Xpeditis est disponible sur les plans{' '}
<strong>Gold</strong> et <strong>Platinium</strong> uniquement.
{t.rich('noAccess.description', {
gold: () => <strong>{t('noAccess.gold')}</strong>,
platinium: () => <strong>{t('noAccess.platinium')}</strong>,
})}
</p>
<a
href="/pricing"
className="inline-flex items-center gap-2 px-6 py-3 bg-[#10183A] hover:bg-[#1a2550] text-white text-sm font-medium rounded-xl transition-colors"
>
Voir les plans
{t('noAccess.viewPlans')}
</a>
</div>
);
@ -355,7 +337,6 @@ export default function ApiKeysPage() {
return (
<>
{/* Modals */}
{showCreateModal && (
<CreateKeyModal
onSuccess={result => {
@ -376,46 +357,45 @@ export default function ApiKeysPage() {
/>
)}
{/* Page header */}
<div className="flex items-start justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Clés API</h1>
<p className="mt-1 text-sm text-gray-500">
Gérez les clés d&apos;accès programmatique à l&apos;API Xpeditis.
</p>
</div>
<PageHeader
title={t('title')}
description={t('description')}
actions={
<button
onClick={() => setShowCreateModal(true)}
disabled={activeKeys.length >= 20}
className="flex items-center gap-2 px-4 py-2.5 bg-[#10183A] hover:bg-[#1a2550] disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-colors"
>
<Plus className="w-4 h-4" />
Nouvelle clé
{t('newKey')}
</button>
</div>
}
/>
{/* Info banner */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-100 rounded-xl flex gap-3">
<ShieldCheck className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-0.5">Comment utiliser vos clés API</p>
<p className="font-medium mb-0.5">{t('infoTitle')}</p>
<p>
Ajoutez l&apos;en-tête{' '}
{t.rich('infoBody', {
code: () => (
<code className="px-1.5 py-0.5 bg-blue-100 rounded text-blue-900 font-mono text-xs">
X-API-Key: xped_live_...
</code>{' '}
à chaque requête HTTP.{' '}
</code>
),
link: () => (
<a
href="/dashboard/docs?section=authentication"
className="font-medium underline underline-offset-2"
>
Voir la documentation
{t('viewDocs')}
</a>
),
})}
</p>
</div>
</div>
{/* Keys list */}
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-16">
@ -424,22 +404,21 @@ export default function ApiKeysPage() {
) : !apiKeys || apiKeys.length === 0 ? (
<div className="py-16 text-center">
<Key className="w-10 h-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-sm">Aucune clé API pour le moment.</p>
<p className="text-gray-500 text-sm">{t('noKeys')}</p>
<button
onClick={() => setShowCreateModal(true)}
className="mt-4 text-sm font-medium text-[#10183A] hover:underline"
>
Créer votre première clé
{t('createFirst')}
</button>
</div>
) : (
<div className="divide-y divide-gray-100">
{/* Table header */}
<div className="grid grid-cols-[2fr_1.5fr_1fr_1fr_auto] gap-4 px-6 py-3 bg-gray-50 text-xs font-medium text-gray-500 uppercase tracking-wide">
<span>Nom / Préfixe</span>
<span>Dernière utilisation</span>
<span>Expiration</span>
<span>Statut</span>
<span>{t('table.name')}</span>
<span>{t('table.lastUsed')}</span>
<span>{t('table.expiry')}</span>
<span>{t('table.status')}</span>
<span />
</div>
@ -448,26 +427,19 @@ export default function ApiKeysPage() {
key={key.id}
className="grid grid-cols-[2fr_1.5fr_1fr_1fr_auto] gap-4 items-center px-6 py-4"
>
{/* Name + prefix */}
<div>
<p className="text-sm font-medium text-gray-900">{key.name}</p>
<code className="text-xs font-mono text-gray-400">{key.keyPrefix}</code>
</div>
{/* Last used */}
<span className="text-sm text-gray-600">{formatDate(key.lastUsedAt)}</span>
{/* Expiry */}
<span className="text-sm text-gray-600">{formatDate(key.expiresAt)}</span>
{/* Status */}
<div>{keyStatusBadge(key)}</div>
{/* Actions */}
<button
onClick={() => setRevokeTarget(key)}
disabled={!key.isActive || revokeMutation.isPending}
title="Révoquer cette clé"
title={t('revoke')}
className="p-2 text-gray-400 hover:text-red-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
@ -478,10 +450,9 @@ export default function ApiKeysPage() {
)}
</div>
{/* Quota */}
{apiKeys && apiKeys.length > 0 && (
<p className="mt-4 text-xs text-gray-400 text-right">
{activeKeys.length} / 20 clés actives utilisées
{t('quota', { active: activeKeys.length, max: 20 })}
</p>
)}
</>

View File

@ -2,6 +2,7 @@
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useAuth } from '@/lib/context/auth-context';
import { getOrganization, updateOrganization } from '@/lib/api/organizations';
import type { OrganizationResponse } from '@/types/api';
@ -23,11 +24,11 @@ interface OrganizationForm {
type TabType = 'information' | 'address' | 'subscription' | 'licenses';
export default function OrganizationSettingsPage() {
const t = useTranslations('dashboard.organizationSettings');
const { user } = useAuth();
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState<TabType>('information');
// Auto-switch to subscription tab if coming back from Stripe (only for ADMIN/MANAGER)
useEffect(() => {
const isSuccess = searchParams.get('success') === 'true';
const isCanceled = searchParams.get('canceled') === 'true';
@ -53,7 +54,6 @@ export default function OrganizationSettingsPage() {
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Check if user can edit organization (only ADMIN and MANAGER)
const canEdit = user?.role === 'ADMIN' || user?.role === 'MANAGER';
const loadOrganization = useCallback(async () => {
@ -77,11 +77,11 @@ export default function OrganizationSettingsPage() {
});
} catch (err) {
console.error('Failed to load organization:', err);
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setIsLoading(false);
}
}, [user]);
}, [user, t]);
useEffect(() => {
if (user?.organizationId) {
@ -135,10 +135,10 @@ export default function OrganizationSettingsPage() {
});
setOrganization(updatedOrg);
setSuccessMessage('Informations sauvegardées avec succès');
setSuccessMessage(t('saveSuccess'));
} catch (err) {
console.error('Failed to update organization:', err);
setError(err instanceof Error ? err.message : 'Erreur lors de la sauvegarde');
setError(err instanceof Error ? err.message : t('saveFailed'));
} finally {
setIsSaving(false);
}
@ -149,7 +149,7 @@ export default function OrganizationSettingsPage() {
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-blue-600 mb-4"></div>
<p className="text-gray-600">Chargement...</p>
<p className="text-gray-600">{t('loading')}</p>
</div>
</div>
);
@ -159,20 +159,19 @@ export default function OrganizationSettingsPage() {
return (
<div className="max-w-4xl mx-auto">
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-red-900 mb-2">Erreur</h3>
<p className="text-red-700">{error || "Impossible de charger l'organisation"}</p>
<h3 className="text-lg font-semibold text-red-900 mb-2">{t('errorTitle')}</h3>
<p className="text-red-700">{error || t('loadError')}</p>
</div>
</div>
);
}
// Check if user can view subscription and licenses (only ADMIN and MANAGER)
const canViewBilling = user?.role === 'ADMIN' || user?.role === 'MANAGER';
const tabs = [
{
id: 'information' as TabType,
label: 'Informations',
label: t('tabs.information'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
@ -181,7 +180,7 @@ export default function OrganizationSettingsPage() {
},
{
id: 'address' as TabType,
label: 'Adresse',
label: t('tabs.address'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
@ -189,11 +188,10 @@ export default function OrganizationSettingsPage() {
</svg>
),
},
// Only show subscription and licenses tabs for ADMIN and MANAGER roles
...(canViewBilling ? [
{
id: 'subscription' as TabType,
label: 'Abonnement',
label: t('tabs.subscription'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
@ -202,7 +200,7 @@ export default function OrganizationSettingsPage() {
},
{
id: 'licenses' as TabType,
label: 'Licences',
label: t('tabs.licenses'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
@ -214,13 +212,11 @@ export default function OrganizationSettingsPage() {
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Paramètres de l&apos;organisation</h1>
<p className="text-gray-600 mt-2">Gérez les informations de votre organisation</p>
<h1 className="text-3xl font-bold text-gray-900">{t('header.title')}</h1>
<p className="text-gray-600 mt-2">{t('header.subtitle')}</p>
</div>
{/* Success Message */}
{successMessage && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center">
@ -232,7 +228,6 @@ export default function OrganizationSettingsPage() {
</div>
)}
{/* Error Message */}
{error && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
@ -244,19 +239,17 @@ export default function OrganizationSettingsPage() {
</div>
)}
{/* Read-only warning for USER role */}
{!canEdit && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center">
<svg className="w-5 h-5 text-blue-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-blue-800 font-medium">Mode lecture seule - Seuls les administrateurs et managers peuvent modifier l&apos;organisation</p>
<p className="text-blue-800 font-medium">{t('readOnlyWarning')}</p>
</div>
</div>
)}
{/* Tabs */}
<div className="bg-white rounded-lg shadow-md">
<div className="border-b border-gray-200">
<nav className="flex -mb-px overflow-x-auto">
@ -279,14 +272,12 @@ export default function OrganizationSettingsPage() {
</nav>
</div>
{/* Tab Content */}
<div className="p-8">
{activeTab === 'information' && (
<div className="space-y-6">
{/* Nom de la société */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nom de la société <span className="text-red-500">*</span>
{t('information.name')} <span className="text-red-500">*</span>
</label>
<input
type="text"
@ -294,16 +285,15 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('name', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="Xpeditis"
placeholder={t('information.namePlaceholder')}
required
/>
</div>
{/* SIREN */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
SIREN
<span className="ml-2 text-xs text-gray-500">(Système d&apos;Identification du Répertoire des Entreprises)</span>
{t('information.siren')}
<span className="ml-2 text-xs text-gray-500">({t('information.sirenHint')})</span>
</label>
<input
type="text"
@ -311,17 +301,16 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('siren', e.target.value.replace(/\D/g, '').slice(0, 9))}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="123 456 789"
placeholder={t('information.sirenPlaceholder')}
maxLength={9}
/>
<p className="mt-1 text-xs text-gray-500">9 chiffres</p>
<p className="mt-1 text-xs text-gray-500">{t('information.sirenDigits')}</p>
</div>
{/* Numéro EORI */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Numéro EORI
<span className="ml-2 text-xs text-gray-500">(Economic Operators Registration and Identification)</span>
{t('information.eori')}
<span className="ml-2 text-xs text-gray-500">({t('information.eoriHint')})</span>
</label>
<input
type="text"
@ -329,35 +318,33 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('eori', e.target.value.toUpperCase())}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="FR123456789"
placeholder={t('information.eoriPlaceholder')}
maxLength={17}
/>
<p className="mt-1 text-xs text-gray-500">Code pays (2 lettres) + numéro unique (max 15 caractères)</p>
<p className="mt-1 text-xs text-gray-500">{t('information.eoriHelp')}</p>
</div>
{/* Téléphone */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Téléphone</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('information.phone')}</label>
<input
type="tel"
value={formData.contact_phone}
onChange={e => handleChange('contact_phone', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="+33 6 80 18 28 12"
placeholder={t('information.phonePlaceholder')}
/>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Email</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('information.email')}</label>
<input
type="email"
value={formData.contact_email}
onChange={e => handleChange('contact_email', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="contact@xpeditis.com"
placeholder={t('information.emailPlaceholder')}
/>
</div>
</div>
@ -365,10 +352,9 @@ export default function OrganizationSettingsPage() {
{activeTab === 'address' && (
<div className="space-y-6">
{/* Rue */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rue <span className="text-red-500">*</span>
{t('address.street')} <span className="text-red-500">*</span>
</label>
<input
type="text"
@ -376,16 +362,15 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('address_street', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="123 Rue de la Paix"
placeholder={t('address.streetPlaceholder')}
required
/>
</div>
{/* Ville et Code postal */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Code postal <span className="text-red-500">*</span>
{t('address.postalCode')} <span className="text-red-500">*</span>
</label>
<input
type="text"
@ -393,13 +378,13 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('address_postal_code', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="75001"
placeholder={t('address.postalCodePlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Ville <span className="text-red-500">*</span>
{t('address.city')} <span className="text-red-500">*</span>
</label>
<input
type="text"
@ -407,16 +392,15 @@ export default function OrganizationSettingsPage() {
onChange={e => handleChange('address_city', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="Paris"
placeholder={t('address.cityPlaceholder')}
required
/>
</div>
</div>
{/* Pays */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Pays <span className="text-red-500">*</span>
{t('address.country')} <span className="text-red-500">*</span>
</label>
<select
value={formData.address_country}
@ -425,15 +409,15 @@ export default function OrganizationSettingsPage() {
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
>
<option value="FR">France</option>
<option value="BE">Belgique</option>
<option value="DE">Allemagne</option>
<option value="ES">Espagne</option>
<option value="IT">Italie</option>
<option value="NL">Pays-Bas</option>
<option value="GB">Royaume-Uni</option>
<option value="US">États-Unis</option>
<option value="CN">Chine</option>
<option value="FR">{t('address.countries.FR')}</option>
<option value="BE">{t('address.countries.BE')}</option>
<option value="DE">{t('address.countries.DE')}</option>
<option value="ES">{t('address.countries.ES')}</option>
<option value="IT">{t('address.countries.IT')}</option>
<option value="NL">{t('address.countries.NL')}</option>
<option value="GB">{t('address.countries.GB')}</option>
<option value="US">{t('address.countries.US')}</option>
<option value="CN">{t('address.countries.CN')}</option>
</select>
</div>
</div>
@ -444,7 +428,6 @@ export default function OrganizationSettingsPage() {
{activeTab === 'licenses' && canViewBilling && <LicensesTab />}
</div>
{/* Actions (only for information and address tabs) */}
{canEdit && (activeTab === 'information' || activeTab === 'address') && (
<div className="bg-gray-50 px-8 py-4 border-t border-gray-200 flex items-center justify-end space-x-4">
<button
@ -453,7 +436,7 @@ export default function OrganizationSettingsPage() {
disabled={isSaving}
className="px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Annuler
{t('actions.cancel')}
</button>
<button
type="button"
@ -464,10 +447,10 @@ export default function OrganizationSettingsPage() {
{isSaving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Enregistrement...
{t('actions.saving')}
</>
) : (
'Enregistrer'
t('actions.save')
)}
</button>
</div>

View File

@ -1,20 +1,16 @@
/**
* Subscription Management Page
*
* Redirects to Organization settings with Subscription tab
*/
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useSearchParams } from 'next/navigation';
import { useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
export default function SubscriptionPage() {
const t = useTranslations('dashboard.subscriptionRedirect');
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
// Preserve any query parameters (success, canceled) from Stripe redirects
const params = searchParams.toString();
const redirectUrl = `/dashboard/settings/organization${params ? `?${params}` : ''}`;
router.replace(redirectUrl);
@ -24,7 +20,7 @@ export default function SubscriptionPage() {
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-blue-600 mb-4"></div>
<p className="text-gray-600">Redirection...</p>
<p className="text-gray-600">{t('loading')}</p>
</div>
</div>
);

View File

@ -1,19 +1,14 @@
/**
* User Management Page
*
* Manage organization users, roles, and invitations
*/
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslations, useLocale } from 'next-intl';
import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
import { createInvitation, listInvitations, cancelInvitation } from '@/lib/api/invitations';
import { useAuth } from '@/lib/context/auth-context';
import Link from 'next/link';
import { Link, useRouter } from '@/i18n/navigation';
import ExportButton from '@/components/ExportButton';
import { PageHeader } from '@/components/ui/PageHeader';
const PAGE_SIZE = 5;
@ -26,13 +21,18 @@ function Pagination({
total: number;
onPage: (p: number) => void;
}) {
const t = useTranslations('dashboard.usersManagement.pagination');
const totalPages = Math.ceil(total / PAGE_SIZE);
if (totalPages <= 1) return null;
return (
<div className="px-6 py-3 flex items-center justify-between border-t border-gray-200">
<p className="text-sm text-gray-500">
{Math.min((page - 1) * PAGE_SIZE + 1, total)}{Math.min(page * PAGE_SIZE, total)} sur {total}
{t('info', {
from: Math.min((page - 1) * PAGE_SIZE + 1, total),
to: Math.min(page * PAGE_SIZE, total),
total,
})}
</p>
<div className="flex items-center gap-1">
<button
@ -68,6 +68,9 @@ function Pagination({
}
export default function UsersManagementPage() {
const t = useTranslations('dashboard.usersManagement');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const router = useRouter();
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
@ -106,14 +109,14 @@ export default function UsersManagementPage() {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
queryClient.invalidateQueries({ queryKey: ['invitations'] });
setSuccess("Invitation envoyée avec succès ! L'utilisateur recevra un email avec un lien d'inscription.");
setSuccess(t('messages.inviteSuccess'));
setShowInviteModal(false);
setInviteForm({ email: '', firstName: '', lastName: '', role: 'USER' });
setInvitationsPage(1);
setTimeout(() => setSuccess(''), 5000);
},
onError: (err: any) => {
setError(err.response?.data?.message || "Échec de l'envoi de l'invitation");
setError(err.response?.data?.message || t('messages.inviteError'));
setTimeout(() => setError(''), 5000);
},
});
@ -123,11 +126,11 @@ export default function UsersManagementPage() {
updateUser(id, { role }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setSuccess('Rôle mis à jour avec succès');
setSuccess(t('messages.roleSuccess'));
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Échec de la mise à jour du rôle');
setError(err.response?.data?.message || t('messages.roleError'));
setTimeout(() => setError(''), 5000);
},
});
@ -138,11 +141,11 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess("Statut de l'utilisateur mis à jour avec succès");
setSuccess(t('messages.statusSuccess'));
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Échec de la mise à jour du statut');
setError(err.response?.data?.message || t('messages.statusError'));
setTimeout(() => setError(''), 5000);
},
});
@ -152,11 +155,11 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('Utilisateur supprimé avec succès');
setSuccess(t('messages.deleteSuccess'));
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || "Échec de la suppression de l'utilisateur");
setError(err.response?.data?.message || t('messages.deleteError'));
setTimeout(() => setError(''), 5000);
},
});
@ -166,11 +169,11 @@ export default function UsersManagementPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invitations'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('Invitation annulée avec succès');
setSuccess(t('messages.cancelInviteSuccess'));
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || "Échec de l'annulation de l'invitation");
setError(err.response?.data?.message || t('messages.cancelInviteError'));
setTimeout(() => setError(''), 5000);
},
});
@ -200,19 +203,20 @@ export default function UsersManagementPage() {
};
const handleToggleActive = (userId: string, isActive: boolean) => {
if (window.confirm(`Êtes-vous sûr de vouloir ${isActive ? 'désactiver' : 'activer'} cet utilisateur ?`)) {
const action = isActive ? t('confirms.toggleDeactivate') : t('confirms.toggleActivate');
if (window.confirm(t('confirms.toggleActive', { action }))) {
toggleActiveMutation.mutate({ id: userId, isActive });
}
};
const handleDelete = (userId: string) => {
if (window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.')) {
if (window.confirm(t('confirms.delete'))) {
deleteMutation.mutate(userId);
}
};
const handleCancelInvitation = (invId: string, name: string) => {
if (window.confirm(`Annuler l'invitation envoyée à ${name} ?`)) {
if (window.confirm(t('confirms.cancelInvite', { name }))) {
cancelInvitationMutation.mutate(invId);
}
};
@ -235,7 +239,6 @@ export default function UsersManagementPage() {
return (
<div className="space-y-6">
{/* License Warning */}
{licenseStatus && !licenseStatus.canInvite && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start">
@ -245,14 +248,13 @@ export default function UsersManagementPage() {
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-amber-800">Limite de licences atteinte</h3>
<h3 className="text-sm font-medium text-amber-800">{t('license.limitTitle')}</h3>
<p className="mt-1 text-sm text-amber-700">
Votre organisation a utilisé toutes les licences disponibles ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
Mettez à niveau votre abonnement pour inviter plus d'utilisateurs.
{t('license.limitMessage', { used: licenseStatus.usedLicenses, max: licenseStatus.maxLicenses })}
</p>
<div className="mt-3">
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-amber-800 hover:text-amber-900 underline">
Mettre à niveau l'abonnement
{t('license.upgradeLink')}
</Link>
</div>
</div>
@ -260,7 +262,6 @@ export default function UsersManagementPage() {
</div>
)}
{/* License Usage Info */}
{licenseStatus && licenseStatus.canInvite && licenseStatus.availableLicenses <= 2 && licenseStatus.maxLicenses !== -1 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
@ -269,54 +270,59 @@ export default function UsersManagementPage() {
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<span className="text-sm text-blue-800">
{licenseStatus.availableLicenses} licence{licenseStatus.availableLicenses !== 1 ? 's' : ''} restante{licenseStatus.availableLicenses !== 1 ? 's' : ''} ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} utilisées)
{t('license.remaining', {
count: licenseStatus.availableLicenses,
used: licenseStatus.usedLicenses,
max: licenseStatus.maxLicenses,
})}
</span>
</div>
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-blue-600 hover:text-blue-800">
Gérer l'abonnement
{t('license.manageLink')}
</Link>
</div>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestion des Utilisateurs</h1>
<p className="text-sm text-gray-500 mt-1">Gérez les membres de l'équipe et leurs permissions</p>
</div>
<div className="flex items-center space-x-3">
<PageHeader
title={t('header.title')}
description={t('header.subtitle')}
actions={
<>
<ExportButton
data={allUsers}
filename="utilisateurs"
filename={t('exportFilename')}
columns={[
{ key: 'firstName', label: 'Prénom' },
{ key: 'lastName', label: 'Nom' },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Rôle', format: (v) => ({ ADMIN: 'Administrateur', MANAGER: 'Manager', USER: 'Utilisateur', VIEWER: 'Lecteur' }[v] || v) },
{ key: 'isActive', label: 'Statut', format: (v) => v ? 'Actif' : 'Inactif' },
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
{ key: 'firstName', label: t('export.firstName') },
{ key: 'lastName', label: t('export.lastName') },
{ key: 'email', label: t('export.email') },
{ key: 'role', label: t('export.role'), format: (v) => t(`modal.rolesExport.${v}` as any) || v },
{ key: 'isActive', label: t('export.status'), format: (v) => v ? t('users.active') : t('users.inactive') },
{ key: 'createdAt', label: t('export.createdAt'), format: (v) => v ? new Date(v).toLocaleDateString(dateLocale) : '' },
]}
/>
{licenseStatus?.canInvite ? (
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
className="inline-flex items-center px-3 sm:px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<span className="mr-2">+</span>
Inviter un utilisateur
<span className="mr-1.5">+</span>
<span className="hidden sm:inline">{t('actions.invite')}</span>
<span className="sm:hidden">{t('actions.inviteShort')}</span>
</button>
) : (
<Link
href="/dashboard/settings/subscription"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
className="inline-flex items-center px-3 sm:px-4 py-2 text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
>
<span className="mr-2">+</span>
Mettre à niveau
<span className="mr-1.5">+</span>
<span className="hidden sm:inline">{t('actions.upgrade')}</span>
<span className="sm:hidden">{t('actions.upgradeShort')}</span>
</Link>
)}
</div>
</div>
</>
}
/>
{success && (
<div className="rounded-md bg-green-50 p-4">
@ -330,18 +336,17 @@ export default function UsersManagementPage() {
</div>
)}
{/* Users Table */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Utilisateurs</h2>
<h2 className="text-lg font-medium text-gray-900">{t('users.title')}</h2>
{allUsers.length > 0 && (
<p className="text-sm text-gray-500 mt-1">{allUsers.length} membre{allUsers.length !== 1 ? 's' : ''}</p>
<p className="text-sm text-gray-500 mt-1">{t('users.membersCount', { count: allUsers.length })}</p>
)}
</div>
{isLoading ? (
<div className="px-6 py-12 text-center text-gray-500">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
Chargement des utilisateurs...
{t('loading')}
</div>
) : pagedUsers.length > 0 ? (
<>
@ -349,12 +354,12 @@ export default function UsersManagementPage() {
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Utilisateur</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rôle</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date de création</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.user')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.email')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.role')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.status')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.createdAt')}</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{t('users.table.actions')}</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
@ -385,19 +390,19 @@ export default function UsersManagementPage() {
user.id === currentUser?.id
}
>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
<option value="MANAGER">Manager</option>
<option value="USER">User</option>
<option value="VIEWER">Viewer</option>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">{t('modal.roles.ADMIN')}</option>}
<option value="MANAGER">{t('modal.roles.MANAGER')}</option>
<option value="USER">{t('modal.roles.USER')}</option>
<option value="VIEWER">{t('modal.roles.VIEWER')}</option>
</select>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{user.isActive ? 'Actif' : 'Inactif'}
{user.isActive ? t('users.active') : t('users.inactive')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString('fr-FR')}
{new Date(user.createdAt).toLocaleDateString(dateLocale)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
@ -430,18 +435,18 @@ export default function UsersManagementPage() {
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun utilisateur</h3>
<p className="mt-1 text-sm text-gray-500">Commencez par inviter un membre de l'équipe</p>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('users.empty.title')}</h3>
<p className="mt-1 text-sm text-gray-500">{t('users.empty.description')}</p>
<div className="mt-6">
{licenseStatus?.canInvite ? (
<button onClick={() => setShowInviteModal(true)} className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
<span className="mr-2">+</span>
Inviter un utilisateur
{t('actions.invite')}
</button>
) : (
<Link href="/dashboard/settings/subscription" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700">
<span className="mr-2">+</span>
Mettre à niveau
{t('actions.upgrade')}
</Link>
)}
</div>
@ -449,25 +454,24 @@ export default function UsersManagementPage() {
)}
</div>
{/* Pending Invitations */}
{allPending.length > 0 && (
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Invitations en attente</h2>
<h2 className="text-lg font-medium text-gray-900">{t('invitations.title')}</h2>
<p className="text-sm text-gray-500 mt-1">
Utilisateurs invités mais n'ayant pas encore créé leur compte — {allPending.length} invitation{allPending.length !== 1 ? 's' : ''}
{t('invitations.subtitle', { count: allPending.length })}
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Utilisateur</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rôle</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Expire le</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.user')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.email')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.role')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.expires')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.status')}</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.actions')}</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
@ -492,11 +496,11 @@ export default function UsersManagementPage() {
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(inv.expiresAt).toLocaleDateString('fr-FR')}
{new Date(inv.expiresAt).toLocaleDateString(dateLocale)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${isExpired ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'}`}>
{isExpired ? 'Expirée' : 'En attente'}
{isExpired ? t('invitations.expired') : t('invitations.pending')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
@ -508,7 +512,7 @@ export default function UsersManagementPage() {
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Annuler
{t('invitations.cancel')}
</button>
</td>
</tr>
@ -521,7 +525,6 @@ export default function UsersManagementPage() {
</div>
)}
{/* Actions Menu Modal */}
{openMenuId && menuPosition && (
<>
<div
@ -548,14 +551,14 @@ export default function UsersManagementPage() {
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<span className="text-sm font-medium text-gray-700">Désactiver</span>
<span className="text-sm font-medium text-gray-700">{t('users.actions.deactivate')}</span>
</>
) : (
<>
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium text-gray-700">Activer</span>
<span className="text-sm font-medium text-gray-700">{t('users.actions.activate')}</span>
</>
)}
</button>
@ -571,14 +574,13 @@ export default function UsersManagementPage() {
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span className="text-sm font-medium text-red-600">Supprimer</span>
<span className="text-sm font-medium text-red-600">{t('users.actions.delete')}</span>
</button>
</div>
</div>
</>
)}
{/* Invite Modal */}
{showInviteModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
@ -586,7 +588,7 @@ export default function UsersManagementPage() {
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Inviter un utilisateur</h3>
<h3 className="text-lg font-medium text-gray-900">{t('modal.title')}</h3>
<button onClick={() => setShowInviteModal(false)} className="text-gray-400 hover:text-gray-500">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@ -596,7 +598,7 @@ export default function UsersManagementPage() {
<form onSubmit={handleInvite} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Prénom *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')} *</label>
<input
type="text"
required
@ -606,7 +608,7 @@ export default function UsersManagementPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Nom *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')} *</label>
<input
type="text"
required
@ -617,7 +619,7 @@ export default function UsersManagementPage() {
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Adresse email *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.email')} *</label>
<input
type="email"
required
@ -627,15 +629,15 @@ export default function UsersManagementPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Rôle *</label>
<label className="block text-sm font-medium text-gray-700">{t('modal.role')} *</label>
<select
value={inviteForm.role}
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
>
<option value="USER">Utilisateur</option>
<option value="MANAGER">Manager</option>
<option value="VIEWER">Lecteur</option>
<option value="USER">{t('modal.roles.USER')}</option>
<option value="MANAGER">{t('modal.roles.MANAGER')}</option>
<option value="VIEWER">{t('modal.roles.VIEWER')}</option>
</select>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
@ -644,14 +646,14 @@ export default function UsersManagementPage() {
disabled={inviteMutation.isPending}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
>
{inviteMutation.isPending ? 'Envoi en cours...' : "Envoyer l'invitation"}
{inviteMutation.isPending ? t('modal.submitting') : t('modal.submit')}
</button>
<button
type="button"
onClick={() => setShowInviteModal(false)}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Annuler
{t('modal.cancel')}
</button>
</div>
</form>

Some files were not shown because too many files have changed in this diff Show More