Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
902438b6ce | ||
|
|
f5eaa4e083 | ||
|
|
9acabb6859 | ||
|
|
71d131f4cb | ||
|
|
8bd2a60749 | ||
|
|
84790e0c68 | ||
|
|
96963b05f0 | ||
|
|
8ae3d600ea | ||
|
|
ec0173483a | ||
|
|
b352d1d9a9 | ||
|
|
8649b8a13c | ||
|
|
982c893952 | ||
|
|
be1de882c3 |
@ -6,6 +6,8 @@
|
|||||||
"deleteOutDir": true,
|
"deleteOutDir": true,
|
||||||
"builder": "tsc",
|
"builder": "tsc",
|
||||||
"tsConfigPath": "tsconfig.build.json",
|
"tsConfigPath": "tsconfig.build.json",
|
||||||
"plugins": ["@nestjs/swagger"]
|
"plugins": ["@nestjs/swagger"],
|
||||||
|
"assets": [{ "include": "i18n/**/*.json", "outDir": "dist" }],
|
||||||
|
"watchAssets": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
apps/backend/package-lock.json
generated
41
apps/backend/package-lock.json
generated
@ -43,6 +43,7 @@
|
|||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"mjml": "^4.16.1",
|
"mjml": "^4.16.1",
|
||||||
|
"nestjs-i18n": "^10.6.5",
|
||||||
"nestjs-pino": "^4.4.1",
|
"nestjs-pino": "^4.4.1",
|
||||||
"nodemailer": "^7.0.9",
|
"nodemailer": "^7.0.9",
|
||||||
"opossum": "^8.1.3",
|
"opossum": "^8.1.3",
|
||||||
@ -5761,6 +5762,12 @@
|
|||||||
"node": ">=6.5"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@ -12169,6 +12176,34 @@
|
|||||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nestjs-pino": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.4.1.tgz",
|
||||||
@ -14472,6 +14507,12 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"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": {
|
"node_modules/string-length": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
||||||
|
|||||||
@ -59,6 +59,7 @@
|
|||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"mjml": "^4.16.1",
|
"mjml": "^4.16.1",
|
||||||
|
"nestjs-i18n": "^10.6.5",
|
||||||
"nestjs-pino": "^4.4.1",
|
"nestjs-pino": "^4.4.1",
|
||||||
"nodemailer": "^7.0.9",
|
"nodemailer": "^7.0.9",
|
||||||
"opossum": "^8.1.3",
|
"opossum": "^8.1.3",
|
||||||
|
|||||||
@ -3,7 +3,16 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
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 * as Joi from 'joi';
|
||||||
|
import { UserPreferenceResolver } from './infrastructure/i18n/user-preference.resolver';
|
||||||
|
|
||||||
// Import feature modules
|
// Import feature modules
|
||||||
import { AuthModule } from './application/auth/auth.module';
|
import { AuthModule } from './application/auth/auth.module';
|
||||||
@ -110,6 +119,29 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
inject: [ConfigService],
|
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
|
// Database
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
useFactory: (configService: ConfigService) => ({
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
|||||||
@ -10,13 +10,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
ApiBearerAuth,
|
|
||||||
ApiOperation,
|
|
||||||
ApiResponse,
|
|
||||||
ApiSecurity,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
|
|
||||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||||
import { RequiresFeature } from '../decorators/requires-feature.decorator';
|
import { RequiresFeature } from '../decorators/requires-feature.decorator';
|
||||||
@ -38,7 +32,7 @@ export class ApiKeysController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Générer une nouvelle clé API',
|
summary: 'Générer une nouvelle clé API',
|
||||||
description:
|
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({
|
@ApiResponse({
|
||||||
status: 201,
|
status: 201,
|
||||||
|
|||||||
@ -23,10 +23,7 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|||||||
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
|
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [TypeOrmModule.forFeature([ApiKeyOrmEntity, UserOrmEntity]), SubscriptionsModule],
|
||||||
TypeOrmModule.forFeature([ApiKeyOrmEntity, UserOrmEntity]),
|
|
||||||
SubscriptionsModule,
|
|
||||||
],
|
|
||||||
controllers: [ApiKeysController],
|
controllers: [ApiKeysController],
|
||||||
providers: [
|
providers: [
|
||||||
ApiKeysService,
|
ApiKeysService,
|
||||||
|
|||||||
@ -8,13 +8,7 @@
|
|||||||
* - Validation for inbound API key authentication
|
* - Validation for inbound API key authentication
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
ForbiddenException,
|
|
||||||
Inject,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
|||||||
@ -41,7 +41,12 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// 👇 Add this to register TypeORM repositories
|
// 👇 Add this to register TypeORM repositories
|
||||||
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity, PasswordResetTokenOrmEntity]),
|
TypeOrmModule.forFeature([
|
||||||
|
UserOrmEntity,
|
||||||
|
OrganizationOrmEntity,
|
||||||
|
InvitationTokenOrmEntity,
|
||||||
|
PasswordResetTokenOrmEntity,
|
||||||
|
]),
|
||||||
|
|
||||||
// Email module for sending invitations
|
// Email module for sending invitations
|
||||||
EmailModule,
|
EmailModule,
|
||||||
|
|||||||
@ -265,7 +265,9 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resetToken.expiresAt < new Date()) {
|
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);
|
const user = await this.userRepository.findById(resetToken.userId);
|
||||||
@ -286,10 +288,7 @@ export class AuthService {
|
|||||||
await this.userRepository.save(user);
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
// Mark token as used
|
// Mark token as used
|
||||||
await this.passwordResetTokenRepository.update(
|
await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() });
|
||||||
{ id: resetToken.id },
|
|
||||||
{ usedAt: new Date() }
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Password reset successfully for user: ${user.email}`);
|
this.logger.log(`Password reset successfully for user: ${user.email}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -744,10 +744,7 @@ export class AdminController {
|
|||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: 'Email sent successfully' })
|
@ApiResponse({ status: 200, description: 'Email sent successfully' })
|
||||||
@ApiResponse({ status: 400, description: 'SMTP error — check the message field' })
|
@ApiResponse({ status: 400, description: 'SMTP error — check the message field' })
|
||||||
async sendTestEmail(
|
async sendTestEmail(@Body() body: { to: string }, @CurrentUser() user: UserPayload) {
|
||||||
@Body() body: { to: string },
|
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
) {
|
|
||||||
if (!body?.to) {
|
if (!body?.to) {
|
||||||
throw new BadRequestException('Field "to" is required');
|
throw new BadRequestException('Field "to" is required');
|
||||||
}
|
}
|
||||||
@ -880,7 +877,9 @@ export class AdminController {
|
|||||||
@Param('documentId', ParseUUIDPipe) documentId: string,
|
@Param('documentId', ParseUUIDPipe) documentId: string,
|
||||||
@CurrentUser() user: UserPayload
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): 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);
|
const booking = await this.csvBookingRepository.findById(bookingId);
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
@ -894,7 +893,9 @@ export class AdminController {
|
|||||||
|
|
||||||
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
|
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) {
|
if (ormBooking) {
|
||||||
ormBooking.documents = updatedDocuments.map(doc => ({
|
ormBooking.documents = updatedDocuments.map(doc => ({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
|
|||||||
@ -289,7 +289,9 @@ export class AuthController {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to send contact email: ${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.' };
|
return { message: 'Message envoyé avec succès.' };
|
||||||
|
|||||||
@ -153,10 +153,7 @@ export class InvitationsController {
|
|||||||
@ApiResponse({ status: 204, description: 'Invitation cancelled' })
|
@ApiResponse({ status: 204, description: 'Invitation cancelled' })
|
||||||
@ApiResponse({ status: 404, description: 'Invitation not found' })
|
@ApiResponse({ status: 404, description: 'Invitation not found' })
|
||||||
@ApiResponse({ status: 400, description: 'Invitation already used' })
|
@ApiResponse({ status: 400, description: 'Invitation already used' })
|
||||||
async cancelInvitation(
|
async cancelInvitation(@Param('id') id: string, @CurrentUser() user: UserPayload): Promise<void> {
|
||||||
@Param('id') id: string,
|
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
): Promise<void> {
|
|
||||||
this.logger.log(`[User: ${user.email}] Cancelling invitation: ${id}`);
|
this.logger.log(`[User: ${user.email}] Cancelling invitation: ${id}`);
|
||||||
await this.invitationService.cancelInvitation(id, user.organizationId);
|
await this.invitationService.cancelInvitation(id, user.organizationId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -166,27 +166,16 @@ export class RatesController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Map DTO to domain input
|
|
||||||
const searchInput = {
|
const searchInput = {
|
||||||
origin: dto.origin,
|
origin: dto.origin,
|
||||||
destination: dto.destination,
|
destination: dto.destination,
|
||||||
volumeCBM: dto.volumeCBM,
|
volumeCBM: dto.volumeCBM,
|
||||||
weightKG: dto.weightKG,
|
weightKG: dto.weightKG,
|
||||||
palletCount: dto.palletCount ?? 0,
|
|
||||||
containerType: dto.containerType,
|
containerType: dto.containerType,
|
||||||
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
|
||||||
|
|
||||||
// Service requirements for detailed pricing
|
|
||||||
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
||||||
requiresSpecialHandling: dto.requiresSpecialHandling ?? false,
|
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
||||||
requiresTailgate: dto.requiresTailgate ?? false,
|
|
||||||
requiresStraps: dto.requiresStraps ?? false,
|
|
||||||
requiresThermalCover: dto.requiresThermalCover ?? false,
|
|
||||||
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
|
|
||||||
requiresAppointment: dto.requiresAppointment ?? false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute CSV rate search
|
|
||||||
const result = await this.csvRateSearchService.execute(searchInput);
|
const result = await this.csvRateSearchService.execute(searchInput);
|
||||||
|
|
||||||
// Map domain output to response DTO
|
// Map domain output to response DTO
|
||||||
@ -241,27 +230,16 @@ export class RatesController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Map DTO to domain input
|
|
||||||
const searchInput = {
|
const searchInput = {
|
||||||
origin: dto.origin,
|
origin: dto.origin,
|
||||||
destination: dto.destination,
|
destination: dto.destination,
|
||||||
volumeCBM: dto.volumeCBM,
|
volumeCBM: dto.volumeCBM,
|
||||||
weightKG: dto.weightKG,
|
weightKG: dto.weightKG,
|
||||||
palletCount: dto.palletCount ?? 0,
|
|
||||||
containerType: dto.containerType,
|
containerType: dto.containerType,
|
||||||
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
|
||||||
|
|
||||||
// Service requirements for detailed pricing
|
|
||||||
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
||||||
requiresSpecialHandling: dto.requiresSpecialHandling ?? false,
|
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
||||||
requiresTailgate: dto.requiresTailgate ?? false,
|
|
||||||
requiresStraps: dto.requiresStraps ?? false,
|
|
||||||
requiresThermalCover: dto.requiresThermalCover ?? false,
|
|
||||||
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
|
|
||||||
requiresAppointment: dto.requiresAppointment ?? false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute CSV rate search WITH OFFERS GENERATION
|
|
||||||
const result = await this.csvRateSearchService.executeWithOffers(searchInput);
|
const result = await this.csvRateSearchService.executeWithOffers(searchInput);
|
||||||
|
|
||||||
// Map domain output to response DTO
|
// Map domain output to response DTO
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Locale } from '@domain/value-objects/locale.vo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User payload interface extracted from JWT
|
* User payload interface extracted from JWT
|
||||||
@ -10,6 +11,7 @@ export interface UserPayload {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
preferredLanguage?: Locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -11,384 +11,192 @@ import {
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { RateSearchFiltersDto } from './rate-search-filters.dto';
|
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 {
|
export class CsvRateSearchDto {
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Origin UN/LOCODE', example: 'FRFOS' })
|
||||||
description: 'Origin port code (UN/LOCODE format)',
|
|
||||||
example: 'NLRTM',
|
|
||||||
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
|
|
||||||
})
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
origin: string;
|
origin: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Destination UN/LOCODE', example: 'CNSHA' })
|
||||||
description: 'Destination port code (UN/LOCODE format)',
|
|
||||||
example: 'USNYC',
|
|
||||||
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
|
|
||||||
})
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
destination: string;
|
destination: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Volume in cubic meters (CBM)', minimum: 0.01, example: 10.5 })
|
||||||
description: 'Volume in cubic meters (CBM)',
|
|
||||||
minimum: 0.01,
|
|
||||||
example: 25.5,
|
|
||||||
})
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0.01)
|
@Min(0.01)
|
||||||
volumeCBM: number;
|
volumeCBM: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Weight in kilograms', minimum: 1, example: 2500 })
|
||||||
description: 'Weight in kilograms',
|
|
||||||
minimum: 1,
|
|
||||||
example: 3500,
|
|
||||||
})
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
weightKG: number;
|
weightKG: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({ description: 'Container type filter', example: 'LCL' })
|
||||||
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',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
containerType?: string;
|
containerType?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({ description: 'Cargo contains dangerous goods', example: false })
|
||||||
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,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
hasDangerousGoods?: boolean;
|
hasDangerousGoods?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({ description: 'Advanced filters', type: RateSearchFiltersDto })
|
||||||
description: 'Requires special handling',
|
|
||||||
example: true,
|
|
||||||
default: false,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@ValidateNested()
|
||||||
requiresSpecialHandling?: boolean;
|
@Type(() => RateSearchFiltersDto)
|
||||||
|
filters?: RateSearchFiltersDto;
|
||||||
@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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* CSV Rate Search Response DTO
|
|
||||||
*
|
|
||||||
* Response containing matching rates with calculated prices
|
|
||||||
*/
|
|
||||||
export class CsvRateSearchResponseDto {
|
export class CsvRateSearchResponseDto {
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Array of matching rate results', type: [Object] })
|
||||||
description: 'Array of matching rate results',
|
|
||||||
type: [Object], // Will be replaced with RateResultDto
|
|
||||||
})
|
|
||||||
results: CsvRateResultDto[];
|
results: CsvRateResultDto[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Total number of results', example: 12 })
|
||||||
description: 'Total number of results found',
|
|
||||||
example: 15,
|
|
||||||
})
|
|
||||||
totalResults: number;
|
totalResults: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'CSV files searched', type: [String] })
|
||||||
description: 'CSV files that were searched',
|
|
||||||
type: [String],
|
|
||||||
example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'],
|
|
||||||
})
|
|
||||||
searchedFiles: string[];
|
searchedFiles: string[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Timestamp of search', example: '2026-05-11T10:30:00Z' })
|
||||||
description: 'Timestamp when search was executed',
|
|
||||||
example: '2025-10-23T10:30:00Z',
|
|
||||||
})
|
|
||||||
searchedAt: Date;
|
searchedAt: Date;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Applied filters' })
|
||||||
description: 'Filters that were applied to the search',
|
|
||||||
type: RateSearchFiltersDto,
|
|
||||||
})
|
|
||||||
appliedFilters: RateSearchFiltersDto;
|
appliedFilters: RateSearchFiltersDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export class FobBreakdownDto {
|
||||||
* Surcharge Item DTO
|
documentation: number;
|
||||||
*/
|
isps: number;
|
||||||
export class SurchargeItemDto {
|
handling: number;
|
||||||
@ApiProperty({
|
solas: number;
|
||||||
description: 'Surcharge code',
|
customs: number;
|
||||||
example: 'DG_FEE',
|
ams_aci: number;
|
||||||
})
|
isf5: number;
|
||||||
code: string;
|
dgAdmin: number;
|
||||||
|
|
||||||
@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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Price Breakdown DTO
|
|
||||||
*/
|
|
||||||
export class PriceBreakdownDto {
|
export class PriceBreakdownDto {
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Freight charge', example: 420.0 })
|
||||||
description: 'Base price before any charges',
|
freightCharge: number;
|
||||||
example: 0,
|
|
||||||
|
@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({
|
@ApiProperty({
|
||||||
description: 'Charge based on volume (CBM)',
|
description: 'DG surcharge status',
|
||||||
example: 150.0,
|
enum: ['computed', 'on_request', 'not_accepted'],
|
||||||
|
example: 'computed',
|
||||||
})
|
})
|
||||||
volumeCharge: number;
|
dgSurchargeStatus: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Total freight in freightCurrency', example: 420.0 })
|
||||||
description: 'Charge based on weight (KG)',
|
totalFreight: number;
|
||||||
example: 25.0,
|
|
||||||
})
|
|
||||||
weightCharge: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Total FOB in fobCurrency', example: 245 })
|
||||||
description: 'Charge for pallets',
|
totalFob: number;
|
||||||
example: 125.0,
|
|
||||||
})
|
|
||||||
palletCharge: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Sum for sorting (currency-naive)', example: 665.0 })
|
||||||
description: 'List of all surcharges',
|
totalPriceForSorting: number;
|
||||||
type: [SurchargeItemDto],
|
|
||||||
})
|
|
||||||
surcharges: SurchargeItemDto[];
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Primary currency', example: 'USD' })
|
||||||
description: 'Total of all surcharges',
|
primaryCurrency: string;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Single CSV Rate Result DTO
|
|
||||||
*/
|
|
||||||
export class CsvRateResultDto {
|
export class CsvRateResultDto {
|
||||||
@ApiProperty({
|
@ApiProperty({ example: 'SSC Consolidation' })
|
||||||
description: 'Company name',
|
|
||||||
example: 'SSC Consolidation',
|
|
||||||
})
|
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ example: 'bookings@ssc.com' })
|
||||||
description: 'Company email for booking requests',
|
|
||||||
example: 'bookings@sscconsolidation.com',
|
|
||||||
})
|
|
||||||
companyEmail: string;
|
companyEmail: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Origin CFS name', example: 'Fos Sur Mer' })
|
||||||
description: 'Origin port code',
|
originCFS: string;
|
||||||
example: 'NLRTM',
|
|
||||||
})
|
@ApiProperty({ description: 'Origin UN/LOCODE', example: 'FRFOS' })
|
||||||
origin: string;
|
origin: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Port of loading', example: 'FOS SUR MER' })
|
||||||
description: 'Destination port code',
|
portOfLoading: string;
|
||||||
example: 'USNYC',
|
|
||||||
})
|
@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;
|
destination: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Destination country', example: 'China' })
|
||||||
description: 'Container type',
|
destinationCountry: string;
|
||||||
example: 'LCL',
|
|
||||||
})
|
@ApiProperty({ example: 'LCL' })
|
||||||
containerType: string;
|
containerType: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Detailed price breakdown', type: PriceBreakdownDto })
|
||||||
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,
|
|
||||||
})
|
|
||||||
priceBreakdown: PriceBreakdownDto;
|
priceBreakdown: PriceBreakdownDto;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Departure frequency', example: 'Weekly' })
|
||||||
description: 'Whether this rate has separate surcharges',
|
frequency: string;
|
||||||
example: true,
|
|
||||||
})
|
|
||||||
hasSurcharges: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Transit time (adjusted if service level)', example: 28 })
|
||||||
description: 'Details of surcharges if any',
|
|
||||||
example: 'BAF+CAF included',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
surchargeDetails: string | null;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Transit time in days',
|
|
||||||
example: 28,
|
|
||||||
})
|
|
||||||
transitDays: number;
|
transitDays: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Rate validity end date', example: '2026-12-31' })
|
||||||
description: 'Rate validity end date',
|
|
||||||
example: '2025-12-31',
|
|
||||||
})
|
|
||||||
validUntil: string;
|
validUntil: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'Whether DG cargo is accepted', example: true })
|
||||||
description: 'Source of the rate',
|
dgAccepted: boolean;
|
||||||
enum: ['CSV', 'API'],
|
|
||||||
example: 'CSV',
|
|
||||||
})
|
|
||||||
source: 'CSV' | 'API';
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({ description: 'DG surcharge status', example: 'computed' })
|
||||||
description: 'Match score (0-100) indicating how well this rate matches the search',
|
dgSurchargeStatus: string;
|
||||||
minimum: 0,
|
|
||||||
maximum: 100,
|
@ApiProperty({ description: 'Internal remarks', example: 'GR1/GR2' })
|
||||||
example: 95,
|
remarks: string;
|
||||||
})
|
|
||||||
|
@ApiProperty({ example: 'CSV' })
|
||||||
|
source: 'CSV';
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Match score 0-100', example: 95 })
|
||||||
matchScore: number;
|
matchScore: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({ enum: ['RAPID', 'STANDARD', 'ECONOMIC'] })
|
||||||
description: 'Service level (only present when using search-csv-offers endpoint)',
|
|
||||||
enum: ['RAPID', 'STANDARD', 'ECONOMIC'],
|
|
||||||
example: 'RAPID',
|
|
||||||
})
|
|
||||||
serviceLevel?: string;
|
serviceLevel?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({ description: 'Price multiplier for service level', example: 1.0 })
|
||||||
description: 'Original price before service level adjustment',
|
priceMultiplier?: number;
|
||||||
example: { usd: 1500.0, eur: 1350.0 },
|
|
||||||
})
|
|
||||||
originalPrice?: {
|
|
||||||
usd: number;
|
|
||||||
eur: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Original transit days before service level adjustment',
|
description: 'Original transit days before service level adjustment',
|
||||||
example: 20,
|
example: 28,
|
||||||
})
|
})
|
||||||
originalTransitDays?: number;
|
originalTransitDays?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,15 +10,9 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate Search Filters DTO
|
|
||||||
*
|
|
||||||
* Advanced filters for narrowing down rate search results
|
|
||||||
* All filters are optional
|
|
||||||
*/
|
|
||||||
export class RateSearchFiltersDto {
|
export class RateSearchFiltersDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'List of company names to include in search',
|
description: 'List of company names to include',
|
||||||
type: [String],
|
type: [String],
|
||||||
example: ['SSC Consolidation', 'ECU Worldwide'],
|
example: ['SSC Consolidation', 'ECU Worldwide'],
|
||||||
})
|
})
|
||||||
@ -28,59 +22,25 @@ export class RateSearchFiltersDto {
|
|||||||
companies?: string[];
|
companies?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Minimum volume in CBM (cubic meters)',
|
description: 'Only show "Direct" routing (exclude transhipment)',
|
||||||
minimum: 0,
|
example: false,
|
||||||
example: 1,
|
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsBoolean()
|
||||||
@Min(0)
|
onlyDirect?: boolean;
|
||||||
minVolumeCBM?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Maximum volume in CBM (cubic meters)',
|
description: 'Exclude routes where DG is not accepted',
|
||||||
minimum: 0,
|
example: false,
|
||||||
example: 100,
|
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsBoolean()
|
||||||
@Min(0)
|
excludeNonDgRoutes?: boolean;
|
||||||
maxVolumeCBM?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Minimum weight in kilograms',
|
description: 'Minimum price (totalPriceForSorting)',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 100,
|
example: 500,
|
||||||
})
|
|
||||||
@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,
|
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@ -88,9 +48,9 @@ export class RateSearchFiltersDto {
|
|||||||
minPrice?: number;
|
minPrice?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Maximum price in selected currency',
|
description: 'Maximum price (totalPriceForSorting)',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 5000,
|
example: 3000,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@ -110,7 +70,7 @@ export class RateSearchFiltersDto {
|
|||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Maximum transit time in days',
|
description: 'Maximum transit time in days',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 40,
|
example: 45,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@ -120,7 +80,7 @@ export class RateSearchFiltersDto {
|
|||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Container types to filter by',
|
description: 'Container types to filter by',
|
||||||
type: [String],
|
type: [String],
|
||||||
example: ['LCL', '20DRY', '40HC'],
|
example: ['LCL'],
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ -128,7 +88,7 @@ export class RateSearchFiltersDto {
|
|||||||
containerTypes?: string[];
|
containerTypes?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Preferred currency for price filtering',
|
description: 'Preferred currency for price display',
|
||||||
enum: ['USD', 'EUR'],
|
enum: ['USD', 'EUR'],
|
||||||
example: 'USD',
|
example: 'USD',
|
||||||
})
|
})
|
||||||
@ -136,17 +96,9 @@ export class RateSearchFiltersDto {
|
|||||||
@IsEnum(['USD', 'EUR'])
|
@IsEnum(['USD', 'EUR'])
|
||||||
currency?: 'USD' | 'EUR';
|
currency?: 'USD' | 'EUR';
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Only show all-in prices (without separate surcharges)',
|
|
||||||
example: false,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
onlyAllInPrices?: boolean;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Departure date to check rate validity (ISO 8601)',
|
description: 'Departure date to check rate validity (ISO 8601)',
|
||||||
example: '2025-06-15',
|
example: '2026-06-15',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,4 @@
|
|||||||
import {
|
import { Controller, Get, Query, Res, UseGuards, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Query,
|
|
||||||
Res,
|
|
||||||
UseGuards,
|
|
||||||
HttpException,
|
|
||||||
HttpStatus,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
@ -22,7 +14,7 @@ export class LogsController {
|
|||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
this.logExporterUrl = this.configService.get<string>(
|
this.logExporterUrl = this.configService.get<string>(
|
||||||
'LOG_EXPORTER_URL',
|
'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}`);
|
if (!res.ok) throw new Error(`log-exporter error: ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw new HttpException(
|
throw new HttpException({ error: err.message }, HttpStatus.BAD_GATEWAY);
|
||||||
{ error: err.message },
|
|
||||||
HttpStatus.BAD_GATEWAY,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,7 +48,7 @@ export class LogsController {
|
|||||||
@Query('end') end: string,
|
@Query('end') end: string,
|
||||||
@Query('limit') limit: string,
|
@Query('limit') limit: string,
|
||||||
@Query('format') format: string = 'json',
|
@Query('format') format: string = 'json',
|
||||||
@Res() res: Response,
|
@Res() res: Response
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@ -71,10 +60,9 @@ export class LogsController {
|
|||||||
if (limit) params.set('limit', limit);
|
if (limit) params.set('limit', limit);
|
||||||
params.set('format', format);
|
params.set('format', format);
|
||||||
|
|
||||||
const upstream = await fetch(
|
const upstream = await fetch(`${this.logExporterUrl}/api/logs/export?${params}`, {
|
||||||
`${this.logExporterUrl}/api/logs/export?${params}`,
|
signal: AbortSignal.timeout(30000),
|
||||||
{ signal: AbortSignal.timeout(30000) },
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
const body = await upstream.json().catch(() => ({}));
|
const body = await upstream.json().catch(() => ({}));
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
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 {
|
import {
|
||||||
CsvRateSearchOutput,
|
CsvRateSearchOutput,
|
||||||
CsvRateSearchResult,
|
CsvRateSearchResult,
|
||||||
@ -9,100 +14,92 @@ import { RateSearchFiltersDto } from '../dto/rate-search-filters.dto';
|
|||||||
import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto';
|
import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto';
|
||||||
import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity';
|
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()
|
@Injectable()
|
||||||
export class CsvRateMapper {
|
export class CsvRateMapper {
|
||||||
/**
|
|
||||||
* Map DTO filters to domain filters
|
|
||||||
*/
|
|
||||||
mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined {
|
mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined {
|
||||||
if (!dto) {
|
if (!dto) return undefined;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
companies: dto.companies,
|
companies: dto.companies,
|
||||||
minVolumeCBM: dto.minVolumeCBM,
|
onlyDirect: dto.onlyDirect,
|
||||||
maxVolumeCBM: dto.maxVolumeCBM,
|
excludeNonDgRoutes: dto.excludeNonDgRoutes,
|
||||||
minWeightKG: dto.minWeightKG,
|
|
||||||
maxWeightKG: dto.maxWeightKG,
|
|
||||||
palletCount: dto.palletCount,
|
|
||||||
minPrice: dto.minPrice,
|
minPrice: dto.minPrice,
|
||||||
maxPrice: dto.maxPrice,
|
maxPrice: dto.maxPrice,
|
||||||
currency: dto.currency,
|
currency: dto.currency,
|
||||||
minTransitDays: dto.minTransitDays,
|
minTransitDays: dto.minTransitDays,
|
||||||
maxTransitDays: dto.maxTransitDays,
|
maxTransitDays: dto.maxTransitDays,
|
||||||
containerTypes: dto.containerTypes,
|
containerTypes: dto.containerTypes,
|
||||||
onlyAllInPrices: dto.onlyAllInPrices,
|
|
||||||
departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined,
|
departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Map domain search result to DTO
|
|
||||||
*/
|
|
||||||
mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto {
|
mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto {
|
||||||
const rate = result.rate;
|
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 {
|
return {
|
||||||
companyName: rate.companyName,
|
companyName: rate.companyName,
|
||||||
companyEmail: rate.companyEmail,
|
companyEmail: rate.companyEmail,
|
||||||
origin: rate.origin.getValue(),
|
originCFS: rate.originCFS,
|
||||||
destination: rate.destination.getValue(),
|
origin: rate.originCode.getValue(),
|
||||||
|
portOfLoading: rate.portOfLoading,
|
||||||
|
routing: rate.routing,
|
||||||
|
destinationCFS: rate.destinationCFS,
|
||||||
|
destination: rate.destinationCode.getValue(),
|
||||||
|
destinationCountry: rate.destinationCountry,
|
||||||
containerType: rate.containerType.getValue(),
|
containerType: rate.containerType.getValue(),
|
||||||
priceUSD: result.calculatedPrice.usd,
|
priceBreakdown,
|
||||||
priceEUR: result.calculatedPrice.eur,
|
frequency: rate.frequency,
|
||||||
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
|
|
||||||
transitDays: result.adjustedTransitDays ?? rate.transitDays,
|
transitDays: result.adjustedTransitDays ?? rate.transitDays,
|
||||||
validUntil: rate.validity.getEndDate().toISOString().split('T')[0],
|
validUntil: rate.validity.getEndDate().toISOString().split('T')[0],
|
||||||
|
dgAccepted: rate.isDgAccepted(),
|
||||||
|
dgSurchargeStatus: bd.dgSurchargeStatus,
|
||||||
|
remarks: rate.remarks,
|
||||||
source: result.source,
|
source: result.source,
|
||||||
matchScore: result.matchScore,
|
matchScore: result.matchScore,
|
||||||
// Include service level fields if present
|
|
||||||
serviceLevel: result.serviceLevel,
|
serviceLevel: result.serviceLevel,
|
||||||
originalPrice: result.originalPrice,
|
priceMultiplier: result.priceMultiplier,
|
||||||
originalTransitDays: result.originalTransitDays,
|
originalTransitDays: result.originalTransitDays,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Map domain search output to response DTO
|
|
||||||
*/
|
|
||||||
mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto {
|
mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto {
|
||||||
return {
|
return {
|
||||||
results: output.results.map(result => this.mapSearchResultToDto(result)),
|
results: output.results.map(r => this.mapSearchResultToDto(r)),
|
||||||
totalResults: output.totalResults,
|
totalResults: output.totalResults,
|
||||||
searchedFiles: output.searchedFiles,
|
searchedFiles: output.searchedFiles,
|
||||||
searchedAt: output.searchedAt,
|
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 {
|
mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto {
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
@ -118,10 +115,7 @@ export class CsvRateMapper {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Map multiple config entities to DTOs
|
|
||||||
*/
|
|
||||||
mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] {
|
mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] {
|
||||||
return entities.map(entity => this.mapConfigEntityToDto(entity));
|
return entities.map(e => this.mapConfigEntityToDto(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -374,18 +374,20 @@ export class CsvBookingService {
|
|||||||
|
|
||||||
booking.markBankTransferDeclared();
|
booking.markBankTransferDeclared();
|
||||||
const updatedBooking = await this.csvBookingRepository.update(booking);
|
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
|
// Send email to all ADMIN users
|
||||||
try {
|
try {
|
||||||
const allUsers = await this.userRepository.findAll();
|
const allUsers = await this.userRepository.findAll();
|
||||||
const adminEmails = allUsers
|
const adminEmails = allUsers.filter(u => u.role === 'ADMIN' && u.isActive).map(u => u.email);
|
||||||
.filter(u => u.role === 'ADMIN' && u.isActive)
|
|
||||||
.map(u => u.email);
|
|
||||||
|
|
||||||
if (adminEmails.length > 0) {
|
if (adminEmails.length > 0) {
|
||||||
const commissionAmount = booking.commissionAmountEur
|
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';
|
: 'N/A';
|
||||||
|
|
||||||
await this.emailAdapter.send({
|
await this.emailAdapter.send({
|
||||||
@ -488,7 +490,9 @@ export class CsvBookingService {
|
|||||||
notes: booking.notes,
|
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,
|
confirmationToken: booking.confirmationToken,
|
||||||
notes: booking.notes,
|
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) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
|
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,7 +70,10 @@ export class InvitationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if licenses are available for this organization
|
// 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) {
|
if (!canInviteResult.canInvite) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`
|
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`
|
||||||
|
|||||||
@ -1,60 +1,69 @@
|
|||||||
import { PortCode } from '../value-objects/port-code.vo';
|
import { PortCode } from '../value-objects/port-code.vo';
|
||||||
import { ContainerType } from '../value-objects/container-type.vo';
|
import { ContainerType } from '../value-objects/container-type.vo';
|
||||||
import { 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';
|
import { DateRange } from '../value-objects/date-range.vo';
|
||||||
|
|
||||||
/**
|
export type DgSurchargeValue = number | 'ON REQUEST' | 'NOT ACCEPTED';
|
||||||
* Volume Range - Valid range for CBM
|
export type HandlingUnit = 'W' | 'UP'; // W = tonne revenue (max CBM/T), UP = per CBM
|
||||||
*/
|
export type FrequencyType = 'Weekly' | 'Bi-Weekly' | 'Bi-Monthly' | 'Monthly';
|
||||||
export interface VolumeRange {
|
|
||||||
minCBM: number;
|
export interface FreightPricing {
|
||||||
maxCBM: number;
|
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
|
* CsvRate — Shipping rate from a consolidator CSV file.
|
||||||
*/
|
|
||||||
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.
|
|
||||||
*
|
*
|
||||||
* Business Rules:
|
* Business Rules:
|
||||||
* - Price is calculated as: max(volumeCBM * pricePerCBM, weightKG * pricePerKG) + surcharges
|
* - Route matching uses originCode + destinationCode (UN/LOCODE)
|
||||||
* - Rate must be valid (within validity period) to be used
|
* - Price = max(freightRatePerCBM×V, freightMinimum) + FOB fixed + handling
|
||||||
* - Volume and weight must be within specified ranges
|
* - FOB and freight may be in different currencies
|
||||||
|
* - DG surcharge applies only when hasDangerousGoods = true
|
||||||
*/
|
*/
|
||||||
export class CsvRate {
|
export class CsvRate {
|
||||||
constructor(
|
constructor(
|
||||||
|
// Supplier identity
|
||||||
public readonly companyName: string,
|
public readonly companyName: string,
|
||||||
public readonly companyEmail: string,
|
public readonly companyEmail: string,
|
||||||
public readonly origin: PortCode,
|
// Route geography
|
||||||
public readonly destination: PortCode,
|
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 containerType: ContainerType,
|
||||||
public readonly volumeRange: VolumeRange,
|
// Pricing
|
||||||
public readonly weightRange: WeightRange,
|
public readonly freight: FreightPricing,
|
||||||
public readonly palletCount: number,
|
public readonly fob: FobCharges,
|
||||||
public readonly pricing: RatePricing,
|
public readonly dgSurcharge: DgSurchargeInfo,
|
||||||
public readonly currency: string, // Primary currency (USD or EUR)
|
// Metadata
|
||||||
public readonly surcharges: SurchargeCollection,
|
public readonly remarks: string,
|
||||||
|
public readonly frequency: FrequencyType,
|
||||||
public readonly transitDays: number,
|
public readonly transitDays: number,
|
||||||
public readonly validity: DateRange
|
public readonly validity: DateRange
|
||||||
) {
|
) {
|
||||||
@ -62,178 +71,56 @@ export class CsvRate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private validate(): void {
|
private validate(): void {
|
||||||
if (!this.companyName || this.companyName.trim().length === 0) {
|
if (!this.companyName?.trim()) throw new Error('Company name is required');
|
||||||
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 {
|
isValidForDate(date: Date): boolean {
|
||||||
return this.validity.contains(date);
|
return this.validity.contains(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if rate is currently valid (today is within validity period)
|
|
||||||
*/
|
|
||||||
isCurrentlyValid(): boolean {
|
isCurrentlyValid(): boolean {
|
||||||
return this.validity.isCurrentRange();
|
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 {
|
matchesRoute(origin: PortCode, destination: PortCode): boolean {
|
||||||
return this.origin.equals(origin) && this.destination.equals(destination);
|
return this.originCode.equals(origin) && this.destinationCode.equals(destination);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
isDgAccepted(): boolean {
|
||||||
* Check if rate has separate surcharges
|
return this.dgSurcharge.dgSurchargeRate !== 'NOT ACCEPTED';
|
||||||
*/
|
|
||||||
hasSurcharges(): boolean {
|
|
||||||
return !this.surcharges.isEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
isDgOnRequest(): boolean {
|
||||||
* Get surcharge details as formatted string
|
return this.dgSurcharge.dgSurchargeRate === 'ON REQUEST';
|
||||||
*/
|
|
||||||
getSurchargeDetails(): string {
|
|
||||||
return this.surcharges.getDetails();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
isDirectRoute(): boolean {
|
||||||
* Check if this is an "all-in" rate (no separate surcharges)
|
return this.routing.trim().toLowerCase() === 'direct';
|
||||||
*/
|
}
|
||||||
isAllInPrice(): boolean {
|
|
||||||
return this.surcharges.isEmpty();
|
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 {
|
getRouteDescription(): string {
|
||||||
return `${this.origin.getValue()} → ${this.destination.getValue()}`;
|
return `${this.originCode.getValue()} → ${this.destinationCode.getValue()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get company and route summary
|
|
||||||
*/
|
|
||||||
getSummary(): string {
|
getSummary(): string {
|
||||||
return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`;
|
return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
* - Type must be valid (FREIGHT_FORWARDER, CARRIER, SHIPPER)
|
* - Type must be valid (FREIGHT_FORWARDER, CARRIER, SHIPPER)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_LOCALE, Locale } from '../value-objects/locale.vo';
|
||||||
|
|
||||||
export enum OrganizationType {
|
export enum OrganizationType {
|
||||||
FREIGHT_FORWARDER = 'FREIGHT_FORWARDER',
|
FREIGHT_FORWARDER = 'FREIGHT_FORWARDER',
|
||||||
CARRIER = 'CARRIER',
|
CARRIER = 'CARRIER',
|
||||||
@ -47,6 +49,7 @@ export interface OrganizationProps {
|
|||||||
siret?: string;
|
siret?: string;
|
||||||
siretVerified: boolean;
|
siretVerified: boolean;
|
||||||
statusBadge: 'none' | 'silver' | 'gold' | 'platinium';
|
statusBadge: 'none' | 'silver' | 'gold' | 'platinium';
|
||||||
|
defaultLanguage: Locale;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
@ -63,9 +66,13 @@ export class Organization {
|
|||||||
* Factory method to create a new Organization
|
* Factory method to create a new Organization
|
||||||
*/
|
*/
|
||||||
static create(
|
static create(
|
||||||
props: Omit<OrganizationProps, 'createdAt' | 'updatedAt' | 'siretVerified' | 'statusBadge'> & {
|
props: Omit<
|
||||||
|
OrganizationProps,
|
||||||
|
'createdAt' | 'updatedAt' | 'siretVerified' | 'statusBadge' | 'defaultLanguage'
|
||||||
|
> & {
|
||||||
siretVerified?: boolean;
|
siretVerified?: boolean;
|
||||||
statusBadge?: 'none' | 'silver' | 'gold' | 'platinium';
|
statusBadge?: 'none' | 'silver' | 'gold' | 'platinium';
|
||||||
|
defaultLanguage?: Locale;
|
||||||
}
|
}
|
||||||
): Organization {
|
): Organization {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -94,6 +101,7 @@ export class Organization {
|
|||||||
...props,
|
...props,
|
||||||
siretVerified: props.siretVerified ?? false,
|
siretVerified: props.siretVerified ?? false,
|
||||||
statusBadge: props.statusBadge ?? 'none',
|
statusBadge: props.statusBadge ?? 'none',
|
||||||
|
defaultLanguage: props.defaultLanguage ?? DEFAULT_LOCALE,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
@ -188,6 +196,15 @@ export class Organization {
|
|||||||
return this.props.isActive;
|
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
|
// Business methods
|
||||||
isCarrier(): boolean {
|
isCarrier(): boolean {
|
||||||
return this.props.type === OrganizationType.CARRIER;
|
return this.props.type === OrganizationType.CARRIER;
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
* - Role-based access control (Admin, Manager, User, Viewer)
|
* - Role-based access control (Admin, Manager, User, Viewer)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_LOCALE, Locale } from '../value-objects/locale.vo';
|
||||||
|
|
||||||
export enum UserRole {
|
export enum UserRole {
|
||||||
ADMIN = 'ADMIN', // Full system access
|
ADMIN = 'ADMIN', // Full system access
|
||||||
MANAGER = 'MANAGER', // Manage bookings and users within organization
|
MANAGER = 'MANAGER', // Manage bookings and users within organization
|
||||||
@ -30,6 +32,7 @@ export interface UserProps {
|
|||||||
isEmailVerified: boolean;
|
isEmailVerified: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
lastLoginAt?: Date;
|
lastLoginAt?: Date;
|
||||||
|
preferredLanguage: Locale;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@ -47,8 +50,13 @@ export class User {
|
|||||||
static create(
|
static create(
|
||||||
props: Omit<
|
props: Omit<
|
||||||
UserProps,
|
UserProps,
|
||||||
'createdAt' | 'updatedAt' | 'isEmailVerified' | 'isActive' | 'lastLoginAt'
|
| 'createdAt'
|
||||||
>
|
| 'updatedAt'
|
||||||
|
| 'isEmailVerified'
|
||||||
|
| 'isActive'
|
||||||
|
| 'lastLoginAt'
|
||||||
|
| 'preferredLanguage'
|
||||||
|
> & { preferredLanguage?: Locale }
|
||||||
): User {
|
): User {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@ -59,6 +67,7 @@ export class User {
|
|||||||
|
|
||||||
return new User({
|
return new User({
|
||||||
...props,
|
...props,
|
||||||
|
preferredLanguage: props.preferredLanguage ?? DEFAULT_LOCALE,
|
||||||
isEmailVerified: false,
|
isEmailVerified: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@ -142,6 +151,15 @@ export class User {
|
|||||||
return this.props.updatedAt;
|
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
|
// Business methods
|
||||||
has2FAEnabled(): boolean {
|
has2FAEnabled(): boolean {
|
||||||
return !!this.props.totpSecret;
|
return !!this.props.totpSecret;
|
||||||
|
|||||||
30
apps/backend/src/domain/exceptions/domain.exception.ts
Normal file
30
apps/backend/src/domain/exceptions/domain.exception.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
* All domain exceptions for the Xpeditis platform
|
* All domain exceptions for the Xpeditis platform
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export * from './domain.exception';
|
||||||
export * from './invalid-port-code.exception';
|
export * from './invalid-port-code.exception';
|
||||||
export * from './invalid-rate-quote.exception';
|
export * from './invalid-rate-quote.exception';
|
||||||
export * from './carrier-timeout.exception';
|
export * from './carrier-timeout.exception';
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* PortNotFoundException
|
* 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) {
|
constructor(public readonly portCode: string) {
|
||||||
super(`Port not found: ${portCode}`);
|
super('error.PORT_NOT_FOUND', { portCode }, `Port not found: ${portCode}`, 404);
|
||||||
this.name = 'PortNotFoundException';
|
|
||||||
Object.setPrototypeOf(this, PortNotFoundException.prototype);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,160 +1,73 @@
|
|||||||
import { CsvRate } from '../../entities/csv-rate.entity';
|
import { CsvRate } from '../../entities/csv-rate.entity';
|
||||||
import { ServiceLevel } from '../../services/rate-offer-generator.service';
|
import { ServiceLevel } from '../../services/rate-offer-generator.service';
|
||||||
|
import { PriceBreakdown } from '../../services/csv-rate-price-calculator.service';
|
||||||
|
|
||||||
|
export { PriceBreakdown };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advanced Rate Search Filters
|
* Filters for narrowing CSV rate search results.
|
||||||
*
|
* Volume/weight range filters removed — new schema has no per-rate volume limits.
|
||||||
* Filters for narrowing down rate search results
|
|
||||||
*/
|
*/
|
||||||
export interface RateSearchFilters {
|
export interface RateSearchFilters {
|
||||||
// Company filters
|
companies?: string[];
|
||||||
companies?: string[]; // List of company names to include
|
|
||||||
|
|
||||||
// Volume/Weight filters
|
// Price filter (applied to totalPriceForSorting)
|
||||||
minVolumeCBM?: number;
|
|
||||||
maxVolumeCBM?: number;
|
|
||||||
minWeightKG?: number;
|
|
||||||
maxWeightKG?: number;
|
|
||||||
palletCount?: number; // Exact pallet count (0 = any)
|
|
||||||
|
|
||||||
// Price filters
|
|
||||||
minPrice?: number;
|
minPrice?: number;
|
||||||
maxPrice?: number;
|
maxPrice?: number;
|
||||||
currency?: 'USD' | 'EUR'; // Preferred currency for filtering
|
currency?: 'USD' | 'EUR';
|
||||||
|
|
||||||
// Transit filters
|
// Transit filter
|
||||||
minTransitDays?: number;
|
minTransitDays?: number;
|
||||||
maxTransitDays?: number;
|
maxTransitDays?: number;
|
||||||
|
|
||||||
// Container type filters
|
// Route filter
|
||||||
containerTypes?: string[]; // e.g., ['LCL', '20DRY', '40HC']
|
onlyDirect?: boolean; // Only show "Direct" routing
|
||||||
|
|
||||||
// Surcharge filters
|
// Container type filter
|
||||||
onlyAllInPrices?: boolean; // Only show rates without separate surcharges
|
containerTypes?: string[];
|
||||||
|
|
||||||
// Date filters
|
// Date filter
|
||||||
departureDate?: Date; // Filter by validity for specific date
|
departureDate?: Date;
|
||||||
|
|
||||||
// Service level filter
|
// Service level filter (for offers endpoint)
|
||||||
serviceLevels?: ServiceLevel[]; // Filter by service level (RAPID, STANDARD, ECONOMIC)
|
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 {
|
export interface CsvRateSearchInput {
|
||||||
origin: string; // Port code (UN/LOCODE)
|
origin: string; // UN/LOCODE
|
||||||
destination: string; // Port code (UN/LOCODE)
|
destination: string; // UN/LOCODE
|
||||||
volumeCBM: number; // Volume in cubic meters
|
volumeCBM: number;
|
||||||
weightKG: number; // Weight in kilograms
|
weightKG: number;
|
||||||
palletCount?: number; // Number of pallets (0 if none)
|
containerType?: string;
|
||||||
containerType?: string; // Optional container type filter
|
|
||||||
filters?: RateSearchFilters; // Advanced filters
|
|
||||||
|
|
||||||
// Service requirements for price calculation
|
|
||||||
hasDangerousGoods?: boolean;
|
hasDangerousGoods?: boolean;
|
||||||
requiresSpecialHandling?: boolean;
|
filters?: RateSearchFilters;
|
||||||
requiresTailgate?: boolean;
|
|
||||||
requiresStraps?: boolean;
|
|
||||||
requiresThermalCover?: boolean;
|
|
||||||
hasRegulatedProducts?: boolean;
|
|
||||||
requiresAppointment?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
export interface CsvRateSearchResult {
|
||||||
rate: CsvRate;
|
rate: CsvRate;
|
||||||
calculatedPrice: {
|
priceBreakdown: PriceBreakdown;
|
||||||
usd: number;
|
|
||||||
eur: number;
|
|
||||||
primaryCurrency: string;
|
|
||||||
};
|
|
||||||
priceBreakdown: PriceBreakdown; // Detailed price calculation
|
|
||||||
source: 'CSV';
|
source: 'CSV';
|
||||||
matchScore: number; // 0-100, how well it matches filters
|
matchScore: number;
|
||||||
serviceLevel?: ServiceLevel; // Service level (RAPID, STANDARD, ECONOMIC) if offers are generated
|
serviceLevel?: ServiceLevel;
|
||||||
originalPrice?: {
|
priceMultiplier?: number;
|
||||||
usd: number;
|
originalTransitDays?: number;
|
||||||
eur: number;
|
adjustedTransitDays?: 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* CSV Rate Search Output
|
|
||||||
*
|
|
||||||
* Results from CSV rate search
|
|
||||||
*/
|
|
||||||
export interface CsvRateSearchOutput {
|
export interface CsvRateSearchOutput {
|
||||||
results: CsvRateSearchResult[];
|
results: CsvRateSearchResult[];
|
||||||
totalResults: number;
|
totalResults: number;
|
||||||
searchedFiles: string[]; // CSV files searched
|
searchedFiles: string[];
|
||||||
searchedAt: Date;
|
searchedAt: Date;
|
||||||
appliedFilters: RateSearchFilters;
|
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 {
|
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(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>;
|
executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get available companies in CSV system
|
|
||||||
* @returns List of company names that have CSV rates
|
|
||||||
*/
|
|
||||||
getAvailableCompanies(): Promise<string[]>;
|
getAvailableCompanies(): Promise<string[]>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get available container types in CSV system
|
|
||||||
* @returns List of container types available
|
|
||||||
*/
|
|
||||||
getAvailableContainerTypes(): Promise<string[]>;
|
getAvailableContainerTypes(): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,217 +3,152 @@ import { CsvRate } from '../entities/csv-rate.entity';
|
|||||||
export interface PriceCalculationParams {
|
export interface PriceCalculationParams {
|
||||||
volumeCBM: number;
|
volumeCBM: number;
|
||||||
weightKG: number;
|
weightKG: number;
|
||||||
palletCount: number;
|
hasDangerousGoods?: boolean;
|
||||||
hasDangerousGoods: boolean;
|
|
||||||
requiresSpecialHandling: boolean;
|
|
||||||
requiresTailgate: boolean;
|
|
||||||
requiresStraps: boolean;
|
|
||||||
requiresThermalCover: boolean;
|
|
||||||
hasRegulatedProducts: boolean;
|
|
||||||
requiresAppointment: 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 {
|
export interface PriceBreakdown {
|
||||||
basePrice: number;
|
// Freight (in freightCurrency)
|
||||||
volumeCharge: number;
|
freightCharge: number;
|
||||||
weightCharge: number;
|
freightCurrency: string;
|
||||||
palletCharge: number;
|
|
||||||
surcharges: SurchargeItem[];
|
|
||||||
totalSurcharges: number;
|
|
||||||
totalPrice: number;
|
|
||||||
currency: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SurchargeItem {
|
// FOB charges (in fobCurrency)
|
||||||
code: string;
|
fobFixed: number; // doc + ISPS + solas + customs + AMS_ACI + ISF5
|
||||||
description: string;
|
fobHandling: number;
|
||||||
amount: number;
|
fobDG: number; // fobDGAdmin only if DG
|
||||||
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
|
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
|
* Calculates price for a CSV rate given volume and weight.
|
||||||
* Calcule le prix total basé sur le volume, poids, palettes et services additionnels
|
*
|
||||||
|
* 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 {
|
export class CsvRatePriceCalculatorService {
|
||||||
/**
|
|
||||||
* Calcule le prix total pour un tarif CSV donné
|
|
||||||
*/
|
|
||||||
calculatePrice(rate: CsvRate, params: PriceCalculationParams): PriceBreakdown {
|
calculatePrice(rate: CsvRate, params: PriceCalculationParams): PriceBreakdown {
|
||||||
// 1. Prix de base
|
const V = params.volumeCBM;
|
||||||
const basePrice = rate.pricing.basePriceUSD.getAmount();
|
const W = params.weightKG / 1000; // convert KG → tonnes for W unit
|
||||||
|
const isDG = params.hasDangerousGoods ?? false;
|
||||||
|
|
||||||
// 2. Frais au volume (USD par CBM)
|
// 1. Freight charge
|
||||||
const volumeCharge = rate.pricing.pricePerCBM * params.volumeCBM;
|
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)
|
// 2. Handling — "W" = tonne revenue (max of CBM and tonnes), "UP" = per CBM
|
||||||
const weightCharge = rate.pricing.pricePerKG * params.weightKG;
|
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)
|
// 3. FOB fixed charges
|
||||||
const palletCharge = params.palletCount * 25;
|
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
|
// 4. DG admin (FOB currency, only if DG)
|
||||||
const standardSurcharges = this.parseStandardSurcharges(rate.getSurchargeDetails(), params);
|
const fobDG = isDG ? rate.fob.fobDGAdmin : 0;
|
||||||
|
|
||||||
// 6. Surcharges additionnelles basées sur les services
|
// 5. DG surcharge (own currency, only if DG)
|
||||||
const additionalSurcharges = this.calculateAdditionalSurcharges(params);
|
let dgSurchargeAmount: number | null = null;
|
||||||
|
let dgSurchargeStatus: DgSurchargeStatus = 'computed';
|
||||||
|
|
||||||
// 7. Total des surcharges
|
if (isDG) {
|
||||||
const allSurcharges = [...standardSurcharges, ...additionalSurcharges];
|
const dgRate = rate.dgSurcharge.dgSurchargeRate;
|
||||||
const totalSurcharges = allSurcharges.reduce((sum, s) => sum + s.amount, 0);
|
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
|
// 6. Total FOB (in fobCurrency)
|
||||||
const totalPrice = basePrice + volumeCharge + weightCharge + palletCharge + totalSurcharges;
|
const totalFob = fobFixed + fobHandling + fobDG + (dgSurchargeAmount ?? 0);
|
||||||
|
|
||||||
|
// 7. Naive sum for sorting (ignores currency differences)
|
||||||
|
const totalPriceForSorting = freightCharge + totalFob;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
basePrice,
|
freightCharge: round2(freightCharge),
|
||||||
volumeCharge,
|
freightCurrency: rate.freight.freightCurrency,
|
||||||
weightCharge,
|
fobFixed: round2(fobFixed),
|
||||||
palletCharge,
|
fobHandling: round2(fobHandling),
|
||||||
surcharges: allSurcharges,
|
fobDG: round2(fobDG),
|
||||||
totalSurcharges,
|
fobCurrency: rate.fob.fobCurrency,
|
||||||
totalPrice: Math.round(totalPrice * 100) / 100, // Arrondi à 2 décimales
|
fobBreakdown: {
|
||||||
currency: rate.currency || 'USD',
|
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
|
function round2(n: number): number {
|
||||||
* Format: "DOC:10 | ISPS:7 | HANDLING:20 W | DG_FEE:65"
|
return Math.round(n * 100) / 100;
|
||||||
*/
|
|
||||||
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, ' ');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { CsvRate } from '../entities/csv-rate.entity';
|
import { CsvRate } from '../entities/csv-rate.entity';
|
||||||
import { PortCode } from '../value-objects/port-code.vo';
|
import { PortCode } from '../value-objects/port-code.vo';
|
||||||
import { ContainerType } from '../value-objects/container-type.vo';
|
import { ContainerType } from '../value-objects/container-type.vo';
|
||||||
import { Volume } from '../value-objects/volume.vo';
|
|
||||||
import {
|
import {
|
||||||
SearchCsvRatesPort,
|
SearchCsvRatesPort,
|
||||||
CsvRateSearchInput,
|
CsvRateSearchInput,
|
||||||
@ -11,11 +10,8 @@ import {
|
|||||||
} from '@domain/ports/in/search-csv-rates.port';
|
} from '@domain/ports/in/search-csv-rates.port';
|
||||||
import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port';
|
import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port';
|
||||||
import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service';
|
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 {
|
interface CsvRateConfig {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
csvFilePath: string;
|
csvFilePath: string;
|
||||||
@ -25,21 +21,10 @@ interface CsvRateConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Config Repository Port (simplified interface)
|
|
||||||
*/
|
|
||||||
export interface CsvRateConfigRepositoryPort {
|
export interface CsvRateConfigRepositoryPort {
|
||||||
findActiveConfigs(): Promise<CsvRateConfig[]>;
|
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 {
|
export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||||
private readonly priceCalculator: CsvRatePriceCalculatorService;
|
private readonly priceCalculator: CsvRatePriceCalculatorService;
|
||||||
private readonly offerGenerator: RateOfferGeneratorService;
|
private readonly offerGenerator: RateOfferGeneratorService;
|
||||||
@ -54,63 +39,39 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
|
|
||||||
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
|
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
|
||||||
const searchStartTime = new Date();
|
const searchStartTime = new Date();
|
||||||
|
|
||||||
// Parse and validate input
|
|
||||||
const origin = PortCode.create(input.origin);
|
const origin = PortCode.create(input.origin);
|
||||||
const destination = PortCode.create(input.destination);
|
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();
|
const allRates = await this.loadAllRates();
|
||||||
|
|
||||||
// Apply route and volume matching
|
|
||||||
let matchingRates = this.filterByRoute(allRates, origin, destination);
|
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) {
|
if (input.containerType) {
|
||||||
const containerType = ContainerType.create(input.containerType);
|
const containerType = ContainerType.create(input.containerType);
|
||||||
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
|
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply advanced filters
|
|
||||||
if (input.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 => {
|
const results: CsvRateSearchResult[] = matchingRates.map(rate => {
|
||||||
// Calculate detailed price breakdown
|
|
||||||
const priceBreakdown = this.priceCalculator.calculatePrice(rate, {
|
const priceBreakdown = this.priceCalculator.calculatePrice(rate, {
|
||||||
volumeCBM: input.volumeCBM,
|
volumeCBM: input.volumeCBM,
|
||||||
weightKG: input.weightKG,
|
weightKG: input.weightKG,
|
||||||
palletCount: input.palletCount ?? 0,
|
|
||||||
hasDangerousGoods: input.hasDangerousGoods ?? false,
|
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 {
|
return {
|
||||||
rate,
|
rate,
|
||||||
calculatedPrice: {
|
|
||||||
usd: priceBreakdown.totalPrice,
|
|
||||||
eur: priceBreakdown.totalPrice, // TODO: Add currency conversion
|
|
||||||
primaryCurrency: priceBreakdown.currency,
|
|
||||||
},
|
|
||||||
priceBreakdown,
|
priceBreakdown,
|
||||||
source: 'CSV' as const,
|
source: 'CSV' as const,
|
||||||
matchScore: this.calculateMatchScore(rate, input),
|
matchScore: this.calculateMatchScore(rate),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by total price (ascending)
|
results.sort(
|
||||||
results.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice);
|
(a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
results,
|
results,
|
||||||
@ -122,101 +83,67 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute CSV rate search with service level offers generation
|
* Search with service level offers — returns 3 variants per rate (ECONOMIC / STANDARD / RAPID).
|
||||||
* Generates 3 offers (RAPID, STANDARD, ECONOMIC) for each matching rate
|
* Price multipliers (0.85 / 1.0 / 1.2) are applied to totalPriceForSorting.
|
||||||
*/
|
*/
|
||||||
async executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
|
async executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
|
||||||
const searchStartTime = new Date();
|
const searchStartTime = new Date();
|
||||||
|
|
||||||
// Parse and validate input
|
|
||||||
const origin = PortCode.create(input.origin);
|
const origin = PortCode.create(input.origin);
|
||||||
const destination = PortCode.create(input.destination);
|
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();
|
const allRates = await this.loadAllRates();
|
||||||
|
|
||||||
// Apply route and volume matching
|
|
||||||
let matchingRates = this.filterByRoute(allRates, origin, destination);
|
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) {
|
if (input.containerType) {
|
||||||
const containerType = ContainerType.create(input.containerType);
|
const containerType = ContainerType.create(input.containerType);
|
||||||
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
|
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply advanced filters (before generating offers)
|
|
||||||
if (input.filters) {
|
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);
|
const eligibleRates = this.offerGenerator.filterEligibleRates(matchingRates);
|
||||||
|
|
||||||
// Generate 3 offers (RAPID, STANDARD, ECONOMIC) for each eligible rate
|
|
||||||
const allOffers = this.offerGenerator.generateOffersForRates(eligibleRates);
|
const allOffers = this.offerGenerator.generateOffersForRates(eligibleRates);
|
||||||
|
|
||||||
// Convert offers to search results
|
|
||||||
const results: CsvRateSearchResult[] = allOffers.map(offer => {
|
const results: CsvRateSearchResult[] = allOffers.map(offer => {
|
||||||
// Calculate detailed price breakdown with adjusted prices
|
|
||||||
const priceBreakdown = this.priceCalculator.calculatePrice(offer.rate, {
|
const priceBreakdown = this.priceCalculator.calculatePrice(offer.rate, {
|
||||||
volumeCBM: input.volumeCBM,
|
volumeCBM: input.volumeCBM,
|
||||||
weightKG: input.weightKG,
|
weightKG: input.weightKG,
|
||||||
palletCount: input.palletCount ?? 0,
|
|
||||||
hasDangerousGoods: input.hasDangerousGoods ?? false,
|
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 multiplier = offer.priceMultiplier;
|
||||||
const adjustedTotalPrice =
|
const adjustedBreakdown = {
|
||||||
priceBreakdown.totalPrice *
|
...priceBreakdown,
|
||||||
(offer.serviceLevel === ServiceLevel.RAPID
|
freightCharge: round2(priceBreakdown.freightCharge * multiplier),
|
||||||
? 1.2
|
totalFreight: round2(priceBreakdown.totalFreight * multiplier),
|
||||||
: offer.serviceLevel === ServiceLevel.ECONOMIC
|
totalFob: round2(priceBreakdown.totalFob * multiplier),
|
||||||
? 0.85
|
totalPriceForSorting: round2(priceBreakdown.totalPriceForSorting * multiplier),
|
||||||
: 1.0);
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rate: offer.rate,
|
rate: offer.rate,
|
||||||
calculatedPrice: {
|
priceBreakdown: adjustedBreakdown,
|
||||||
usd: adjustedTotalPrice,
|
|
||||||
eur: adjustedTotalPrice, // TODO: Add currency conversion
|
|
||||||
primaryCurrency: priceBreakdown.currency,
|
|
||||||
},
|
|
||||||
priceBreakdown: {
|
|
||||||
...priceBreakdown,
|
|
||||||
totalPrice: adjustedTotalPrice,
|
|
||||||
},
|
|
||||||
source: 'CSV' as const,
|
source: 'CSV' as const,
|
||||||
matchScore: this.calculateMatchScore(offer.rate, input),
|
matchScore: this.calculateMatchScore(offer.rate),
|
||||||
serviceLevel: offer.serviceLevel,
|
serviceLevel: offer.serviceLevel,
|
||||||
originalPrice: {
|
priceMultiplier: offer.priceMultiplier,
|
||||||
usd: offer.originalPriceUSD,
|
|
||||||
eur: offer.originalPriceEUR,
|
|
||||||
},
|
|
||||||
originalTransitDays: offer.originalTransitDays,
|
originalTransitDays: offer.originalTransitDays,
|
||||||
adjustedTransitDays: offer.adjustedTransitDays,
|
adjustedTransitDays: offer.adjustedTransitDays,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply service level filter if specified
|
|
||||||
let filteredResults = results;
|
let filteredResults = results;
|
||||||
if (input.filters?.serviceLevels && input.filters.serviceLevels.length > 0) {
|
if (input.filters?.serviceLevels?.length) {
|
||||||
filteredResults = results.filter(
|
filteredResults = results.filter(
|
||||||
r => r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel)
|
r => r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by total price (ascending) - ECONOMIC first, then STANDARD, then RAPID
|
filteredResults.sort(
|
||||||
filteredResults.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice);
|
(a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
results: filteredResults,
|
results: filteredResults,
|
||||||
@ -229,197 +156,110 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
|
|
||||||
async getAvailableCompanies(): Promise<string[]> {
|
async getAvailableCompanies(): Promise<string[]> {
|
||||||
const allRates = await this.loadAllRates();
|
const allRates = await this.loadAllRates();
|
||||||
const companies = new Set(allRates.map(rate => rate.companyName));
|
return [...new Set(allRates.map(r => r.companyName))].sort();
|
||||||
return Array.from(companies).sort();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailableContainerTypes(): Promise<string[]> {
|
async getAvailableContainerTypes(): Promise<string[]> {
|
||||||
const allRates = await this.loadAllRates();
|
const allRates = await this.loadAllRates();
|
||||||
const types = new Set(allRates.map(rate => rate.containerType.getValue()));
|
return [...new Set(allRates.map(r => r.containerType.getValue()))].sort();
|
||||||
return Array.from(types).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[]> {
|
async getAvailableOrigins(): Promise<string[]> {
|
||||||
const allRates = await this.loadAllRates();
|
const allRates = await this.loadAllRates();
|
||||||
const origins = new Set(allRates.map(rate => rate.origin.getValue()));
|
return [...new Set(allRates.map(r => r.originCode.getValue()))].sort();
|
||||||
return Array.from(origins).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[]> {
|
async getAvailableDestinations(origin: string): Promise<string[]> {
|
||||||
const allRates = await this.loadAllRates();
|
const allRates = await this.loadAllRates();
|
||||||
const originCode = PortCode.create(origin);
|
const originCode = PortCode.create(origin);
|
||||||
|
return [
|
||||||
const destinations = new Set(
|
...new Set(
|
||||||
allRates
|
allRates.filter(r => r.originCode.equals(originCode)).map(r => r.destinationCode.getValue())
|
||||||
.filter(rate => rate.origin.equals(originCode))
|
),
|
||||||
.map(rate => rate.destination.getValue())
|
].sort();
|
||||||
);
|
|
||||||
|
|
||||||
return Array.from(destinations).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[]>> {
|
async getAvailableRoutes(): Promise<Map<string, string[]>> {
|
||||||
const allRates = await this.loadAllRates();
|
const allRates = await this.loadAllRates();
|
||||||
const routeMap = new Map<string, Set<string>>();
|
const routeMap = new Map<string, Set<string>>();
|
||||||
|
|
||||||
allRates.forEach(rate => {
|
allRates.forEach(rate => {
|
||||||
const origin = rate.origin.getValue();
|
const origin = rate.originCode.getValue();
|
||||||
const destination = rate.destination.getValue();
|
const destination = rate.destinationCode.getValue();
|
||||||
|
if (!routeMap.has(origin)) routeMap.set(origin, new Set());
|
||||||
if (!routeMap.has(origin)) {
|
|
||||||
routeMap.set(origin, new Set());
|
|
||||||
}
|
|
||||||
routeMap.get(origin)!.add(destination);
|
routeMap.get(origin)!.add(destination);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert Sets to sorted arrays
|
|
||||||
const result = new Map<string, string[]>();
|
const result = new Map<string, string[]>();
|
||||||
routeMap.forEach((destinations, origin) => {
|
routeMap.forEach((destinations, origin) => {
|
||||||
result.set(origin, Array.from(destinations).sort());
|
result.set(origin, [...destinations].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load all rates from all CSV files
|
|
||||||
*/
|
|
||||||
private async loadAllRates(): Promise<CsvRate[]> {
|
private async loadAllRates(): Promise<CsvRate[]> {
|
||||||
// If config repository is available, load rates with emails and company names from configs
|
|
||||||
if (this.configRepository) {
|
if (this.configRepository) {
|
||||||
const configs = await this.configRepository.findActiveConfigs();
|
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';
|
const email = config.metadata?.companyEmail || 'bookings@example.com';
|
||||||
// Pass company name from config to override CSV column value
|
return this.csvRateLoader.loadRatesFromCsv(
|
||||||
return this.csvRateLoader.loadRatesFromCsv(config.csvFilePath, email, config.companyName);
|
config.csvFilePath,
|
||||||
});
|
email,
|
||||||
|
config.companyName
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Use allSettled to handle missing files gracefully
|
const failures = results.filter(r => r.status === 'rejected');
|
||||||
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');
|
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
console.warn(
|
console.warn(`Failed to load ${failures.length} CSV files from database configs`);
|
||||||
`Failed to load ${failures.length} CSV files:`,
|
|
||||||
failures.map(
|
|
||||||
(f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 files = await this.csvRateLoader.getAvailableCsvFiles();
|
||||||
const ratePromises = files.map(file =>
|
const results = await Promise.allSettled(
|
||||||
this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com')
|
files.map(file => this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com'))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use allSettled here too for consistency
|
return results
|
||||||
const results = await Promise.allSettled(ratePromises);
|
.filter((r): r is PromiseFulfilledResult<CsvRate[]> => r.status === 'fulfilled')
|
||||||
const rateArrays = results
|
.flatMap(r => r.value);
|
||||||
.filter(
|
|
||||||
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
|
|
||||||
)
|
|
||||||
.map(result => result.value);
|
|
||||||
|
|
||||||
return rateArrays.flat();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter rates by route (origin/destination)
|
|
||||||
*/
|
|
||||||
private filterByRoute(rates: CsvRate[], origin: PortCode, destination: PortCode): CsvRate[] {
|
private filterByRoute(rates: CsvRate[], origin: PortCode, destination: PortCode): CsvRate[] {
|
||||||
return rates.filter(rate => rate.matchesRoute(origin, destination));
|
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(
|
private applyAdvancedFilters(
|
||||||
rates: CsvRate[],
|
rates: CsvRate[],
|
||||||
filters: RateSearchFilters,
|
filters: RateSearchFilters,
|
||||||
volume: Volume
|
input: CsvRateSearchInput
|
||||||
): CsvRate[] {
|
): CsvRate[] {
|
||||||
let filtered = rates;
|
let filtered = rates;
|
||||||
|
|
||||||
// Company filter
|
if (filters.companies?.length) {
|
||||||
if (filters.companies && filters.companies.length > 0) {
|
|
||||||
filtered = filtered.filter(rate => filters.companies!.includes(rate.companyName));
|
filtered = filtered.filter(rate => filters.companies!.includes(rate.companyName));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Volume CBM filter
|
if (filters.onlyDirect) {
|
||||||
if (filters.minVolumeCBM !== undefined) {
|
filtered = filtered.filter(rate => rate.isDirectRoute());
|
||||||
filtered = filtered.filter(rate => rate.volumeRange.maxCBM >= filters.minVolumeCBM!);
|
|
||||||
}
|
|
||||||
if (filters.maxVolumeCBM !== undefined) {
|
|
||||||
filtered = filtered.filter(rate => rate.volumeRange.minCBM <= filters.maxVolumeCBM!);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weight KG filter
|
if (filters.excludeNonDgRoutes) {
|
||||||
if (filters.minWeightKG !== undefined) {
|
filtered = filtered.filter(rate => rate.isDgAccepted());
|
||||||
filtered = filtered.filter(rate => rate.weightRange.maxKG >= filters.minWeightKG!);
|
|
||||||
}
|
|
||||||
if (filters.maxWeightKG !== undefined) {
|
|
||||||
filtered = filtered.filter(rate => rate.weightRange.minKG <= filters.maxWeightKG!);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
if (filters.minTransitDays !== undefined) {
|
||||||
filtered = filtered.filter(rate => rate.transitDays >= filters.minTransitDays!);
|
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!);
|
filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container type filter
|
if (filters.containerTypes?.length) {
|
||||||
if (filters.containerTypes && filters.containerTypes.length > 0) {
|
|
||||||
filtered = filtered.filter(rate =>
|
filtered = filtered.filter(rate =>
|
||||||
filters.containerTypes!.includes(rate.containerType.getValue())
|
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) {
|
if (filters.departureDate) {
|
||||||
filtered = filtered.filter(rate => rate.isValidForDate(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;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate match score (0-100) based on how well rate matches input
|
* Score (0–100) based on routing type, departure frequency, and rate validity.
|
||||||
* Higher score = better match
|
* Higher = better match.
|
||||||
*/
|
*/
|
||||||
private calculateMatchScore(rate: CsvRate, input: CsvRateSearchInput): number {
|
private calculateMatchScore(rate: CsvRate): number {
|
||||||
let score = 100;
|
let score = 100;
|
||||||
|
|
||||||
// Reduce score if volume/weight is near boundaries
|
// Direct route bonus
|
||||||
const volumeUtilization =
|
if (rate.isDirectRoute()) {
|
||||||
(input.volumeCBM - rate.volumeRange.minCBM) /
|
score += 10;
|
||||||
(rate.volumeRange.maxCBM - rate.volumeRange.minCBM);
|
} else {
|
||||||
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) {
|
|
||||||
score -= 5;
|
score -= 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increase score for all-in prices (simpler for customers)
|
// Frequency bonus (Weekly = best)
|
||||||
if (rate.isAllInPrice()) {
|
const freqScore = rate.getFrequencyScore(); // 1–4
|
||||||
score += 5;
|
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(
|
const daysUntilExpiry = Math.floor(
|
||||||
(rate.validity.getEndDate().getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
(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));
|
return Math.max(0, Math.min(100, score));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function round2(n: number): number {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
}
|
||||||
|
|||||||
@ -2,16 +2,8 @@ import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.
|
|||||||
import { CsvRate } from '../entities/csv-rate.entity';
|
import { CsvRate } from '../entities/csv-rate.entity';
|
||||||
import { PortCode } from '../value-objects/port-code.vo';
|
import { PortCode } from '../value-objects/port-code.vo';
|
||||||
import { ContainerType } from '../value-objects/container-type.vo';
|
import { ContainerType } from '../value-objects/container-type.vo';
|
||||||
import { 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', () => {
|
describe('RateOfferGeneratorService', () => {
|
||||||
let service: RateOfferGeneratorService;
|
let service: RateOfferGeneratorService;
|
||||||
let mockRate: CsvRate;
|
let mockRate: CsvRate;
|
||||||
@ -19,415 +11,226 @@ describe('RateOfferGeneratorService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
service = new RateOfferGeneratorService();
|
service = new RateOfferGeneratorService();
|
||||||
|
|
||||||
// Créer un tarif de base pour les tests
|
// Mock minimal CsvRate compatible with new schema
|
||||||
// Prix: 1000 USD / 900 EUR, Transit: 20 jours
|
|
||||||
mockRate = {
|
mockRate = {
|
||||||
companyName: 'Test Carrier',
|
companyName: 'Test Carrier',
|
||||||
companyEmail: 'test@carrier.com',
|
companyEmail: 'test@carrier.com',
|
||||||
origin: PortCode.create('FRPAR'),
|
originCFS: 'Fos Sur Mer',
|
||||||
destination: PortCode.create('USNYC'),
|
originCode: PortCode.create('FRFOS'),
|
||||||
|
portOfLoading: 'FOS SUR MER',
|
||||||
|
routing: 'Direct',
|
||||||
|
destinationCFS: 'New York',
|
||||||
|
destinationCode: PortCode.create('USNYC'),
|
||||||
|
destinationCountry: 'USA',
|
||||||
containerType: ContainerType.create('LCL'),
|
containerType: ContainerType.create('LCL'),
|
||||||
volumeRange: { minCBM: 1, maxCBM: 10 },
|
freight: {
|
||||||
weightRange: { minKG: 100, maxKG: 5000 },
|
freightCurrency: 'USD',
|
||||||
palletCount: 0,
|
freightRatePerCBM: 50,
|
||||||
pricing: {
|
freightMinimum: 500,
|
||||||
pricePerCBM: 100,
|
|
||||||
pricePerKG: 0.5,
|
|
||||||
basePriceUSD: Money.create(1000, 'USD'),
|
|
||||||
basePriceEUR: Money.create(900, 'EUR'),
|
|
||||||
},
|
},
|
||||||
currency: 'USD',
|
fob: {
|
||||||
hasSurcharges: false,
|
fobCurrency: 'EUR',
|
||||||
surchargeBAF: null,
|
fobDocumentation: 55,
|
||||||
surchargeCAF: null,
|
fobISPS: 18,
|
||||||
surchargeDetails: null,
|
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,
|
transitDays: 20,
|
||||||
validity: {
|
validity: DateRange.create(new Date('2026-01-01'), new Date('2026-12-31'), true),
|
||||||
getStartDate: () => new Date('2024-01-01'),
|
|
||||||
getEndDate: () => new Date('2024-12-31'),
|
|
||||||
},
|
|
||||||
isValidForDate: () => true,
|
isValidForDate: () => true,
|
||||||
|
isCurrentlyValid: () => true,
|
||||||
matchesRoute: () => true,
|
matchesRoute: () => true,
|
||||||
matchesVolume: () => true,
|
isDgAccepted: () => true,
|
||||||
matchesPalletCount: () => true,
|
isDgOnRequest: () => false,
|
||||||
getPriceInCurrency: () => Money.create(1000, 'USD'),
|
isDirectRoute: () => true,
|
||||||
isAllInPrice: () => true,
|
getFrequencyScore: () => 4,
|
||||||
getSurchargeDetails: () => null,
|
getRouteDescription: () => 'FRFOS → USNYC',
|
||||||
|
getSummary: () => 'Test Carrier: FRFOS → USNYC',
|
||||||
|
toString: () => 'Test Carrier: FRFOS → USNYC',
|
||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateOffers', () => {
|
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);
|
const offers = service.generateOffers(mockRate);
|
||||||
|
|
||||||
expect(offers).toHaveLength(3);
|
expect(offers).toHaveLength(3);
|
||||||
expect(offers.map(o => o.serviceLevel)).toEqual(
|
expect(offers.map(o => o.serviceLevel)).toEqual(
|
||||||
expect.arrayContaining([ServiceLevel.RAPID, ServiceLevel.STANDARD, ServiceLevel.ECONOMIC])
|
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 offers = service.generateOffers(mockRate);
|
||||||
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
expect(economic.priceMultiplier).toBe(0.85);
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
expect(economic.priceAdjustmentPercent).toBe(-15);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('RAPID doit être le plus cher', () => {
|
it('RAPID has the highest price multiplier (1.2)', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
|
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
expect(rapid.priceMultiplier).toBe(1.2);
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
expect(rapid.priceAdjustmentPercent).toBe(20);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 offers = service.generateOffers(mockRate);
|
||||||
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
expect(standard.priceMultiplier).toBe(1.0);
|
||||||
|
expect(standard.priceAdjustmentPercent).toBe(0);
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 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);
|
expect(rapid.adjustedTransitDays).toBeLessThan(standard.adjustedTransitDays);
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
// 20 * 0.70 = 14
|
||||||
|
expect(rapid.adjustedTransitDays).toBe(14);
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 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);
|
expect(economic.adjustedTransitDays).toBeGreaterThan(standard.adjustedTransitDays);
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
// 20 * 1.50 = 30
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
expect(economic.adjustedTransitDays).toBe(30);
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("STANDARD doit avoir le transit time de base (pas d'ajustement)", () => {
|
it('STANDARD has no transit adjustment', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
expect(standard.adjustedTransitDays).toBe(20);
|
||||||
|
expect(standard.transitAdjustmentPercent).toBe(0);
|
||||||
// STANDARD doit avoir le transit time de base
|
|
||||||
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);
|
const offers = service.generateOffers(mockRate);
|
||||||
|
|
||||||
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
||||||
expect(offers[1].serviceLevel).toBe(ServiceLevel.STANDARD);
|
expect(offers[1].serviceLevel).toBe(ServiceLevel.STANDARD);
|
||||||
expect(offers[2].serviceLevel).toBe(ServiceLevel.RAPID);
|
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', () => {
|
it('clamps transit time to minimum (5 days)', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
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) {
|
for (const offer of offers) {
|
||||||
expect(offer.rate).toBe(mockRate);
|
expect(offer.rate).toBe(mockRate);
|
||||||
expect(offer.originalPriceUSD).toBe(1000);
|
|
||||||
expect(offer.originalPriceEUR).toBe(900);
|
|
||||||
expect(offer.originalTransitDays).toBe(20);
|
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', () => {
|
describe('generateOffersForRates', () => {
|
||||||
it('doit générer 3 offres par tarif', () => {
|
it('generates 3 offers per rate', () => {
|
||||||
const rate1 = mockRate;
|
const rate2 = { ...mockRate, companyName: 'Another Carrier' } as any;
|
||||||
const rate2 = {
|
const offers = service.generateOffersForRates([mockRate, rate2]);
|
||||||
...mockRate,
|
expect(offers).toHaveLength(6);
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateOffersForServiceLevel', () => {
|
describe('generateOffersForServiceLevel', () => {
|
||||||
it('doit générer uniquement les offres RAPID', () => {
|
it('generates only RAPID offers', () => {
|
||||||
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.RAPID);
|
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.RAPID);
|
||||||
|
|
||||||
expect(offers).toHaveLength(1);
|
expect(offers).toHaveLength(1);
|
||||||
expect(offers[0].serviceLevel).toBe(ServiceLevel.RAPID);
|
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);
|
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.ECONOMIC);
|
||||||
|
|
||||||
expect(offers).toHaveLength(1);
|
expect(offers).toHaveLength(1);
|
||||||
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
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', () => {
|
describe('getBestOffersPerServiceLevel', () => {
|
||||||
it('doit retourner la meilleure offre de chaque niveau de service', () => {
|
it('returns one offer per service level', () => {
|
||||||
const rate1 = mockRate;
|
const best = service.getBestOffersPerServiceLevel([mockRate]);
|
||||||
const rate2 = {
|
|
||||||
...mockRate,
|
|
||||||
pricing: {
|
|
||||||
...mockRate.pricing,
|
|
||||||
basePriceUSD: Money.create(800, 'USD'),
|
|
||||||
},
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const best = service.getBestOffersPerServiceLevel([rate1, rate2]);
|
|
||||||
|
|
||||||
expect(best.rapid).not.toBeNull();
|
expect(best.rapid).not.toBeNull();
|
||||||
expect(best.standard).not.toBeNull();
|
expect(best.standard).not.toBeNull();
|
||||||
expect(best.economic).not.toBeNull();
|
expect(best.economic).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
// Toutes doivent provenir du rate2 (moins cher)
|
it('returns null for all levels when no rates', () => {
|
||||||
expect(best.rapid!.originalPriceUSD).toBe(800);
|
const best = service.getBestOffersPerServiceLevel([]);
|
||||||
expect(best.standard!.originalPriceUSD).toBe(800);
|
expect(best.rapid).toBeNull();
|
||||||
expect(best.economic!.originalPriceUSD).toBe(800);
|
expect(best.standard).toBeNull();
|
||||||
|
expect(best.economic).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isRateEligible', () => {
|
describe('isRateEligible', () => {
|
||||||
it('doit accepter un tarif valide', () => {
|
it('accepts a valid rate', () => {
|
||||||
expect(service.isRateEligible(mockRate)).toBe(true);
|
expect(service.isRateEligible(mockRate)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doit rejeter un tarif avec transit time = 0', () => {
|
it('rejects a rate with transitDays = 0', () => {
|
||||||
const invalidRate = { ...mockRate, transitDays: 0 } as any;
|
const invalid = { ...mockRate, transitDays: 0 } as any;
|
||||||
expect(service.isRateEligible(invalidRate)).toBe(false);
|
expect(service.isRateEligible(invalid)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doit rejeter un tarif avec prix = 0', () => {
|
it('rejects a rate with freightRatePerCBM = 0 and freightMinimum = 0', () => {
|
||||||
const invalidRate = {
|
const invalid = {
|
||||||
...mockRate,
|
...mockRate,
|
||||||
pricing: {
|
freight: { ...mockRate.freight, freightRatePerCBM: 0, freightMinimum: 0 },
|
||||||
...mockRate.pricing,
|
|
||||||
basePriceUSD: Money.create(0, 'USD'),
|
|
||||||
},
|
|
||||||
} as any;
|
} as any;
|
||||||
expect(service.isRateEligible(invalidRate)).toBe(false);
|
expect(service.isRateEligible(invalid)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doit rejeter un tarif expiré', () => {
|
it('rejects an expired rate', () => {
|
||||||
const expiredRate = {
|
const expired = { ...mockRate, isValidForDate: () => false } as any;
|
||||||
...mockRate,
|
expect(service.isRateEligible(expired)).toBe(false);
|
||||||
isValidForDate: () => false,
|
|
||||||
} as any;
|
|
||||||
expect(service.isRateEligible(expiredRate)).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('filterEligibleRates', () => {
|
describe('Business logic invariants', () => {
|
||||||
it('doit filtrer les tarifs invalides', () => {
|
it('RAPID priceMultiplier always > ECONOMIC priceMultiplier', () => {
|
||||||
const validRate = mockRate;
|
const offers = service.generateOffers(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);
|
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||||
|
expect(rapid.priceMultiplier).toBeGreaterThan(economic.priceMultiplier);
|
||||||
expect(rapid.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('RAPID doit TOUJOURS être plus rapide que ECONOMIC', () => {
|
it('RAPID transit always < ECONOMIC transit for different base days', () => {
|
||||||
// Test avec différents transit times de base
|
for (const days of [5, 10, 20, 30, 60]) {
|
||||||
const transitDays = [5, 10, 20, 30, 60];
|
|
||||||
|
|
||||||
for (const days of transitDays) {
|
|
||||||
const rate = { ...mockRate, transitDays: days } as any;
|
const rate = { ...mockRate, transitDays: days } as any;
|
||||||
|
|
||||||
const offers = service.generateOffers(rate);
|
const offers = service.generateOffers(rate);
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||||
|
|
||||||
expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import { CsvRate } from '../entities/csv-rate.entity';
|
|||||||
/**
|
/**
|
||||||
* Service Level Types
|
* Service Level Types
|
||||||
*
|
*
|
||||||
* - RAPID: Offre la plus chère + la plus rapide (transit time réduit)
|
* - RAPID : +20% price, -30% transit (express, priority)
|
||||||
* - STANDARD: Offre standard (prix et transit time de base)
|
* - STANDARD : base price and transit
|
||||||
* - ECONOMIC: Offre la moins chère + la plus lente (transit time augmenté)
|
* - ECONOMIC : -15% price, +50% transit (cheapest, slowest)
|
||||||
*/
|
*/
|
||||||
export enum ServiceLevel {
|
export enum ServiceLevel {
|
||||||
RAPID = 'RAPID',
|
RAPID = 'RAPID',
|
||||||
@ -13,243 +13,110 @@ export enum ServiceLevel {
|
|||||||
ECONOMIC = 'ECONOMIC',
|
ECONOMIC = 'ECONOMIC',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate Offer - Variante d'un tarif avec un niveau de service
|
|
||||||
*/
|
|
||||||
export interface RateOffer {
|
export interface RateOffer {
|
||||||
rate: CsvRate;
|
rate: CsvRate;
|
||||||
serviceLevel: ServiceLevel;
|
serviceLevel: ServiceLevel;
|
||||||
adjustedPriceUSD: number;
|
priceMultiplier: number;
|
||||||
adjustedPriceEUR: number;
|
|
||||||
adjustedTransitDays: number;
|
adjustedTransitDays: number;
|
||||||
originalPriceUSD: number;
|
|
||||||
originalPriceEUR: number;
|
|
||||||
originalTransitDays: number;
|
originalTransitDays: number;
|
||||||
priceAdjustmentPercent: number;
|
priceAdjustmentPercent: number;
|
||||||
transitAdjustmentPercent: number;
|
transitAdjustmentPercent: number;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration pour les ajustements de prix et transit par niveau de service
|
|
||||||
*/
|
|
||||||
interface ServiceLevelConfig {
|
interface ServiceLevelConfig {
|
||||||
priceMultiplier: number; // Multiplicateur de prix (1.0 = pas de changement)
|
priceMultiplier: number;
|
||||||
transitMultiplier: number; // Multiplicateur de transit time (1.0 = pas de changement)
|
transitMultiplier: number;
|
||||||
description: string;
|
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.
|
* Price adjustment is applied to the total calculated price in the search service —
|
||||||
*
|
* this service only stores the multiplier and the adjusted transit time.
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
export class RateOfferGeneratorService {
|
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> = {
|
private readonly SERVICE_LEVEL_CONFIGS: Record<ServiceLevel, ServiceLevelConfig> = {
|
||||||
[ServiceLevel.RAPID]: {
|
[ServiceLevel.RAPID]: {
|
||||||
priceMultiplier: 1.2, // +20% du prix de base
|
priceMultiplier: 1.2,
|
||||||
transitMultiplier: 0.7, // -30% du temps de transit (plus rapide)
|
transitMultiplier: 0.7,
|
||||||
description: 'Express - Livraison rapide avec service prioritaire',
|
description: 'Express — Livraison rapide avec service prioritaire',
|
||||||
},
|
},
|
||||||
[ServiceLevel.STANDARD]: {
|
[ServiceLevel.STANDARD]: {
|
||||||
priceMultiplier: 1.0, // Prix de base (pas de changement)
|
priceMultiplier: 1.0,
|
||||||
transitMultiplier: 1.0, // Transit time de base (pas de changement)
|
transitMultiplier: 1.0,
|
||||||
description: 'Standard - Service régulier au meilleur rapport qualité/prix',
|
description: 'Standard — Service régulier au meilleur rapport qualité/prix',
|
||||||
},
|
},
|
||||||
[ServiceLevel.ECONOMIC]: {
|
[ServiceLevel.ECONOMIC]: {
|
||||||
priceMultiplier: 0.85, // -15% du prix de base
|
priceMultiplier: 0.85,
|
||||||
transitMultiplier: 1.5, // +50% du temps de transit (plus lent)
|
transitMultiplier: 1.5,
|
||||||
description: 'Économique - Tarif réduit avec délai étendu',
|
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;
|
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;
|
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[] {
|
generateOffers(rate: CsvRate): RateOffer[] {
|
||||||
const offers: 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)) {
|
for (const serviceLevel of Object.values(ServiceLevel)) {
|
||||||
const config = this.SERVICE_LEVEL_CONFIGS[serviceLevel];
|
const config = this.SERVICE_LEVEL_CONFIGS[serviceLevel];
|
||||||
|
const rawTransit = rate.transitDays * config.transitMultiplier;
|
||||||
// Calculer les prix ajustés
|
const adjustedTransitDays = this.clampTransit(Math.round(rawTransit));
|
||||||
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);
|
|
||||||
|
|
||||||
offers.push({
|
offers.push({
|
||||||
rate,
|
rate,
|
||||||
serviceLevel,
|
serviceLevel,
|
||||||
adjustedPriceUSD,
|
priceMultiplier: config.priceMultiplier,
|
||||||
adjustedPriceEUR,
|
|
||||||
adjustedTransitDays,
|
adjustedTransitDays,
|
||||||
originalPriceUSD: basePriceUSD,
|
originalTransitDays: rate.transitDays,
|
||||||
originalPriceEUR: basePriceEUR,
|
priceAdjustmentPercent: Math.round((config.priceMultiplier - 1) * 100),
|
||||||
originalTransitDays: baseTransitDays,
|
transitAdjustmentPercent: Math.round((config.transitMultiplier - 1) * 100),
|
||||||
priceAdjustmentPercent,
|
|
||||||
transitAdjustmentPercent,
|
|
||||||
description: config.description,
|
description: config.description,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trier par prix croissant: ECONOMIC (moins cher) -> STANDARD -> RAPID (plus cher)
|
// ECONOMIC → STANDARD → RAPID (cheapest first)
|
||||||
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
|
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[] {
|
generateOffersForRates(rates: CsvRate[]): RateOffer[] {
|
||||||
const allOffers: RateOffer[] = [];
|
return rates.flatMap(rate => this.generateOffers(rate));
|
||||||
|
|
||||||
for (const rate of rates) {
|
|
||||||
const offers = this.generateOffers(rate);
|
|
||||||
allOffers.push(...offers);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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[] {
|
generateOffersForServiceLevel(rates: CsvRate[], serviceLevel: ServiceLevel): RateOffer[] {
|
||||||
const offers: RateOffer[] = [];
|
return rates
|
||||||
|
.map(rate => this.generateOffers(rate).find(o => o.serviceLevel === serviceLevel)!)
|
||||||
for (const rate of rates) {
|
.filter(Boolean);
|
||||||
const allOffers = this.generateOffers(rate);
|
|
||||||
const matchingOffer = allOffers.find(o => o.serviceLevel === serviceLevel);
|
|
||||||
|
|
||||||
if (matchingOffer) {
|
|
||||||
offers.push(matchingOffer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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[]): {
|
getBestOffersPerServiceLevel(rates: CsvRate[]): {
|
||||||
rapid: RateOffer | null;
|
rapid: RateOffer | null;
|
||||||
standard: RateOffer | null;
|
standard: RateOffer | null;
|
||||||
economic: RateOffer | null;
|
economic: RateOffer | null;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] || null,
|
rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] ?? null,
|
||||||
standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] || null,
|
standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] ?? null,
|
||||||
economic: this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC)[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 {
|
isRateEligible(rate: CsvRate): boolean {
|
||||||
if (rate.transitDays <= 0) return false;
|
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;
|
if (!rate.isValidForDate(new Date())) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Filtre les tarifs éligibles pour la génération d'offres
|
|
||||||
*/
|
|
||||||
filterEligibleRates(rates: CsvRate[]): CsvRate[] {
|
filterEligibleRates(rates: CsvRate[]): CsvRate[] {
|
||||||
return rates.filter(rate => this.isRateEligible(rate));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,3 +14,4 @@ export * from './booking-status.vo';
|
|||||||
export * from './subscription-plan.vo';
|
export * from './subscription-plan.vo';
|
||||||
export * from './subscription-status.vo';
|
export * from './subscription-status.vo';
|
||||||
export * from './license-status.vo';
|
export * from './license-status.vo';
|
||||||
|
export * from './locale.vo';
|
||||||
|
|||||||
19
apps/backend/src/domain/value-objects/locale.vo.ts
Normal file
19
apps/backend/src/domain/value-objects/locale.vo.ts
Normal 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;
|
||||||
|
}
|
||||||
9
apps/backend/src/i18n/en/auth.json
Normal file
9
apps/backend/src/i18n/en/auth.json
Normal 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"
|
||||||
|
}
|
||||||
10
apps/backend/src/i18n/en/booking.json
Normal file
10
apps/backend/src/i18n/en/booking.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"status": {
|
||||||
|
"DRAFT": "Draft",
|
||||||
|
"CONFIRMED": "Confirmed",
|
||||||
|
"SHIPPED": "Shipped",
|
||||||
|
"DELIVERED": "Delivered",
|
||||||
|
"CANCELLED": "Cancelled",
|
||||||
|
"REJECTED": "Rejected"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
apps/backend/src/i18n/en/common.json
Normal file
5
apps/backend/src/i18n/en/common.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"SUCCESS": "Success",
|
||||||
|
"YES": "Yes",
|
||||||
|
"NO": "No"
|
||||||
|
}
|
||||||
44
apps/backend/src/i18n/en/email.json
Normal file
44
apps/backend/src/i18n/en/email.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/backend/src/i18n/en/error.json
Normal file
23
apps/backend/src/i18n/en/error.json
Normal 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"
|
||||||
|
}
|
||||||
30
apps/backend/src/i18n/en/notification.json
Normal file
30
apps/backend/src/i18n/en/notification.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/backend/src/i18n/en/pdf.json
Normal file
36
apps/backend/src/i18n/en/pdf.json
Normal 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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/backend/src/i18n/en/validation.json
Normal file
30
apps/backend/src/i18n/en/validation.json
Normal 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'"
|
||||||
|
}
|
||||||
9
apps/backend/src/i18n/fr/auth.json
Normal file
9
apps/backend/src/i18n/fr/auth.json
Normal 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é"
|
||||||
|
}
|
||||||
10
apps/backend/src/i18n/fr/booking.json
Normal file
10
apps/backend/src/i18n/fr/booking.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"status": {
|
||||||
|
"DRAFT": "Brouillon",
|
||||||
|
"CONFIRMED": "Confirmée",
|
||||||
|
"SHIPPED": "Expédiée",
|
||||||
|
"DELIVERED": "Livrée",
|
||||||
|
"CANCELLED": "Annulée",
|
||||||
|
"REJECTED": "Refusée"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
apps/backend/src/i18n/fr/common.json
Normal file
5
apps/backend/src/i18n/fr/common.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"SUCCESS": "Succès",
|
||||||
|
"YES": "Oui",
|
||||||
|
"NO": "Non"
|
||||||
|
}
|
||||||
44
apps/backend/src/i18n/fr/email.json
Normal file
44
apps/backend/src/i18n/fr/email.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/backend/src/i18n/fr/error.json
Normal file
23
apps/backend/src/i18n/fr/error.json
Normal 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"
|
||||||
|
}
|
||||||
30
apps/backend/src/i18n/fr/notification.json
Normal file
30
apps/backend/src/i18n/fr/notification.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/backend/src/i18n/fr/pdf.json
Normal file
36
apps/backend/src/i18n/fr/pdf.json
Normal 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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/backend/src/i18n/fr/validation.json
Normal file
30
apps/backend/src/i18n/fr/validation.json
Normal 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'"
|
||||||
|
}
|
||||||
@ -5,41 +5,58 @@ import * as path from 'path';
|
|||||||
/**
|
/**
|
||||||
* CSV Converter Service
|
* CSV Converter Service
|
||||||
*
|
*
|
||||||
* Détecte automatiquement le format du CSV et convertit au format attendu
|
* Detects and converts CSV files to the standard 33-column Xpeditis format.
|
||||||
* Supporte:
|
*
|
||||||
* - Format standard Xpeditis
|
* Standard format columns (33):
|
||||||
* - Format "Frais FOB FRET"
|
* 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()
|
@Injectable()
|
||||||
export class CsvConverterService {
|
export class CsvConverterService {
|
||||||
private readonly logger = new Logger(CsvConverterService.name);
|
private readonly logger = new Logger(CsvConverterService.name);
|
||||||
|
|
||||||
// Headers du format standard attendu
|
|
||||||
private readonly STANDARD_HEADERS = [
|
private readonly STANDARD_HEADERS = [
|
||||||
'companyName',
|
'companyName',
|
||||||
'origin',
|
'companyEmail',
|
||||||
'destination',
|
'originCFS',
|
||||||
|
'originCode',
|
||||||
|
'portOfLoading',
|
||||||
|
'routing',
|
||||||
|
'destinationCFS',
|
||||||
|
'destinationCode',
|
||||||
|
'destinationCountry',
|
||||||
'containerType',
|
'containerType',
|
||||||
'minVolumeCBM',
|
'freightCurrency',
|
||||||
'maxVolumeCBM',
|
'freightRatePerCBM',
|
||||||
'minWeightKG',
|
'freightMinimum',
|
||||||
'maxWeightKG',
|
'fobCurrency',
|
||||||
'palletCount',
|
'fobDocumentation',
|
||||||
'pricePerCBM',
|
'fobISPS',
|
||||||
'pricePerKG',
|
'fobHandling',
|
||||||
'basePriceUSD',
|
'fobHandlingUnit',
|
||||||
'basePriceEUR',
|
'fobHandlingMinimum',
|
||||||
'currency',
|
'fobSolas',
|
||||||
'hasSurcharges',
|
'fobCustoms',
|
||||||
'surchargeBAF',
|
'fobAMS_ACI',
|
||||||
'surchargeCAF',
|
'fobISF5',
|
||||||
'surchargeDetails',
|
'fobDGAdmin',
|
||||||
|
'dgSurchargeCurrency',
|
||||||
|
'dgSurchargeRate',
|
||||||
|
'dgSurchargeUnit',
|
||||||
|
'dgSurchargeMin',
|
||||||
|
'remarks',
|
||||||
|
'frequency',
|
||||||
'transitDays',
|
'transitDays',
|
||||||
'validFrom',
|
'validFrom',
|
||||||
'validUntil',
|
'validUntil',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Headers du format "Frais FOB FRET"
|
// Legacy "Frais FOB FRET" format indicators (older Excel exports)
|
||||||
private readonly FOB_FRET_HEADERS = [
|
private readonly FOB_FRET_HEADERS = [
|
||||||
'Origine UN code',
|
'Origine UN code',
|
||||||
'Destination UN code',
|
'Destination UN code',
|
||||||
@ -49,259 +66,32 @@ export class CsvConverterService {
|
|||||||
'Transit time',
|
'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'> {
|
async detectFormat(filePath: string): Promise<'STANDARD' | 'FOB_FRET' | 'UNKNOWN'> {
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(filePath, 'utf-8');
|
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++) {
|
for (let i = 0; i < Math.min(2, lines.length); i++) {
|
||||||
const headers = this.parseCSVLine(lines[i]);
|
const headers = this.parseCSVLine(lines[i]);
|
||||||
|
if (this.STANDARD_HEADERS.some(h => headers.includes(h))) return 'STANDARD';
|
||||||
// Vérifier format standard
|
if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) return 'FOB_FRET';
|
||||||
const hasStandardHeaders = this.STANDARD_HEADERS.some(h => headers.includes(h));
|
|
||||||
if (hasStandardHeaders) {
|
|
||||||
return 'STANDARD';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier format FOB FRET
|
|
||||||
const hasFobFretHeaders = this.FOB_FRET_HEADERS.some(h => headers.includes(h));
|
|
||||||
if (hasFobFretHeaders) {
|
|
||||||
return 'FOB_FRET';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'UNKNOWN';
|
return 'UNKNOWN';
|
||||||
} catch (error) {
|
} catch {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
this.logger.error(`Error detecting CSV format: ${errorMessage}`);
|
|
||||||
return 'UNKNOWN';
|
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(
|
async autoConvert(
|
||||||
inputPath: string,
|
inputPath: string,
|
||||||
companyName: string
|
companyName: string
|
||||||
): Promise<{ convertedPath: string; wasConverted: boolean; rowsConverted?: number }> {
|
): Promise<{ convertedPath: string; wasConverted: boolean; rowsConverted?: number }> {
|
||||||
const format = await this.detectFormat(inputPath);
|
const format = await this.detectFormat(inputPath);
|
||||||
|
this.logger.log(`Detected CSV format: ${format} for ${inputPath}`);
|
||||||
this.logger.log(`Detected CSV format: ${format}`);
|
|
||||||
|
|
||||||
if (format === 'STANDARD') {
|
if (format === 'STANDARD') {
|
||||||
return {
|
return { convertedPath: inputPath, wasConverted: false };
|
||||||
convertedPath: inputPath,
|
|
||||||
wasConverted: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === 'FOB_FRET') {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,61 +4,109 @@ import { parse } from 'csv-parse/sync';
|
|||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port';
|
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 { PortCode } from '@domain/value-objects/port-code.vo';
|
||||||
import { ContainerType } from '@domain/value-objects/container-type.vo';
|
import { ContainerType } from '@domain/value-objects/container-type.vo';
|
||||||
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 { DateRange } from '@domain/value-objects/date-range.vo';
|
||||||
import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter';
|
import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter';
|
||||||
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Row Interface
|
* Standardized 33-column CSV row.
|
||||||
* Maps to CSV file structure
|
* All suppliers share this exact schema.
|
||||||
*/
|
*/
|
||||||
interface CsvRow {
|
interface CsvRow {
|
||||||
|
// Supplier identity
|
||||||
companyName: string;
|
companyName: string;
|
||||||
origin: string;
|
companyEmail: string;
|
||||||
destination: string;
|
// Route geography
|
||||||
|
originCFS: string;
|
||||||
|
originCode: string;
|
||||||
|
portOfLoading: string;
|
||||||
|
routing: string;
|
||||||
|
destinationCFS: string;
|
||||||
|
destinationCode: string;
|
||||||
|
destinationCountry: string;
|
||||||
|
// Container
|
||||||
containerType: string;
|
containerType: string;
|
||||||
minVolumeCBM: string;
|
// Freight
|
||||||
maxVolumeCBM: string;
|
freightCurrency: string;
|
||||||
minWeightKG: string;
|
freightRatePerCBM: string;
|
||||||
maxWeightKG: string;
|
freightMinimum: string;
|
||||||
palletCount: string;
|
// FOB charges
|
||||||
pricePerCBM: string;
|
fobCurrency: string;
|
||||||
pricePerKG: string;
|
fobDocumentation: string;
|
||||||
basePriceUSD: string;
|
fobISPS: string;
|
||||||
basePriceEUR: string;
|
fobHandling: string;
|
||||||
currency: string;
|
fobHandlingUnit: string;
|
||||||
hasSurcharges: string;
|
fobHandlingMinimum: string;
|
||||||
surchargeBAF?: string;
|
fobSolas: string;
|
||||||
surchargeCAF?: string;
|
fobCustoms: string;
|
||||||
surchargeDetails?: 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;
|
transitDays: string;
|
||||||
validFrom: string;
|
validFrom: string;
|
||||||
validUntil: string;
|
validUntil: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const REQUIRED_COLUMNS = [
|
||||||
* CSV Rate Loader Adapter
|
'companyName',
|
||||||
*
|
'companyEmail',
|
||||||
* Infrastructure adapter for loading shipping rates from CSV files.
|
'originCFS',
|
||||||
* Implements CsvRateLoaderPort interface.
|
'originCode',
|
||||||
*
|
'portOfLoading',
|
||||||
* Features:
|
'routing',
|
||||||
* - CSV parsing with validation
|
'destinationCFS',
|
||||||
* - Mapping CSV rows to domain entities
|
'destinationCode',
|
||||||
* - Error handling and logging
|
'destinationCountry',
|
||||||
* - File system operations
|
'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()
|
@Injectable()
|
||||||
export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||||
private readonly logger = new Logger(CsvRateLoaderAdapter.name);
|
private readonly logger = new Logger(CsvRateLoaderAdapter.name);
|
||||||
private readonly csvDirectory: string;
|
private readonly csvDirectory: string;
|
||||||
|
|
||||||
// Company name to CSV file mapping
|
|
||||||
private readonly companyFileMapping: Map<string, string> = new Map([
|
private readonly companyFileMapping: Map<string, string> = new Map([
|
||||||
['SSC Consolidation', 'ssc-consolidation.csv'],
|
['SSC Consolidation', 'ssc-consolidation.csv'],
|
||||||
['ECU Worldwide', 'ecu-worldwide.csv'],
|
['ECU Worldwide', 'ecu-worldwide.csv'],
|
||||||
@ -71,10 +119,6 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
@Optional() private readonly configService?: ConfigService,
|
@Optional() private readonly configService?: ConfigService,
|
||||||
@Optional() private readonly csvConfigRepository?: TypeOrmCsvRateConfigRepository
|
@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(
|
this.csvDirectory = path.join(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
'src',
|
'src',
|
||||||
@ -84,10 +128,6 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
'rates'
|
'rates'
|
||||||
);
|
);
|
||||||
this.logger.log(`CSV directory initialized: ${this.csvDirectory}`);
|
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(
|
async loadRatesFromCsv(
|
||||||
@ -95,49 +135,32 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
companyEmail: string,
|
companyEmail: string,
|
||||||
companyNameOverride?: string
|
companyNameOverride?: string
|
||||||
): Promise<CsvRate[]> {
|
): Promise<CsvRate[]> {
|
||||||
this.logger.log(
|
this.logger.log(`Loading rates from CSV: ${filePath}`);
|
||||||
`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let fileContent: string;
|
let fileContent: string;
|
||||||
|
|
||||||
// Try to load from MinIO first if configured
|
|
||||||
if (this.s3Storage && this.configService && this.csvConfigRepository && companyNameOverride) {
|
if (this.s3Storage && this.configService && this.csvConfigRepository && companyNameOverride) {
|
||||||
try {
|
try {
|
||||||
const config = await this.csvConfigRepository.findByCompanyName(companyNameOverride);
|
const config = await this.csvConfigRepository.findByCompanyName(companyNameOverride);
|
||||||
const minioObjectKey = config?.metadata?.minioObjectKey as string | undefined;
|
const minioObjectKey = config?.metadata?.minioObjectKey as string | undefined;
|
||||||
|
|
||||||
if (minioObjectKey) {
|
if (minioObjectKey) {
|
||||||
const bucket = this.configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates');
|
const bucket = this.configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates');
|
||||||
this.logger.log(`📥 Loading CSV from MinIO: ${bucket}/${minioObjectKey}`);
|
|
||||||
|
|
||||||
const buffer = await this.s3Storage.download({ bucket, key: minioObjectKey });
|
const buffer = await this.s3Storage.download({ bucket, key: minioObjectKey });
|
||||||
fileContent = buffer.toString('utf-8');
|
fileContent = buffer.toString('utf-8');
|
||||||
this.logger.log(`✅ Successfully loaded CSV from MinIO`);
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to local file
|
throw new Error('No MinIO object key');
|
||||||
throw new Error('No MinIO object key found, using local file');
|
|
||||||
}
|
}
|
||||||
} catch (minioError: any) {
|
} catch (minioError: any) {
|
||||||
this.logger.warn(
|
this.logger.warn(`MinIO unavailable: ${minioError.message}. Using local file.`);
|
||||||
`⚠️ Failed to load from MinIO: ${minioError.message}. Falling back to local file.`
|
const fullPath = this.resolvePath(filePath);
|
||||||
);
|
|
||||||
// Fallback to local file system
|
|
||||||
const fullPath = path.isAbsolute(filePath)
|
|
||||||
? filePath
|
|
||||||
: path.join(this.csvDirectory, filePath);
|
|
||||||
fileContent = await fs.readFile(fullPath, 'utf-8');
|
fileContent = await fs.readFile(fullPath, 'utf-8');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Read from local file system
|
const fullPath = this.resolvePath(filePath);
|
||||||
const fullPath = path.isAbsolute(filePath)
|
|
||||||
? filePath
|
|
||||||
: path.join(this.csvDirectory, filePath);
|
|
||||||
fileContent = await fs.readFile(fullPath, 'utf-8');
|
fileContent = await fs.readFile(fullPath, 'utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse CSV
|
|
||||||
const records: CsvRow[] = parse(fileContent, {
|
const records: CsvRow[] = parse(fileContent, {
|
||||||
columns: true,
|
columns: true,
|
||||||
skip_empty_lines: true,
|
skip_empty_lines: true,
|
||||||
@ -145,62 +168,48 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Parsed ${records.length} rows from ${filePath}`);
|
this.logger.log(`Parsed ${records.length} rows from ${filePath}`);
|
||||||
|
|
||||||
// Validate structure
|
|
||||||
this.validateCsvStructure(records);
|
this.validateCsvStructure(records);
|
||||||
|
|
||||||
// Map to domain entities
|
|
||||||
const rates = records.map((record, index) => {
|
const rates = records.map((record, index) => {
|
||||||
try {
|
try {
|
||||||
return this.mapToCsvRate(record, companyEmail, companyNameOverride);
|
return this.mapToCsvRate(record, companyEmail, companyNameOverride);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
this.logger.error(`Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`);
|
throw new Error(`Row ${index + 1} in ${filePath}: ${msg}`);
|
||||||
throw new Error(`Invalid data in row ${index + 1} of ${filePath}: ${errorMessage}`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Successfully loaded ${rates.length} rates from ${filePath}`);
|
this.logger.log(`Loaded ${rates.length} rates from ${filePath}`);
|
||||||
return rates;
|
return rates;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
this.logger.error(`Failed to load CSV file ${filePath}: ${errorMessage}`);
|
this.logger.error(`Failed to load ${filePath}: ${msg}`);
|
||||||
throw new Error(`CSV loading failed for ${filePath}: ${errorMessage}`);
|
throw new Error(`CSV loading failed for ${filePath}: ${msg}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadRatesByCompany(companyName: string): Promise<CsvRate[]> {
|
async loadRatesByCompany(companyName: string): Promise<CsvRate[]> {
|
||||||
const fileName = this.companyFileMapping.get(companyName);
|
const fileName = this.companyFileMapping.get(companyName);
|
||||||
|
|
||||||
if (!fileName) {
|
if (!fileName) {
|
||||||
this.logger.warn(`No CSV file configured for company: ${companyName}`);
|
this.logger.warn(`No CSV file for company: ${companyName}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const email = `info@${companyName.toLowerCase().replace(/\s+/g, '-')}.com`;
|
||||||
// Use placeholder email since we don't have access to config repository here
|
return this.loadRatesFromCsv(fileName, email);
|
||||||
const placeholderEmail = `info@${companyName.toLowerCase().replace(/\s+/g, '-')}.com`;
|
|
||||||
return this.loadRatesFromCsv(fileName, placeholderEmail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateCsvFile(
|
async validateCsvFile(
|
||||||
filePath: string
|
filePath: string
|
||||||
): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> {
|
): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fullPath = path.isAbsolute(filePath)
|
const fullPath = this.resolvePath(filePath);
|
||||||
? filePath
|
|
||||||
: path.join(this.csvDirectory, filePath);
|
|
||||||
|
|
||||||
// Check if file exists
|
|
||||||
try {
|
try {
|
||||||
await fs.access(fullPath);
|
await fs.access(fullPath);
|
||||||
} catch {
|
} catch {
|
||||||
errors.push(`File not found: ${filePath}`);
|
return { valid: false, errors: [`File not found: ${filePath}`] };
|
||||||
return { valid: false, errors };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and parse
|
|
||||||
const fileContent = await fs.readFile(fullPath, 'utf-8');
|
const fileContent = await fs.readFile(fullPath, 'utf-8');
|
||||||
const records: CsvRow[] = parse(fileContent, {
|
const records: CsvRow[] = parse(fileContent, {
|
||||||
columns: true,
|
columns: true,
|
||||||
@ -209,200 +218,154 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
errors.push('CSV file is empty');
|
return { valid: false, errors: ['CSV file is empty'], rowCount: 0 };
|
||||||
return { valid: false, errors, rowCount: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate structure
|
|
||||||
try {
|
try {
|
||||||
this.validateCsvStructure(records);
|
this.validateCsvStructure(records);
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
errors.push(e instanceof Error ? e.message : String(e));
|
||||||
errors.push(errorMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate each row (use dummy email for validation)
|
|
||||||
records.forEach((record, index) => {
|
records.forEach((record, index) => {
|
||||||
try {
|
try {
|
||||||
this.mapToCsvRate(record, 'validation@example.com');
|
this.mapToCsvRate(record, 'validation@example.com');
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
errors.push(`Row ${index + 1}: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
errors.push(`Row ${index + 1}: ${errorMessage}`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors, rowCount: records.length };
|
||||||
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
valid: errors.length === 0,
|
valid: false,
|
||||||
errors,
|
errors: [`Validation failed: ${e instanceof Error ? e.message : String(e)}`],
|
||||||
rowCount: records.length,
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
errors.push(`Validation failed: ${errorMessage}`);
|
|
||||||
return { valid: false, errors };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailableCsvFiles(): Promise<string[]> {
|
async getAvailableCsvFiles(): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
// If MinIO/S3 is configured, list files from there
|
if (this.s3Storage && this.csvConfigRepository) {
|
||||||
if (this.s3Storage && this.configService && this.csvConfigRepository) {
|
|
||||||
try {
|
try {
|
||||||
const configs = await this.csvConfigRepository.findAll();
|
const configs = await this.csvConfigRepository.findAll();
|
||||||
const minioFiles = configs
|
const minioFiles = configs
|
||||||
.filter(config => config.metadata?.minioObjectKey)
|
.filter(c => c.metadata?.minioObjectKey)
|
||||||
.map(config => config.metadata?.minioObjectKey as string);
|
.map(c => c.metadata?.minioObjectKey as string);
|
||||||
|
if (minioFiles.length > 0) return minioFiles;
|
||||||
if (minioFiles.length > 0) {
|
} catch {
|
||||||
this.logger.log(`📂 Found ${minioFiles.length} CSV files in MinIO`);
|
// fall through to local
|
||||||
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.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: list from local file system
|
|
||||||
try {
|
try {
|
||||||
await fs.access(this.csvDirectory);
|
await fs.access(this.csvDirectory);
|
||||||
} catch {
|
} catch {
|
||||||
this.logger.warn(`CSV directory does not exist: ${this.csvDirectory}`);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = await fs.readdir(this.csvDirectory);
|
const files = await fs.readdir(this.csvDirectory);
|
||||||
return files.filter(file => file.endsWith('.csv'));
|
return files.filter(f => f.endsWith('.csv'));
|
||||||
} catch (error) {
|
} catch {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
this.logger.error(`Failed to list CSV files: ${errorMessage}`);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private resolvePath(filePath: string): string {
|
||||||
* Validate that CSV has all required columns
|
return path.isAbsolute(filePath) ? filePath : path.join(this.csvDirectory, filePath);
|
||||||
*/
|
}
|
||||||
|
|
||||||
private validateCsvStructure(records: CsvRow[]): void {
|
private validateCsvStructure(records: CsvRow[]): void {
|
||||||
const requiredColumns = [
|
if (records.length === 0) throw new Error('CSV file is empty');
|
||||||
'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');
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstRecord = records[0];
|
const firstRecord = records[0];
|
||||||
const missingColumns = requiredColumns.filter(col => !(col in firstRecord));
|
const missing = REQUIRED_COLUMNS.filter(col => !(col in firstRecord));
|
||||||
|
if (missing.length > 0) {
|
||||||
if (missingColumns.length > 0) {
|
throw new Error(`Missing required columns: ${missing.join(', ')}`);
|
||||||
throw new Error(`Missing required columns: ${missingColumns.join(', ')}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private mapToCsvRate(r: CsvRow, companyEmail: string, companyNameOverride?: string): CsvRate {
|
||||||
* Map CSV row to CsvRate domain entity
|
const companyName = companyNameOverride || r.companyName.trim();
|
||||||
*/
|
// Admin-configured email always takes priority over the value in the CSV row
|
||||||
private mapToCsvRate(
|
const email = companyEmail?.trim() || r.companyEmail?.trim();
|
||||||
record: CsvRow,
|
|
||||||
companyEmail: string,
|
|
||||||
companyNameOverride?: string
|
|
||||||
): CsvRate {
|
|
||||||
// Parse surcharges
|
|
||||||
const surcharges = this.parseSurcharges(record);
|
|
||||||
|
|
||||||
// Create DateRange
|
const freight: FreightPricing = {
|
||||||
const validFrom = new Date(record.validFrom);
|
freightCurrency: r.freightCurrency.toUpperCase(),
|
||||||
const validUntil = new Date(record.validUntil);
|
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);
|
const validity = DateRange.create(validFrom, validUntil, true);
|
||||||
|
|
||||||
// Use override company name if provided, otherwise use the one from CSV
|
const frequency = parseFrequency(r.frequency);
|
||||||
const companyName = companyNameOverride || record.companyName.trim();
|
|
||||||
|
|
||||||
// Create CsvRate
|
|
||||||
return new CsvRate(
|
return new CsvRate(
|
||||||
companyName,
|
companyName,
|
||||||
companyEmail,
|
email,
|
||||||
PortCode.create(record.origin),
|
r.originCFS.trim(),
|
||||||
PortCode.create(record.destination),
|
PortCode.create(r.originCode.trim()),
|
||||||
ContainerType.create(record.containerType),
|
r.portOfLoading.trim(),
|
||||||
{
|
r.routing.trim(),
|
||||||
minCBM: parseFloat(record.minVolumeCBM),
|
r.destinationCFS.trim(),
|
||||||
maxCBM: parseFloat(record.maxVolumeCBM),
|
PortCode.create(r.destinationCode.trim()),
|
||||||
},
|
r.destinationCountry.trim(),
|
||||||
{
|
ContainerType.create(r.containerType.trim()),
|
||||||
minKG: parseFloat(record.minWeightKG),
|
freight,
|
||||||
maxKG: parseFloat(record.maxWeightKG),
|
fob,
|
||||||
},
|
dgSurcharge,
|
||||||
parseInt(record.palletCount, 10),
|
r.remarks?.trim() || '',
|
||||||
{
|
frequency,
|
||||||
pricePerCBM: parseFloat(record.pricePerCBM),
|
parseInt(r.transitDays, 10),
|
||||||
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),
|
|
||||||
validity
|
validity
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
function parseDgValue(raw: string): DgSurchargeValue {
|
||||||
* Parse surcharges from CSV row
|
if (!raw || raw.trim() === '') return 0;
|
||||||
*/
|
const upper = raw.trim().toUpperCase();
|
||||||
private parseSurcharges(record: CsvRow): Surcharge[] {
|
if (upper === 'ON REQUEST') return 'ON REQUEST';
|
||||||
const hasSurcharges = record.hasSurcharges.toLowerCase() === 'true';
|
if (upper === 'NOT ACCEPTED') return 'NOT ACCEPTED';
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? 0 : num;
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasSurcharges) {
|
function parseFrequency(raw: string): FrequencyType {
|
||||||
return [];
|
switch (raw?.trim()) {
|
||||||
}
|
case 'Weekly':
|
||||||
|
return 'Weekly';
|
||||||
const surcharges: Surcharge[] = [];
|
case 'Bi-Weekly':
|
||||||
const currency = record.currency.toUpperCase();
|
return 'Bi-Weekly';
|
||||||
|
case 'Bi-Monthly':
|
||||||
// BAF (Bunker Adjustment Factor)
|
return 'Bi-Monthly';
|
||||||
if (record.surchargeBAF && parseFloat(record.surchargeBAF) > 0) {
|
case 'Monthly':
|
||||||
surcharges.push(
|
return 'Monthly';
|
||||||
new Surcharge(
|
default:
|
||||||
SurchargeType.BAF,
|
return 'Weekly';
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,7 +73,9 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
|
|||||||
this.buildTransporter(ip, host);
|
this.buildTransporter(ip, host);
|
||||||
return;
|
return;
|
||||||
} catch (err: any) {
|
} 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> {
|
private resolveViaDoH(hostname: string): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=A`;
|
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 = '';
|
let raw = '';
|
||||||
res.on('data', (chunk) => (raw += chunk));
|
res.on('data', chunk => (raw += chunk));
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(raw);
|
const json = JSON.parse(raw);
|
||||||
@ -136,7 +138,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
|
|||||||
`Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}`
|
`Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}`
|
||||||
);
|
);
|
||||||
|
|
||||||
this.transporter.verify((error) => {
|
this.transporter.verify(error => {
|
||||||
if (error) {
|
if (error) {
|
||||||
this.logger.error(`❌ SMTP connection FAILED: ${error.message}`);
|
this.logger.error(`❌ SMTP connection FAILED: ${error.message}`);
|
||||||
} else {
|
} else {
|
||||||
@ -148,8 +150,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
|
|||||||
async send(options: EmailOptions): Promise<void> {
|
async send(options: EmailOptions): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const from =
|
const from =
|
||||||
options.from ??
|
options.from ?? this.configService.get<string>('SMTP_FROM', EMAIL_SENDERS.NOREPLY);
|
||||||
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)
|
// 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);
|
const text = options.text ?? (options.html ? htmlToPlainText(options.html) : undefined);
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -75,11 +75,24 @@ export class CsvBookingOrmEntity {
|
|||||||
@Column({
|
@Column({
|
||||||
name: 'status',
|
name: 'status',
|
||||||
type: 'enum',
|
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',
|
default: 'PENDING_PAYMENT',
|
||||||
})
|
})
|
||||||
@Index()
|
@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' })
|
@Column({ name: 'documents', type: 'jsonb' })
|
||||||
documents: Array<{
|
documents: Array<{
|
||||||
|
|||||||
@ -74,6 +74,9 @@ export class OrganizationOrmEntity {
|
|||||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'default_language', type: 'varchar', length: 2, default: 'fr' })
|
||||||
|
defaultLanguage: string;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,4 @@
|
|||||||
import {
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
Index,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('password_reset_tokens')
|
@Entity('password_reset_tokens')
|
||||||
export class PasswordResetTokenOrmEntity {
|
export class PasswordResetTokenOrmEntity {
|
||||||
|
|||||||
@ -62,6 +62,9 @@ export class UserOrmEntity {
|
|||||||
@Column({ name: 'last_login_at', type: 'timestamp', nullable: true })
|
@Column({ name: 'last_login_at', type: 'timestamp', nullable: true })
|
||||||
lastLoginAt: Date | null;
|
lastLoginAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'preferred_language', type: 'varchar', length: 2, default: 'fr' })
|
||||||
|
preferredLanguage: string;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Organization, OrganizationProps } from '@domain/entities/organization.entity';
|
import { Organization, OrganizationProps } from '@domain/entities/organization.entity';
|
||||||
|
import { toLocale } from '@domain/value-objects/locale.vo';
|
||||||
import { OrganizationOrmEntity } from '../entities/organization.orm-entity';
|
import { OrganizationOrmEntity } from '../entities/organization.orm-entity';
|
||||||
|
|
||||||
export class OrganizationOrmMapper {
|
export class OrganizationOrmMapper {
|
||||||
@ -34,6 +35,7 @@ export class OrganizationOrmMapper {
|
|||||||
orm.siretVerified = props.siretVerified;
|
orm.siretVerified = props.siretVerified;
|
||||||
orm.statusBadge = props.statusBadge;
|
orm.statusBadge = props.statusBadge;
|
||||||
orm.isActive = props.isActive;
|
orm.isActive = props.isActive;
|
||||||
|
orm.defaultLanguage = props.defaultLanguage;
|
||||||
orm.createdAt = props.createdAt;
|
orm.createdAt = props.createdAt;
|
||||||
orm.updatedAt = props.updatedAt;
|
orm.updatedAt = props.updatedAt;
|
||||||
|
|
||||||
@ -66,6 +68,7 @@ export class OrganizationOrmMapper {
|
|||||||
siretVerified: orm.siretVerified ?? false,
|
siretVerified: orm.siretVerified ?? false,
|
||||||
statusBadge: (orm.statusBadge as 'none' | 'silver' | 'gold' | 'platinium') || 'none',
|
statusBadge: (orm.statusBadge as 'none' | 'silver' | 'gold' | 'platinium') || 'none',
|
||||||
isActive: orm.isActive,
|
isActive: orm.isActive,
|
||||||
|
defaultLanguage: toLocale(orm.defaultLanguage),
|
||||||
createdAt: orm.createdAt,
|
createdAt: orm.createdAt,
|
||||||
updatedAt: orm.updatedAt,
|
updatedAt: orm.updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,7 +5,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Subscription } from '@domain/entities/subscription.entity';
|
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. */
|
/** Maps canonical domain plan names back to the values stored in the DB. */
|
||||||
const DOMAIN_TO_ORM_PLAN: Record<string, SubscriptionPlanOrmType> = {
|
const DOMAIN_TO_ORM_PLAN: Record<string, SubscriptionPlanOrmType> = {
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { User, UserProps } from '@domain/entities/user.entity';
|
import { User, UserProps } from '@domain/entities/user.entity';
|
||||||
|
import { toLocale } from '@domain/value-objects/locale.vo';
|
||||||
import { UserOrmEntity } from '../entities/user.orm-entity';
|
import { UserOrmEntity } from '../entities/user.orm-entity';
|
||||||
|
|
||||||
export class UserOrmMapper {
|
export class UserOrmMapper {
|
||||||
@ -27,6 +28,7 @@ export class UserOrmMapper {
|
|||||||
orm.isEmailVerified = props.isEmailVerified;
|
orm.isEmailVerified = props.isEmailVerified;
|
||||||
orm.isActive = props.isActive;
|
orm.isActive = props.isActive;
|
||||||
orm.lastLoginAt = props.lastLoginAt || null;
|
orm.lastLoginAt = props.lastLoginAt || null;
|
||||||
|
orm.preferredLanguage = props.preferredLanguage;
|
||||||
orm.createdAt = props.createdAt;
|
orm.createdAt = props.createdAt;
|
||||||
orm.updatedAt = props.updatedAt;
|
orm.updatedAt = props.updatedAt;
|
||||||
|
|
||||||
@ -50,6 +52,7 @@ export class UserOrmMapper {
|
|||||||
isEmailVerified: orm.isEmailVerified,
|
isEmailVerified: orm.isEmailVerified,
|
||||||
isActive: orm.isActive,
|
isActive: orm.isActive,
|
||||||
lastLoginAt: orm.lastLoginAt || undefined,
|
lastLoginAt: orm.lastLoginAt || undefined,
|
||||||
|
preferredLanguage: toLocale(orm.preferredLanguage),
|
||||||
createdAt: orm.createdAt,
|
createdAt: orm.createdAt,
|
||||||
updatedAt: orm.updatedAt,
|
updatedAt: orm.updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -38,15 +38,9 @@ export class CreateApiKeysTable1741000000001 implements MigrationInterface {
|
|||||||
await queryRunner.query(
|
await queryRunner.query(
|
||||||
`CREATE INDEX "idx_api_keys_organization_id" ON "api_keys" ("organization_id")`
|
`CREATE INDEX "idx_api_keys_organization_id" ON "api_keys" ("organization_id")`
|
||||||
);
|
);
|
||||||
await queryRunner.query(
|
await queryRunner.query(`CREATE INDEX "idx_api_keys_user_id" ON "api_keys" ("user_id")`);
|
||||||
`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_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(
|
await queryRunner.query(
|
||||||
`COMMENT ON TABLE "api_keys" IS 'API keys for programmatic access — GOLD and PLATINIUM plans only'`
|
`COMMENT ON TABLE "api_keys" IS 'API keys for programmatic access — GOLD and PLATINIUM plans only'`
|
||||||
|
|||||||
@ -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"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,14 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe, VersioningType } from '@nestjs/common';
|
import { VersioningType } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { I18nService, I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import compression from 'compression';
|
import compression from 'compression';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
import { helmetConfig, corsConfig } from './infrastructure/security/security.config';
|
import { helmetConfig, corsConfig } from './infrastructure/security/security.config';
|
||||||
|
import { DomainExceptionFilter } from './application/filters/domain-exception.filter';
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
@ -42,9 +44,9 @@ async function bootstrap() {
|
|||||||
type: VersioningType.URI,
|
type: VersioningType.URI,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global validation pipe
|
// Global validation pipe — i18n-aware (messages translated to caller locale)
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new I18nValidationPipe({
|
||||||
whitelist: true,
|
whitelist: true,
|
||||||
forbidNonWhitelisted: true,
|
forbidNonWhitelisted: true,
|
||||||
transform: 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 ────────────────────────────────────────────────
|
// ─── Swagger documentation ────────────────────────────────────────────────
|
||||||
const swaggerUser = configService.get<string>('SWAGGER_USERNAME');
|
const swaggerUser = configService.get<string>('SWAGGER_USERNAME');
|
||||||
const swaggerPass = configService.get<string>('SWAGGER_PASSWORD');
|
const swaggerPass = configService.get<string>('SWAGGER_PASSWORD');
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef } from 'react';
|
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 { motion, useInView } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Ship,
|
Ship,
|
||||||
@ -13,10 +14,42 @@ import {
|
|||||||
Linkedin,
|
Linkedin,
|
||||||
Calendar,
|
Calendar,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
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() {
|
export default function AboutPage() {
|
||||||
|
const t = useTranslations('marketing.about');
|
||||||
const heroRef = useRef(null);
|
const heroRef = useRef(null);
|
||||||
const missionRef = useRef(null);
|
const missionRef = useRef(null);
|
||||||
const valuesRef = useRef(null);
|
const valuesRef = useRef(null);
|
||||||
@ -31,117 +64,6 @@ export default function AboutPage() {
|
|||||||
const isTimelineInView = useInView(timelineRef, { once: true });
|
const isTimelineInView = useInView(timelineRef, { once: true });
|
||||||
const isStatsInView = useInView(statsRef, { 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 = {
|
const containerVariants = {
|
||||||
hidden: { opacity: 0, y: 50 },
|
hidden: { opacity: 0, y: 50 },
|
||||||
visible: {
|
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"
|
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" />
|
<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>
|
</motion.div>
|
||||||
|
|
||||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||||
Révolutionner le fret maritime,
|
{t('title1')}
|
||||||
<br />
|
<br />
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
<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>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
|
<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
|
{t('intro')}
|
||||||
qu'une réservation de vol. Nous connectons les transitaires du monde entier avec les plus
|
|
||||||
grandes compagnies maritimes.
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</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">
|
<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" />
|
<Target className="w-8 h-8 text-white" />
|
||||||
</div>
|
</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">
|
<p className="text-gray-600 text-lg leading-relaxed">
|
||||||
Démocratiser l'accès au fret maritime en offrant une plateforme technologique de pointe
|
{t('mission.body')}
|
||||||
qui simplifie la recherche, la comparaison et la réservation de transport maritime pour
|
|
||||||
tous les professionnels de la logistique.
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</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">
|
<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" />
|
<Eye className="w-8 h-8 text-white" />
|
||||||
</div>
|
</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">
|
<p className="text-gray-600 text-lg leading-relaxed">
|
||||||
Devenir la référence mondiale du fret maritime digital, en connectant chaque transitaire
|
{t('vision.body')}
|
||||||
à chaque compagnie maritime, partout dans le monde, avec la transparence et l'efficacité
|
|
||||||
que mérite le commerce international.
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -269,9 +185,9 @@ export default function AboutPage() {
|
|||||||
className="max-w-7xl mx-auto px-6 lg:px-8"
|
className="max-w-7xl mx-auto px-6 lg:px-8"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
{stats.map((stat, index) => (
|
{STATS.map((stat, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={stat.key}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="text-center"
|
className="text-center"
|
||||||
>
|
>
|
||||||
@ -283,7 +199,7 @@ export default function AboutPage() {
|
|||||||
>
|
>
|
||||||
{stat.value}
|
{stat.value}
|
||||||
</motion.div>
|
</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>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -299,9 +215,9 @@ export default function AboutPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="text-center mb-16"
|
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">
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||||
Les principes qui guident chacune de nos décisions
|
{t('valuesSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -311,11 +227,11 @@ export default function AboutPage() {
|
|||||||
animate={isValuesInView ? 'visible' : 'hidden'}
|
animate={isValuesInView ? 'visible' : 'hidden'}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
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;
|
const IconComponent = value.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={value.key}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
whileHover={{ y: -10 }}
|
whileHover={{ y: -10 }}
|
||||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
|
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" />
|
<IconComponent className="w-7 h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-brand-navy mb-3">{value.title}</h3>
|
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`values.${value.key}.title`)}</h3>
|
||||||
<p className="text-gray-600">{value.description}</p>
|
<p className="text-gray-600">{t(`values.${value.key}.description`)}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -343,14 +259,13 @@ export default function AboutPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="text-center mb-16"
|
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">
|
<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>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="relative">
|
<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">
|
<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
|
<motion.div
|
||||||
initial={{ scaleY: 0 }}
|
initial={{ scaleY: 0 }}
|
||||||
@ -362,9 +277,9 @@ export default function AboutPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
{timeline.map((item, index) => (
|
{TIMELINE_YEARS.map((year, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={year}
|
||||||
initial={{ opacity: 0, x: index % 2 === 0 ? -64 : 64 }}
|
initial={{ opacity: 0, x: index % 2 === 0 ? -64 : 64 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
viewport={{ once: true, amount: 0.4 }}
|
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="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' : ''}`}>
|
<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" />
|
<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>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-brand-navy mb-2">{item.title}</h3>
|
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`timeline.${year}.title`)}</h3>
|
||||||
<p className="text-gray-600">{item.description}</p>
|
<p className="text-gray-600">{t(`timeline.${year}.description`)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Animated center dot */}
|
|
||||||
<div className="hidden lg:flex items-center justify-center mx-4 flex-shrink-0">
|
<div className="hidden lg:flex items-center justify-center mx-4 flex-shrink-0">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0 }}
|
initial={{ scale: 0 }}
|
||||||
@ -402,7 +316,7 @@ export default function AboutPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Team 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">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
@ -410,9 +324,9 @@ export default function AboutPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="text-center mb-16"
|
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">
|
<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>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -422,9 +336,9 @@ export default function AboutPage() {
|
|||||||
animate={isTeamInView ? 'visible' : 'hidden'}
|
animate={isTeamInView ? 'visible' : 'hidden'}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||||
>
|
>
|
||||||
{team.map((member, index) => (
|
{TEAM.map((member) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={member.key}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
whileHover={{ y: -10 }}
|
whileHover={{ y: -10 }}
|
||||||
className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden group"
|
className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden group"
|
||||||
@ -444,8 +358,8 @@ export default function AboutPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h3 className="text-xl font-bold text-brand-navy mb-1">{member.name}</h3>
|
<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-brand-turquoise font-medium mb-3">{t(`team.${member.key}.role`)}</p>
|
||||||
<p className="text-gray-600 text-sm">{member.bio}</p>
|
<p className="text-gray-600 text-sm">{t(`team.${member.key}.bio`)}</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
@ -463,25 +377,24 @@ export default function AboutPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||||
Rejoignez l'aventure Xpeditis
|
{t('cta.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-white/80 mb-10">
|
<p className="text-xl text-white/80 mb-10">
|
||||||
Que vous soyez transitaire à la recherche d'une solution moderne ou talent souhaitant
|
{t('cta.body')}
|
||||||
rejoindre une équipe passionnée, nous avons hâte de vous rencontrer.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
|
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
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"
|
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" />
|
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/careers"
|
href="/careers"
|
||||||
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
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 { motion, useInView } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Ship,
|
Ship,
|
||||||
@ -15,11 +16,34 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
FileText,
|
FileText,
|
||||||
Anchor,
|
Anchor,
|
||||||
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
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() {
|
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 [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
const heroRef = useRef(null);
|
const heroRef = useRef(null);
|
||||||
@ -30,121 +54,14 @@ export default function BlogPage() {
|
|||||||
const isArticlesInView = useInView(articlesRef, { once: true });
|
const isArticlesInView = useInView(articlesRef, { once: true });
|
||||||
const isCategoriesInView = useInView(categoriesRef, { once: true });
|
const isCategoriesInView = useInView(categoriesRef, { once: true });
|
||||||
|
|
||||||
const categories = [
|
const filteredArticles = ARTICLES.filter((article) => {
|
||||||
{ 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 categoryMatch = selectedCategory === 'all' || article.category === selectedCategory;
|
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 =
|
const searchMatch =
|
||||||
searchQuery === '' ||
|
searchQuery === '' ||
|
||||||
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
article.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
|
excerpt.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
return categoryMatch && searchMatch;
|
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"
|
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" />
|
<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>
|
</motion.div>
|
||||||
|
|
||||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||||
Actualités & Insights
|
{t('title1')}
|
||||||
<br />
|
<br />
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||||
du fret maritime
|
{t('title2')}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
|
<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
|
{t('intro')}
|
||||||
pratiques et suivez l'actualité de Xpeditis.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* 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" />
|
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Rechercher un article..."
|
placeholder={t('searchPlaceholder')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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"
|
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"
|
className="max-w-7xl mx-auto px-6 lg:px-8"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||||
{categories.map((category) => {
|
{CATEGORIES.map((category) => {
|
||||||
const IconComponent = category.icon;
|
const IconComponent = category.icon;
|
||||||
const isActive = selectedCategory === category.value;
|
const isActive = selectedCategory === category.key;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={category.value}
|
key={category.key}
|
||||||
onClick={() => setSelectedCategory(category.value)}
|
onClick={() => setSelectedCategory(category.key)}
|
||||||
className={`flex items-center space-x-2 px-4 py-2 rounded-full transition-all ${
|
className={`flex items-center space-x-2 px-4 py-2 rounded-full transition-all ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-brand-turquoise text-white'
|
? 'bg-brand-turquoise text-white'
|
||||||
@ -265,7 +181,7 @@ export default function BlogPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<IconComponent className="w-4 h-4" />
|
<IconComponent className="w-4 h-4" />
|
||||||
<span className="font-medium">{category.label}</span>
|
<span className="font-medium">{t(`categories.${category.key}`)}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -282,7 +198,7 @@ export default function BlogPage() {
|
|||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.8 }}
|
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="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 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">
|
<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="max-w-2xl">
|
||||||
<div className="flex items-center space-x-2 mb-4">
|
<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">
|
<span className="px-3 py-1 bg-brand-turquoise text-white text-sm font-medium rounded-full">
|
||||||
À la une
|
{t('featuredBadge')}
|
||||||
</span>
|
</span>
|
||||||
<span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors">
|
<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>
|
</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-6 text-white/60 text-sm">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<User className="w-4 h-4" />
|
<User className="w-4 h-4" />
|
||||||
<span>{featuredArticle.author}</span>
|
<span>{t('featured.author')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
<span>{featuredArticle.date}</span>
|
<span>{t('featured.date')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
<span>{featuredArticle.readTime}</span>
|
<span>{t('featured.readTime')}</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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" />
|
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -342,15 +258,15 @@ export default function BlogPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="flex items-center justify-between mb-12"
|
className="flex items-center justify-between mb-12"
|
||||||
>
|
>
|
||||||
<h2 className="text-3xl font-bold text-brand-navy">Tous les articles</h2>
|
<h2 className="text-3xl font-bold text-brand-navy">{t('allTitle')}</h2>
|
||||||
<span className="text-gray-500">{filteredArticles.length} articles</span>
|
<span className="text-gray-500">{t('articlesCount', { count: filteredArticles.length })}</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{filteredArticles.length === 0 ? (
|
{filteredArticles.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
<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>
|
<h3 className="text-xl font-medium text-gray-600">{t('noResults.title')}</h3>
|
||||||
<p className="text-gray-500">Essayez de modifier vos filtres ou votre recherche</p>
|
<p className="text-gray-500">{t('noResults.body')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -367,17 +283,19 @@ export default function BlogPage() {
|
|||||||
<Ship className="w-16 h-16 text-brand-navy/20" />
|
<Ship className="w-16 h-16 text-brand-navy/20" />
|
||||||
<div className="absolute top-4 left-4">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 flex-1 flex flex-col">
|
<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">
|
<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>
|
</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">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
{article.tags.map((tag) => (
|
{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">
|
<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" />
|
<User className="w-4 h-4 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<span>{article.author}</span>
|
<span>{t(`articles.${article.key}.author` as any)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<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">
|
<span className="flex items-center space-x-1">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
<span>{article.readTime}</span>
|
<span>{t(`articles.${article.key}.readTime` as any)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -423,7 +341,7 @@ export default function BlogPage() {
|
|||||||
className="text-center mt-12"
|
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">
|
<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>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@ -440,28 +358,27 @@ export default function BlogPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl font-bold text-white mb-6">
|
<h2 className="text-4xl font-bold text-white mb-6">
|
||||||
Restez informé
|
{t('newsletter.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-white/80 mb-10">
|
<p className="text-xl text-white/80 mb-10">
|
||||||
Abonnez-vous à notre newsletter pour recevoir les derniers articles et actualités
|
{t('newsletter.body')}
|
||||||
du fret maritime directement dans votre boîte mail.
|
|
||||||
</p>
|
</p>
|
||||||
<form className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
<form className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||||
<input
|
<input
|
||||||
type="email"
|
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"
|
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
|
<button
|
||||||
type="submit"
|
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"
|
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" />
|
<ArrowRight className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p className="text-white/50 text-sm mt-4">
|
<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>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@ -8,21 +8,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
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';
|
import { acceptCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings';
|
||||||
|
|
||||||
export default function BookingConfirmPage() {
|
export default function BookingConfirmPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
|
||||||
const token = params.token as string;
|
const token = params.token as string;
|
||||||
|
const t = useTranslations('bookingPortal.confirm');
|
||||||
|
const tCommon = useTranslations('bookingPortal.common');
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [booking, setBooking] = useState<CsvBookingResponse | null>(null);
|
const [booking, setBooking] = useState<CsvBookingResponse | null>(null);
|
||||||
const [isAccepting, setIsAccepting] = useState(false);
|
|
||||||
|
|
||||||
const handleAccept = useCallback(async () => {
|
const handleAccept = useCallback(async () => {
|
||||||
setIsAccepting(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -33,31 +33,29 @@ export default function BookingConfirmPage() {
|
|||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} else {
|
} else {
|
||||||
setError('Une erreur est survenue lors de l\'acceptation');
|
setError(t('errorGeneric'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setIsAccepting(false);
|
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [token, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError('Token de confirmation invalide');
|
setError(t('tokenInvalid'));
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-accept the booking
|
|
||||||
handleAccept();
|
handleAccept();
|
||||||
}, [token, handleAccept]);
|
}, [token, handleAccept, t]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
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="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="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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -84,24 +82,24 @@ export default function BookingConfirmPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
Erreur de confirmation
|
{t('errorTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">{error}</p>
|
<p className="text-gray-600">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||||
<p className="text-sm text-red-800">
|
<p className="text-sm text-red-800">
|
||||||
<strong>Raisons possibles :</strong>
|
<strong>{t('errorReasonsTitle')}</strong>
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside">
|
<ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside">
|
||||||
<li>Le lien a expiré</li>
|
<li>{t('errorReason1')}</li>
|
||||||
<li>La demande a déjà été acceptée ou refusée</li>
|
<li>{t('errorReason2')}</li>
|
||||||
<li>Le token de confirmation est invalide</li>
|
<li>{t('errorReason3')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-gray-500 text-center">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -133,67 +131,68 @@ export default function BookingConfirmPage() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{/* Animated rings */}
|
|
||||||
<div className="absolute inset-0 rounded-full border-4 border-green-200 animate-ping opacity-20"></div>
|
<div className="absolute inset-0 rounded-full border-4 border-green-200 animate-ping opacity-20"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">
|
<h1 className="text-3xl font-bold text-gray-900 mb-3">
|
||||||
Demande acceptée !
|
{t('successTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-gray-600 mb-2">
|
<p className="text-lg text-gray-600 mb-2">
|
||||||
Merci d'avoir accepté cette demande de transport.
|
{t('successHeadline')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
Le client a été notifié par email.
|
{t('successBody')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Booking Summary */}
|
{/* Booking Summary */}
|
||||||
<div className="bg-gray-50 rounded-xl p-6 mb-6">
|
<div className="bg-gray-50 rounded-xl p-6 mb-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
Récapitulatif de la réservation
|
{t('summaryTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
<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>
|
<span className="font-semibold text-gray-900">{booking.bookingId}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
<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">
|
<span className="font-semibold text-gray-900">
|
||||||
{booking.origin} → {booking.destination}
|
{booking.origin} → {booking.destination}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
<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>
|
<span className="font-semibold text-gray-900">{booking.volumeCBM} CBM</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
<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>
|
<span className="font-semibold text-gray-900">{booking.weightKG} kg</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
<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>
|
<span className="font-semibold text-gray-900">{booking.palletCount}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
<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>
|
<span className="font-semibold text-gray-900">{booking.containerType}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
<div className="flex justify-between py-2 border-b border-gray-200">
|
||||||
<span className="text-gray-600">Temps de transit</span>
|
<span className="text-gray-600">{t('labels.transitDays')}</span>
|
||||||
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
|
<span className="font-semibold text-gray-900">
|
||||||
|
{t('transitDaysValue', { count: booking.transitDays })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between py-3">
|
<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="text-right">
|
||||||
<div className="font-bold text-xl text-green-600">
|
<div className="font-bold text-xl text-green-600">
|
||||||
{booking.primaryCurrency === 'USD'
|
{booking.primaryCurrency === 'USD'
|
||||||
@ -213,7 +212,7 @@ export default function BookingConfirmPage() {
|
|||||||
|
|
||||||
{booking.notes && (
|
{booking.notes && (
|
||||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
<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>
|
<p className="text-gray-800">{booking.notes}</p>
|
||||||
</div>
|
</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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Prochaines étapes
|
{t('nextStepsTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
|
<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>{t('nextStep1')}</li>
|
||||||
<li>Vous recevrez un email avec les documents nécessaires</li>
|
<li>{t('nextStep2')}</li>
|
||||||
<li>Le paiement sera traité selon vos conditions habituelles</li>
|
<li>{t('nextStep3')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Documents Section */}
|
{/* Documents Section */}
|
||||||
{booking.documents && booking.documents.length > 0 && (
|
{booking.documents && booking.documents.length > 0 && (
|
||||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
<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">
|
<div className="space-y-2">
|
||||||
{booking.documents.map((doc, index) => (
|
{booking.documents.map((doc, index) => (
|
||||||
<div key={index} className="flex items-center justify-between p-3 bg-white rounded border border-gray-200">
|
<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"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||||
>
|
>
|
||||||
Télécharger
|
{t('labels.download')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -266,7 +265,7 @@ export default function BookingConfirmPage() {
|
|||||||
|
|
||||||
{/* Contact Info */}
|
{/* Contact Info */}
|
||||||
<div className="text-center text-sm text-gray-500">
|
<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">
|
<a href="mailto:support@xpeditis.com" className="text-blue-600 hover:underline">
|
||||||
support@xpeditis.com
|
support@xpeditis.com
|
||||||
</a>
|
</a>
|
||||||
@ -9,11 +9,14 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { rejectCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings';
|
import { rejectCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings';
|
||||||
|
|
||||||
export default function BookingRejectPage() {
|
export default function BookingRejectPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const token = params.token as string;
|
const token = params.token as string;
|
||||||
|
const t = useTranslations('bookingPortal.reject');
|
||||||
|
const tCommon = useTranslations('bookingPortal.common');
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -25,14 +28,13 @@ export default function BookingRejectPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError('Token de refus invalide');
|
setError(t('tokenInvalid'));
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Just validate the token exists, don't auto-reject
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, [token]);
|
}, [token, t]);
|
||||||
|
|
||||||
const handleReject = async () => {
|
const handleReject = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@ -49,7 +51,7 @@ export default function BookingRejectPage() {
|
|||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} else {
|
} else {
|
||||||
setError('Une erreur est survenue lors du refus');
|
setError(t('errorGeneric'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsRejecting(false);
|
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="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="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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -88,36 +90,34 @@ export default function BookingRejectPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
Erreur de refus
|
{t('errorTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">{error}</p>
|
<p className="text-gray-600">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||||
<p className="text-sm text-red-800">
|
<p className="text-sm text-red-800">
|
||||||
<strong>Raisons possibles :</strong>
|
<strong>{t('errorReasonsTitle')}</strong>
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside">
|
<ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside">
|
||||||
<li>Le lien a expiré</li>
|
<li>{t('errorReason1')}</li>
|
||||||
<li>La demande a déjà été acceptée ou refusée</li>
|
<li>{t('errorReason2')}</li>
|
||||||
<li>Le token est invalide</li>
|
<li>{t('errorReason3')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-gray-500 text-center">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// After successful rejection
|
|
||||||
if (hasRejected && booking) {
|
if (hasRejected && booking) {
|
||||||
return (
|
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="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">
|
<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="text-center mb-8">
|
||||||
<div className="relative inline-block">
|
<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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">
|
<h1 className="text-3xl font-bold text-gray-900 mb-3">
|
||||||
Demande refusée
|
{t('rejectedTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-gray-600 mb-2">
|
<p className="text-lg text-gray-600 mb-2">
|
||||||
Vous avez refusé cette demande de transport.
|
{t('rejectedHeadline')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
Le client a été notifié par email.
|
{t('rejectedBody')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Booking Summary */}
|
|
||||||
<div className="bg-gray-50 rounded-xl p-6 mb-6">
|
<div className="bg-gray-50 rounded-xl p-6 mb-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
Récapitulatif de la demande refusée
|
{t('summaryTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
<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>
|
<span className="font-semibold text-gray-900">{booking.bookingId}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
<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">
|
<span className="font-semibold text-gray-900">
|
||||||
{booking.origin} → {booking.destination}
|
{booking.origin} → {booking.destination}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
<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>
|
<span className="font-semibold text-gray-900">{booking.volumeCBM} CBM</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
<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>
|
<span className="font-semibold text-gray-900">{booking.weightKG} kg</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between py-2">
|
<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">
|
<span className="font-semibold text-gray-900">
|
||||||
{booking.primaryCurrency === 'USD'
|
{booking.primaryCurrency === 'USD'
|
||||||
? `$${booking.priceUSD.toLocaleString()}`
|
? `$${booking.priceUSD.toLocaleString()}`
|
||||||
@ -190,7 +189,7 @@ export default function BookingRejectPage() {
|
|||||||
|
|
||||||
{reason && (
|
{reason && (
|
||||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
<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">
|
<p className="text-gray-800 bg-white p-3 rounded border border-gray-200">
|
||||||
{reason}
|
{reason}
|
||||||
</p>
|
</p>
|
||||||
@ -198,22 +197,20 @@ export default function BookingRejectPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Message */}
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
<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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Information
|
{t('infoTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-blue-800">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact Info */}
|
|
||||||
<div className="text-center text-sm text-gray-500">
|
<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">
|
<a href="mailto:support@xpeditis.com" className="text-blue-600 hover:underline">
|
||||||
support@xpeditis.com
|
support@xpeditis.com
|
||||||
</a>
|
</a>
|
||||||
@ -243,11 +240,9 @@ export default function BookingRejectPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial rejection form
|
|
||||||
return (
|
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="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">
|
<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="text-center mb-6">
|
||||||
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<svg
|
<svg
|
||||||
@ -265,14 +260,13 @@ export default function BookingRejectPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
Refuser cette demande
|
{t('formTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Vous êtes sur le point de refuser cette demande de transport.
|
{t('formIntro')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optional Reason Field */}
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
{!showReasonField ? (
|
{!showReasonField ? (
|
||||||
<button
|
<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"
|
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">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -289,20 +283,20 @@ export default function BookingRejectPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="reason" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="reason" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Raison du refus (optionnel)
|
{t('reasonLabel')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="reason"
|
id="reason"
|
||||||
rows={4}
|
rows={4}
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={(e) => setReason(e.target.value)}
|
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"
|
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}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 flex items-center justify-between">
|
<div className="mt-1 flex items-center justify-between">
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Cette information sera communiquée au client
|
{t('reasonHint')}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
{reason.length}/500
|
{reason.length}/500
|
||||||
@ -312,14 +306,12 @@ export default function BookingRejectPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Warning Message */}
|
|
||||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6">
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6">
|
||||||
<p className="text-sm text-orange-800">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleReject}
|
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>
|
<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>
|
<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>
|
</svg>
|
||||||
Refus en cours...
|
{t('submitting')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
Confirmer le refus
|
{t('submit')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -348,13 +340,12 @@ export default function BookingRejectPage() {
|
|||||||
href="mailto:support@xpeditis.com"
|
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"
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Help Text */}
|
|
||||||
<p className="mt-6 text-xs text-center text-gray-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
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 { motion, useInView, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Briefcase,
|
Briefcase,
|
||||||
@ -22,12 +23,63 @@ import {
|
|||||||
LineChart,
|
LineChart,
|
||||||
Headphones,
|
Headphones,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
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() {
|
export default function CareersPage() {
|
||||||
const [selectedDepartment, setSelectedDepartment] = useState('all');
|
const t = useTranslations('marketing.careers');
|
||||||
const [selectedLocation, setSelectedLocation] = useState('all');
|
const [selectedDepartment, setSelectedDepartment] = useState<DepartmentValue>('all');
|
||||||
|
const [selectedLocation, setSelectedLocation] = useState<LocationValue>('all');
|
||||||
const [expandedJob, setExpandedJob] = useState<number | null>(null);
|
const [expandedJob, setExpandedJob] = useState<number | null>(null);
|
||||||
|
|
||||||
const heroRef = useRef(null);
|
const heroRef = useRef(null);
|
||||||
@ -40,161 +92,7 @@ export default function CareersPage() {
|
|||||||
const isJobsInView = useInView(jobsRef, { once: true });
|
const isJobsInView = useInView(jobsRef, { once: true });
|
||||||
const isCultureInView = useInView(cultureRef, { once: true });
|
const isCultureInView = useInView(cultureRef, { once: true });
|
||||||
|
|
||||||
const benefits = [
|
const filteredJobs = JOBS.filter((job) => {
|
||||||
{
|
|
||||||
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 departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment;
|
const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment;
|
||||||
const locationMatch = selectedLocation === 'all' || job.location === selectedLocation;
|
const locationMatch = selectedLocation === 'all' || job.location === selectedLocation;
|
||||||
return departmentMatch && locationMatch;
|
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"
|
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" />
|
<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>
|
</motion.div>
|
||||||
|
|
||||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||||
Construisons ensemble
|
{t('title1')}
|
||||||
<br />
|
<br />
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||||
le futur du maritime
|
{t('title2')}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
|
<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,
|
{t('intro')}
|
||||||
une culture bienveillante et des opportunités de croissance uniques vous attendent.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
|
<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"
|
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"
|
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" />
|
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||||
</a>
|
</a>
|
||||||
<Link
|
<Link
|
||||||
href="/about"
|
href="/about"
|
||||||
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -295,14 +192,9 @@ export default function CareersPage() {
|
|||||||
<section className="py-16 bg-gray-50">
|
<section className="py-16 bg-gray-50">
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
{[
|
{STATS.map((stat, index) => (
|
||||||
{ 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) => (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={stat.key}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
@ -310,7 +202,7 @@ export default function CareersPage() {
|
|||||||
className="text-center"
|
className="text-center"
|
||||||
>
|
>
|
||||||
<div className="text-5xl font-bold text-brand-turquoise mb-2">{stat.value}</div>
|
<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>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -327,10 +219,10 @@ export default function CareersPage() {
|
|||||||
className="text-center mb-16"
|
className="text-center mb-16"
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||||
Pourquoi nous rejoindre ?
|
{t('benefitsTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<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>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -340,11 +232,11 @@ export default function CareersPage() {
|
|||||||
animate={isBenefitsInView ? 'visible' : 'hidden'}
|
animate={isBenefitsInView ? 'visible' : 'hidden'}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
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;
|
const IconComponent = benefit.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={benefit.key}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
whileHover={{ y: -5 }}
|
whileHover={{ y: -5 }}
|
||||||
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
|
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">
|
<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" />
|
<IconComponent className="w-7 h-7 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-brand-navy mb-2">{benefit.title}</h3>
|
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`benefits.${benefit.key}.title`)}</h3>
|
||||||
<p className="text-gray-600">{benefit.description}</p>
|
<p className="text-gray-600">{t(`benefits.${benefit.key}.description`)}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -371,21 +263,15 @@ export default function CareersPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||||
Notre culture
|
{t('cultureTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-white/80 mb-8">
|
<p className="text-xl text-white/80 mb-8">
|
||||||
Chez Xpeditis, nous croyons que les meilleures idées viennent d'équipes diverses
|
{t('cultureBody')}
|
||||||
et inclusives. Nous valorisons l'autonomie, la créativité et le feedback constructif.
|
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
{[
|
{CULTURE_ITEMS.map((itemKey, index) => (
|
||||||
'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) => (
|
|
||||||
<motion.li
|
<motion.li
|
||||||
key={index}
|
key={itemKey}
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
animate={isCultureInView ? { opacity: 1, x: 0 } : {}}
|
animate={isCultureInView ? { opacity: 1, x: 0 } : {}}
|
||||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
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">
|
<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" />
|
<ChevronRight className="w-4 h-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span>{item}</span>
|
<span>{t(`culture.${itemKey}`)}</span>
|
||||||
</motion.li>
|
</motion.li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -429,10 +315,10 @@ export default function CareersPage() {
|
|||||||
className="text-center mb-12"
|
className="text-center mb-12"
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||||
Nos offres d'emploi
|
{t('jobsTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||||
Trouvez le poste qui correspond à vos ambitions
|
{t('jobsSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -446,12 +332,12 @@ export default function CareersPage() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
value={selectedDepartment}
|
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"
|
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) => (
|
{DEPARTMENT_VALUES.map((value) => (
|
||||||
<option key={dept.value} value={dept.value}>
|
<option key={value} value={value}>
|
||||||
{dept.label}
|
{value === 'all' ? t('filters.allDepartments') : t(`departments.${value}` as any)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -460,12 +346,12 @@ export default function CareersPage() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
value={selectedLocation}
|
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"
|
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) => (
|
{LOCATION_VALUES.map((value) => (
|
||||||
<option key={loc.value} value={loc.value}>
|
<option key={value} value={value}>
|
||||||
{loc.label}
|
{value === 'all' ? t('filters.allLocations') : t(`locations.${value}` as any)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -483,8 +369,8 @@ export default function CareersPage() {
|
|||||||
{filteredJobs.length === 0 ? (
|
{filteredJobs.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
<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>
|
<h3 className="text-xl font-medium text-gray-600">{t('noJobs.title')}</h3>
|
||||||
<p className="text-gray-500">Essayez de modifier vos filtres</p>
|
<p className="text-gray-500">{t('noJobs.body')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredJobs.map((job) => {
|
filteredJobs.map((job) => {
|
||||||
@ -507,15 +393,15 @@ export default function CareersPage() {
|
|||||||
<IconComponent className="w-6 h-6 text-brand-turquoise" />
|
<IconComponent className="w-6 h-6 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
|
||||||
<span className="flex items-center space-x-1">
|
<span className="flex items-center space-x-1">
|
||||||
<Building2 className="w-4 h-4" />
|
<Building2 className="w-4 h-4" />
|
||||||
<span>{job.department}</span>
|
<span>{t(`departments.${job.department}` as any)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center space-x-1">
|
<span className="flex items-center space-x-1">
|
||||||
<MapPin className="w-4 h-4" />
|
<MapPin className="w-4 h-4" />
|
||||||
<span>{job.location}</span>
|
<span>{t(`locations.${job.location}` as any)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center space-x-1">
|
<span className="flex items-center space-x-1">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
@ -528,7 +414,7 @@ export default function CareersPage() {
|
|||||||
<div className="hidden md:flex items-center space-x-2">
|
<div className="hidden md:flex items-center space-x-2">
|
||||||
{job.remote && (
|
{job.remote && (
|
||||||
<span className="px-3 py-1 bg-green-100 text-green-700 text-sm font-medium rounded-full">
|
<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>
|
||||||
)}
|
)}
|
||||||
<span className="px-3 py-1 bg-brand-turquoise/10 text-brand-turquoise text-sm font-medium rounded-full">
|
<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"
|
className="border-t border-gray-100"
|
||||||
>
|
>
|
||||||
<div className="p-6 bg-gray-50">
|
<div className="p-6 bg-gray-50">
|
||||||
<p className="text-gray-600 mb-6">{job.description}</p>
|
<p className="text-gray-600 mb-6">{t(`jobs.${job.key}.description`)}</p>
|
||||||
<h4 className="font-bold text-brand-navy mb-3">Profil recherché :</h4>
|
<h4 className="font-bold text-brand-navy mb-3">{t('jobCard.profile')}</h4>
|
||||||
<ul className="space-y-2 mb-6">
|
<ul className="space-y-2 mb-6">
|
||||||
{job.requirements.map((req, index) => (
|
{JOB_REQ_KEYS.map((reqKey) => (
|
||||||
<li key={index} className="flex items-start space-x-2 text-gray-600">
|
<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" />
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -569,11 +455,11 @@ export default function CareersPage() {
|
|||||||
href={`/careers/${job.id}`}
|
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"
|
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" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<button className="px-6 py-3 border border-gray-300 rounded-lg hover:border-brand-turquoise transition-all font-medium text-gray-700">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -598,17 +484,16 @@ export default function CareersPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl font-bold text-brand-navy mb-6">
|
<h2 className="text-4xl font-bold text-brand-navy mb-6">
|
||||||
Pas de poste correspondant ?
|
{t('cta.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 mb-10">
|
<p className="text-xl text-gray-600 mb-10">
|
||||||
Envoyez-nous une candidature spontanée ! Nous sommes toujours à la recherche de
|
{t('cta.body')}
|
||||||
talents passionnés pour rejoindre notre aventure.
|
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/contact"
|
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"
|
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" />
|
<ArrowRight className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -1,13 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
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';
|
import { CheckCircle, Loader2, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
export default function CarrierAcceptPage() {
|
export default function CarrierAcceptPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const token = params.token as string;
|
const token = params.token as string;
|
||||||
|
const t = useTranslations('carrierPortal');
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -18,20 +21,18 @@ export default function CarrierAcceptPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const acceptBooking = async () => {
|
const acceptBooking = async () => {
|
||||||
// Protection contre les doubles appels
|
|
||||||
if (hasCalledApi.current) {
|
if (hasCalledApi.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hasCalledApi.current = true;
|
hasCalledApi.current = true;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError('Token manquant');
|
setError(t('common.tokenMissing'));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Appeler l'API backend pour accepter le booking
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||||
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/accept/${token}`, {
|
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/accept/${token}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@ -45,17 +46,17 @@ export default function CarrierAcceptPage() {
|
|||||||
try {
|
try {
|
||||||
errorData = await response.json();
|
errorData = await response.json();
|
||||||
} catch (e) {
|
} 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')) {
|
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')) {
|
} 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')) {
|
} 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);
|
throw new Error(errorMessage);
|
||||||
@ -63,7 +64,6 @@ export default function CarrierAcceptPage() {
|
|||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
// Démarrer le compte à rebours
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setCountdown((prev) => {
|
setCountdown((prev) => {
|
||||||
if (prev <= 1) {
|
if (prev <= 1) {
|
||||||
@ -78,13 +78,13 @@ export default function CarrierAcceptPage() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error accepting booking:', 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);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
acceptBooking();
|
acceptBooking();
|
||||||
}, [token, router]);
|
}, [token, router, t]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
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">
|
<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" />
|
<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">
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||||
Traitement en cours...
|
{t('accept.loadingTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Nous traitons votre acceptation.
|
{t('accept.loadingMessage')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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="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">
|
<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" />
|
<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>
|
<p className="text-gray-600 mb-6">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/')}
|
onClick={() => router.push('/')}
|
||||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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" />
|
<CheckCircle className="w-20 h-20 text-green-500 mx-auto mb-6" />
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
Merci !
|
{t('accept.thanksTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6 mb-6">
|
<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">
|
<p className="text-green-800 font-medium text-lg mb-2">
|
||||||
✅ Votre acceptation a bien été prise en compte
|
{t('accept.successHeadline')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-green-700 text-sm">
|
<p className="text-green-700 text-sm">
|
||||||
Nous vous remercions d'avoir accepté cette demande de transport.
|
{t('accept.successBody')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-500 text-sm mb-4">
|
<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>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/')}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
@ -55,13 +56,13 @@ interface AccessRequirements {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentTypeLabels: Record<string, string> = {
|
const DOCUMENT_TYPE_KEYS = [
|
||||||
BILL_OF_LADING: 'Connaissement',
|
'BILL_OF_LADING',
|
||||||
PACKING_LIST: 'Liste de colisage',
|
'PACKING_LIST',
|
||||||
COMMERCIAL_INVOICE: 'Facture commerciale',
|
'COMMERCIAL_INVOICE',
|
||||||
CERTIFICATE_OF_ORIGIN: "Certificat d'origine",
|
'CERTIFICATE_OF_ORIGIN',
|
||||||
OTHER: 'Autre document',
|
'OTHER',
|
||||||
};
|
] as const;
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
const formatFileSize = (bytes: number): string => {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@ -80,6 +81,7 @@ const getFileIcon = (mimeType: string) => {
|
|||||||
export default function CarrierDocumentsPage() {
|
export default function CarrierDocumentsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const token = params.token as string;
|
const token = params.token as string;
|
||||||
|
const t = useTranslations('carrierPortal.documents');
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -96,10 +98,17 @@ export default function CarrierDocumentsPage() {
|
|||||||
const hasCalledApi = useRef(false);
|
const hasCalledApi = useRef(false);
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
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
|
// Check access requirements first
|
||||||
const checkRequirements = async () => {
|
const checkRequirements = async () => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError('Lien invalide');
|
setError(t('linkInvalid'));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -120,13 +129,13 @@ export default function CarrierDocumentsPage() {
|
|||||||
try {
|
try {
|
||||||
errorData = await response.json();
|
errorData = await response.json();
|
||||||
} catch {
|
} 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')) {
|
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);
|
throw new Error(errorMessage);
|
||||||
@ -135,16 +144,12 @@ export default function CarrierDocumentsPage() {
|
|||||||
const reqData: AccessRequirements = await response.json();
|
const reqData: AccessRequirements = await response.json();
|
||||||
setRequirements(reqData);
|
setRequirements(reqData);
|
||||||
|
|
||||||
// If booking is not accepted yet
|
|
||||||
if (reqData.status !== 'ACCEPTED') {
|
if (reqData.status !== 'ACCEPTED') {
|
||||||
setError(
|
setError(t('notAcceptedYet'));
|
||||||
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
|
|
||||||
);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no password required, fetch documents directly
|
|
||||||
if (!reqData.requiresPassword) {
|
if (!reqData.requiresPassword) {
|
||||||
await fetchDocumentsWithoutPassword();
|
await fetchDocumentsWithoutPassword();
|
||||||
} else {
|
} else {
|
||||||
@ -152,7 +157,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error checking requirements:', 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);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -172,22 +177,19 @@ export default function CarrierDocumentsPage() {
|
|||||||
try {
|
try {
|
||||||
errorData = await response.json();
|
errorData = await response.json();
|
||||||
} catch {
|
} 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 (
|
if (
|
||||||
errorMessage.includes('pas encore été acceptée') ||
|
errorMessage.includes('pas encore été acceptée') ||
|
||||||
errorMessage.includes('not accepted')
|
errorMessage.includes('not accepted')
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(t('notAcceptedYet'));
|
||||||
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
|
|
||||||
);
|
|
||||||
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
|
} 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')) {
|
} else if (errorMessage.includes('Mot de passe requis') || errorMessage.includes('required')) {
|
||||||
// Password is now required, show the form
|
|
||||||
setRequirements({ requiresPassword: true, status: 'ACCEPTED' });
|
setRequirements({ requiresPassword: true, status: 'ACCEPTED' });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@ -201,7 +203,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching documents:', 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);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -225,17 +227,17 @@ export default function CarrierDocumentsPage() {
|
|||||||
try {
|
try {
|
||||||
errorData = await response.json();
|
errorData = await response.json();
|
||||||
} catch {
|
} 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 (
|
if (
|
||||||
response.status === 401 ||
|
response.status === 401 ||
|
||||||
errorMessage.includes('incorrect') ||
|
errorMessage.includes('incorrect') ||
|
||||||
errorMessage.includes('invalid')
|
errorMessage.includes('invalid')
|
||||||
) {
|
) {
|
||||||
setPasswordError('Mot de passe incorrect. Vérifiez votre email pour retrouver le mot de passe.');
|
setPasswordError(t('passwordIncorrect'));
|
||||||
setVerifying(false);
|
setVerifying(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -248,7 +250,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
setVerifying(false);
|
setVerifying(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error verifying password:', 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);
|
setVerifying(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -262,7 +264,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
const handlePasswordSubmit = (e: React.FormEvent) => {
|
const handlePasswordSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!password.trim()) {
|
if (!password.trim()) {
|
||||||
setPasswordError('Veuillez entrer le mot de passe');
|
setPasswordError(t('passwordMissing'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetchDocumentsWithPassword(password.trim());
|
fetchDocumentsWithPassword(password.trim());
|
||||||
@ -272,13 +274,11 @@ export default function CarrierDocumentsPage() {
|
|||||||
setDownloading(doc.id);
|
setDownloading(doc.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// The downloadUrl is already a signed URL, open it directly
|
|
||||||
window.open(doc.downloadUrl, '_blank');
|
window.open(doc.downloadUrl, '_blank');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error downloading document:', err);
|
console.error('Error downloading document:', err);
|
||||||
alert('Erreur lors du téléchargement. Veuillez réessayer.');
|
alert(t('downloadError'));
|
||||||
} finally {
|
} finally {
|
||||||
// Small delay to show loading state
|
|
||||||
setTimeout(() => setDownloading(null), 500);
|
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="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">
|
<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" />
|
<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>
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('loading')}</h1>
|
||||||
<p className="text-gray-600">Veuillez patienter</p>
|
<p className="text-gray-600">{t('loadingHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
</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="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">
|
<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" />
|
<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>
|
<p className="text-gray-600 mb-6">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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" />
|
<Lock className="w-8 h-8 text-brand-turquoise" />
|
||||||
</div>
|
</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">
|
<p className="text-gray-600">
|
||||||
Cette page est protégée. Entrez le mot de passe reçu par email pour accéder aux
|
{t('password.intro')}
|
||||||
documents.
|
|
||||||
</p>
|
</p>
|
||||||
{requirements.bookingNumber && (
|
{requirements.bookingNumber && (
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -350,7 +349,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Mot de passe
|
{t('password.passwordLabel')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@ -358,7 +357,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
id="password"
|
id="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value.toUpperCase())}
|
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"
|
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"
|
autoComplete="off"
|
||||||
autoFocus
|
autoFocus
|
||||||
@ -387,12 +386,12 @@ export default function CarrierDocumentsPage() {
|
|||||||
{verifying ? (
|
{verifying ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
Vérification...
|
{t('password.verifying')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Lock className="w-5 h-5" />
|
<Lock className="w-5 h-5" />
|
||||||
Accéder aux documents
|
{t('password.submit')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -400,10 +399,9 @@ export default function CarrierDocumentsPage() {
|
|||||||
|
|
||||||
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||||
<p className="text-sm text-amber-800">
|
<p className="text-sm text-amber-800">
|
||||||
<strong>Où trouver le mot de passe ?</strong>
|
<strong>{t('password.helpTitle')}</strong>
|
||||||
<br />
|
<br />
|
||||||
Le mot de passe vous a été envoyé dans l'email de confirmation de la réservation. Il
|
{t('password.helpBody')}
|
||||||
correspond aux 6 derniers caractères du numéro de devis.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -433,7 +431,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
className="text-sm text-brand-turquoise hover:text-brand-navy font-medium"
|
className="text-sm text-brand-turquoise hover:text-brand-navy font-medium"
|
||||||
>
|
>
|
||||||
Actualiser
|
{t('header.refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -449,7 +447,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{booking.bookingNumber && (
|
{booking.bookingNumber && (
|
||||||
<p className="text-center text-white/70 text-sm mt-1">
|
<p className="text-center text-white/70 text-sm mt-1">
|
||||||
N° {booking.bookingNumber}
|
{t('summary.bookingNumberPrefix')} {booking.bookingNumber}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -458,33 +456,33 @@ export default function CarrierDocumentsPage() {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||||
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
<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>
|
<p className="font-semibold text-gray-900">{booking.volumeCBM} CBM</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||||
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
<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>
|
<p className="font-semibold text-gray-900">{booking.weightKG} kg</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||||
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
||||||
<p className="text-xs text-gray-500">Transit</p>
|
<p className="text-xs text-gray-500">{t('summary.transit')}</p>
|
||||||
<p className="font-semibold text-gray-900">{booking.transitDays} jours</p>
|
<p className="font-semibold text-gray-900">{t('summary.transitDays', { count: booking.transitDays })}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||||
<Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
<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>
|
<p className="font-semibold text-gray-900">{booking.containerType}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
||||||
<span className="text-gray-500">
|
<span className="text-gray-500">
|
||||||
Transporteur:{' '}
|
{t('summary.carrierLabel')}{' '}
|
||||||
<span className="text-gray-900 font-medium">{booking.carrierName}</span>
|
<span className="text-gray-900 font-medium">{booking.carrierName}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500">
|
<span className="text-gray-500">
|
||||||
Ref:{' '}
|
{t('summary.refLabel')}{' '}
|
||||||
<span className="font-mono text-gray-900">
|
<span className="font-mono text-gray-900">
|
||||||
{booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()}
|
{booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
@ -498,16 +496,16 @@ export default function CarrierDocumentsPage() {
|
|||||||
<div className="px-6 py-4 border-b border-gray-100">
|
<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">
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
<FileText className="w-5 h-5 text-brand-turquoise" />
|
<FileText className="w-5 h-5 text-brand-turquoise" />
|
||||||
Documents ({documents.length})
|
{t('list.heading', { count: documents.length })}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{documents.length === 0 ? (
|
{documents.length === 0 ? (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
<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">
|
<p className="text-gray-500 text-sm mt-1">
|
||||||
Les documents apparaîtront ici une fois ajoutés.
|
{t('list.emptyHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -523,7 +521,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
<p className="font-medium text-gray-900">{doc.fileName}</p>
|
<p className="font-medium text-gray-900">{doc.fileName}</p>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<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">
|
<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>
|
||||||
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
|
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -543,7 +541,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
<span>Télécharger</span>
|
<span>{t('list.download')}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -555,13 +553,13 @@ export default function CarrierDocumentsPage() {
|
|||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<p className="mt-6 text-center text-sm text-gray-500">
|
<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>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="mt-auto py-6 text-center text-sm text-brand-navy/50">
|
<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>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -1,13 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
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';
|
import { XCircle, Loader2, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
export default function CarrierRejectPage() {
|
export default function CarrierRejectPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const token = params.token as string;
|
const token = params.token as string;
|
||||||
|
const t = useTranslations('carrierPortal');
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -18,20 +21,18 @@ export default function CarrierRejectPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rejectBooking = async () => {
|
const rejectBooking = async () => {
|
||||||
// Protection contre les doubles appels
|
|
||||||
if (hasCalledApi.current) {
|
if (hasCalledApi.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hasCalledApi.current = true;
|
hasCalledApi.current = true;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError('Token manquant');
|
setError(t('common.tokenMissing'));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Appeler l'API backend pour refuser le booking
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||||
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/reject/${token}`, {
|
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/reject/${token}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@ -45,17 +46,17 @@ export default function CarrierRejectPage() {
|
|||||||
try {
|
try {
|
||||||
errorData = await response.json();
|
errorData = await response.json();
|
||||||
} catch (e) {
|
} 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')) {
|
if (errorMessage.includes('status REJECTED')) {
|
||||||
errorMessage = 'Ce booking a déjà été refusé.';
|
errorMessage = t('common.bookingAlreadyRejected');
|
||||||
} else if (errorMessage.includes('status ACCEPTED')) {
|
} 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')) {
|
} 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);
|
throw new Error(errorMessage);
|
||||||
@ -63,7 +64,6 @@ export default function CarrierRejectPage() {
|
|||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
// Démarrer le compte à rebours
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setCountdown((prev) => {
|
setCountdown((prev) => {
|
||||||
if (prev <= 1) {
|
if (prev <= 1) {
|
||||||
@ -78,13 +78,13 @@ export default function CarrierRejectPage() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error rejecting booking:', 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);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
rejectBooking();
|
rejectBooking();
|
||||||
}, [token, router]);
|
}, [token, router, t]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
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">
|
<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" />
|
<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">
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||||
Traitement en cours...
|
{t('reject.loadingTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Nous traitons votre refus.
|
{t('reject.loadingMessage')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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="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">
|
<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" />
|
<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>
|
<p className="text-gray-600 mb-6">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/')}
|
onClick={() => router.push('/')}
|
||||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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" />
|
<CheckCircle className="w-20 h-20 text-orange-500 mx-auto mb-6" />
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
Merci de votre réponse
|
{t('reject.thanksTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-6 mb-6">
|
<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">
|
<p className="text-orange-800 font-medium text-lg mb-2">
|
||||||
✓ Votre refus a bien été pris en compte
|
{t('reject.successHeadline')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-orange-700 text-sm">
|
<p className="text-orange-700 text-sm">
|
||||||
Nous avons bien enregistré votre décision concernant cette demande de transport.
|
{t('reject.successBody')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-500 text-sm mb-4">
|
<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>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/')}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { motion, useInView } from 'framer-motion';
|
import { motion, useInView } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Shield,
|
Shield,
|
||||||
@ -15,86 +16,41 @@ import {
|
|||||||
Edit,
|
Edit,
|
||||||
Eye,
|
Eye,
|
||||||
Mail,
|
Mail,
|
||||||
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
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() {
|
export default function CompliancePage() {
|
||||||
|
const t = useTranslations('marketing.compliance');
|
||||||
const heroRef = useRef(null);
|
const heroRef = useRef(null);
|
||||||
const contentRef = useRef(null);
|
const contentRef = useRef(null);
|
||||||
|
|
||||||
const isHeroInView = useInView(heroRef, { once: true });
|
const isHeroInView = useInView(heroRef, { once: true });
|
||||||
const isContentInView = useInView(contentRef, { 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 = {
|
const containerVariants = {
|
||||||
hidden: { opacity: 0, y: 50 },
|
hidden: { opacity: 0, y: 50 },
|
||||||
visible: {
|
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"
|
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" />
|
<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>
|
</motion.div>
|
||||||
|
|
||||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||||
Conformité
|
{t('title1')}
|
||||||
<br />
|
<br />
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||||
RGPD
|
{t('title2')}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
|
<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)
|
{t('intro')}
|
||||||
et à garantir vos droits en matière de protection des données personnelles.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center space-x-4">
|
<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">
|
<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" />
|
<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>
|
||||||
<div className="flex items-center space-x-2 bg-white/10 px-4 py-2 rounded-lg">
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -191,10 +146,10 @@ export default function CompliancePage() {
|
|||||||
className="text-center mb-16"
|
className="text-center mb-16"
|
||||||
>
|
>
|
||||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||||
Vos droits RGPD
|
{t('rightsTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<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>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -204,11 +159,11 @@ export default function CompliancePage() {
|
|||||||
animate={isContentInView ? 'visible' : 'hidden'}
|
animate={isContentInView ? 'visible' : 'hidden'}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
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;
|
const IconComponent = right.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={right.key}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
whileHover={{ y: -5 }}
|
whileHover={{ y: -5 }}
|
||||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all text-center"
|
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">
|
<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" />
|
<IconComponent className="w-8 h-8 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-brand-navy mb-3">{right.title}</h3>
|
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`rights.${right.key}.title`)}</h3>
|
||||||
<p className="text-gray-600">{right.description}</p>
|
<p className="text-gray-600">{t(`rights.${right.key}.description`)}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -231,20 +186,20 @@ export default function CompliancePage() {
|
|||||||
className="mt-12 text-center"
|
className="mt-12 text-center"
|
||||||
>
|
>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
Pour exercer vos droits, connectez-vous à votre compte ou contactez notre DPO
|
{t('rightsCta.text')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium"
|
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>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href="mailto:dpo@xpeditis.com"
|
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"
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -262,19 +217,19 @@ export default function CompliancePage() {
|
|||||||
className="text-center mb-16"
|
className="text-center mb-16"
|
||||||
>
|
>
|
||||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||||
Nos principes de protection des données
|
{t('principlesTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<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>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
<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;
|
const IconComponent = principle.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={principle.key}
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
whileInView={{ opacity: 1, scale: 1 }}
|
whileInView={{ opacity: 1, scale: 1 }}
|
||||||
viewport={{ once: true }}
|
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">
|
<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" />
|
<IconComponent className="w-6 h-6 text-brand-green" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-bold text-brand-navy mb-2">{principle.title}</h3>
|
<h3 className="text-lg font-bold text-brand-navy mb-2">{t(`principles.${principle.key}.title`)}</h3>
|
||||||
<p className="text-gray-600 text-sm">{principle.description}</p>
|
<p className="text-gray-600 text-sm">{t(`principles.${principle.key}.description`)}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -304,29 +259,29 @@ export default function CompliancePage() {
|
|||||||
className="text-center mb-16"
|
className="text-center mb-16"
|
||||||
>
|
>
|
||||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||||
Mesures de protection
|
{t('measuresTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<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>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
{measures.map((measure, index) => (
|
{MEASURES.map((key, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={key}
|
||||||
initial={{ opacity: 0, x: index === 0 ? -30 : 30 }}
|
initial={{ opacity: 0, x: index === 0 ? -30 : 30 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.6 }}
|
transition={{ duration: 0.6 }}
|
||||||
className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-8 rounded-2xl"
|
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">
|
<ul className="space-y-4">
|
||||||
{measure.items.map((item, i) => (
|
{MEASURE_ITEMS.map((itemKey) => (
|
||||||
<li key={i} className="flex items-center space-x-3 text-white/80">
|
<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" />
|
<CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" />
|
||||||
<span>{item}</span>
|
<span>{t(`measures.${key}.${itemKey}` as any)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -352,33 +307,18 @@ export default function CompliancePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-bold text-brand-navy mb-4">
|
<h3 className="text-2xl font-bold text-brand-navy mb-4">
|
||||||
Registre des traitements
|
{t('register.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
Conformément à l'article 30 du RGPD, nous tenons un registre des activités de traitement
|
{t('register.body')}
|
||||||
des données personnelles. Ce registre documente :
|
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-3 text-gray-600">
|
<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" />
|
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
|
||||||
<span>Les finalités de chaque traitement</span>
|
<span>{t(`register.${itemKey}` as any)}</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>
|
|
||||||
</li>
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -398,11 +338,10 @@ export default function CompliancePage() {
|
|||||||
>
|
>
|
||||||
<UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
|
<UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
|
||||||
<h3 className="text-2xl font-bold text-white mb-4">
|
<h3 className="text-2xl font-bold text-white mb-4">
|
||||||
Contacter notre DPO
|
{t('dpo.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-white/80 mb-6 max-w-2xl mx-auto">
|
<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
|
{t('dpo.body')}
|
||||||
relative au traitement de vos données personnelles ou à l'exercice de vos droits.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||||
<a
|
<a
|
||||||
@ -416,7 +355,7 @@ export default function CompliancePage() {
|
|||||||
href="/privacy"
|
href="/privacy"
|
||||||
className="px-6 py-3 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-colors font-medium"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { motion, useInView } from 'framer-motion';
|
import { motion, useInView } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Mail,
|
Mail,
|
||||||
@ -17,11 +18,26 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||||
import { sendContactForm } from '@/lib/api/auth';
|
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() {
|
export default function ContactPage() {
|
||||||
|
const t = useTranslations('marketing.contact');
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
@ -64,7 +80,7 @@ export default function ContactPage() {
|
|||||||
});
|
});
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "Une erreur est survenue lors de l'envoi. Veuillez réessayer.");
|
setError(err.message || t('form.genericError'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
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 = {
|
const containerVariants = {
|
||||||
hidden: { opacity: 0, y: 50 },
|
hidden: { opacity: 0, y: 50 },
|
||||||
visible: {
|
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"
|
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" />
|
<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>
|
</motion.div>
|
||||||
|
|
||||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||||
Une question ?
|
{t('title1')}
|
||||||
<br />
|
<br />
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||||
Nous sommes là pour vous
|
{t('title2')}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
|
<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,
|
{t('intro')}
|
||||||
nos services et nos tarifs. N'hésitez pas à nous contacter !
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@ -216,11 +178,11 @@ export default function ContactPage() {
|
|||||||
className="max-w-7xl mx-auto px-6 lg:px-8"
|
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">
|
<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;
|
const IconComponent = method.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={method.key}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100"
|
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" />
|
<IconComponent className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-bold text-brand-navy mb-1">{method.title}</h3>
|
<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">{method.description}</p>
|
<p className="text-gray-500 text-sm mb-2">{t(`methods.${method.key}.description`)}</p>
|
||||||
<p className="text-brand-turquoise font-medium">{method.value}</p>
|
<p className="text-brand-turquoise font-medium">{t(`methods.${method.key}.value`)}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -249,9 +211,9 @@ export default function ContactPage() {
|
|||||||
animate={isFormInView ? { opacity: 1, x: 0 } : {}}
|
animate={isFormInView ? { opacity: 1, x: 0 } : {}}
|
||||||
transition={{ duration: 0.8 }}
|
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">
|
<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>
|
</p>
|
||||||
|
|
||||||
{isSubmitted ? (
|
{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">
|
<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" />
|
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
||||||
</div>
|
</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">
|
<p className="text-green-700 mb-6">
|
||||||
Merci pour votre message. Notre équipe vous répondra dans les 24 heures.
|
{t('form.successBody')}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -282,7 +244,7 @@ export default function ContactPage() {
|
|||||||
}}
|
}}
|
||||||
className="text-brand-turquoise font-medium hover:underline"
|
className="text-brand-turquoise font-medium hover:underline"
|
||||||
>
|
>
|
||||||
Envoyer un autre message
|
{t('form.sendAnother')}
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
@ -290,7 +252,7 @@ export default function ContactPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Prénom *
|
{t('form.firstName')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -300,12 +262,12 @@ export default function ContactPage() {
|
|||||||
value={formData.firstName}
|
value={formData.firstName}
|
||||||
onChange={handleChange}
|
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"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Nom *
|
{t('form.lastName')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -315,7 +277,7 @@ export default function ContactPage() {
|
|||||||
value={formData.lastName}
|
value={formData.lastName}
|
||||||
onChange={handleChange}
|
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -323,7 +285,7 @@ export default function ContactPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Email *
|
{t('form.email')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@ -333,12 +295,12 @@ export default function ContactPage() {
|
|||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleChange}
|
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"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Téléphone
|
{t('form.phone')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
@ -347,14 +309,14 @@ export default function ContactPage() {
|
|||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={handleChange}
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Entreprise
|
{t('form.company')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -363,13 +325,13 @@ export default function ContactPage() {
|
|||||||
value={formData.company}
|
value={formData.company}
|
||||||
onChange={handleChange}
|
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"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Sujet *
|
{t('form.subject')} *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="subject"
|
id="subject"
|
||||||
@ -379,9 +341,10 @@ export default function ContactPage() {
|
|||||||
onChange={handleChange}
|
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"
|
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 value="">{t('subjects.placeholder')}</option>
|
||||||
<option key={subject.value} value={subject.value}>
|
{SUBJECTS.map((key) => (
|
||||||
{subject.label}
|
<option key={key} value={key}>
|
||||||
|
{t(`subjects.${key}`)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -389,7 +352,7 @@ export default function ContactPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Message *
|
{t('form.message')} *
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="message"
|
id="message"
|
||||||
@ -399,7 +362,7 @@ export default function ContactPage() {
|
|||||||
value={formData.message}
|
value={formData.message}
|
||||||
onChange={handleChange}
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
@ -417,12 +380,12 @@ export default function ContactPage() {
|
|||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
<span>Envoi en cours...</span>
|
<span>{t('form.submitting')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Send className="w-5 h-5" />
|
<Send className="w-5 h-5" />
|
||||||
<span>Envoyer le message</span>
|
<span>{t('form.submit')}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -436,87 +399,72 @@ export default function ContactPage() {
|
|||||||
animate={isFormInView ? { opacity: 1, x: 0 } : {}}
|
animate={isFormInView ? { opacity: 1, x: 0 } : {}}
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
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">
|
<p className="text-gray-600 mb-8">
|
||||||
Retrouvez-nous à Paris ou contactez-nous par email.
|
{t('office.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{offices.map((office, index) => (
|
<div className="bg-white p-6 rounded-2xl border-2 border-brand-turquoise shadow-lg">
|
||||||
<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="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
<div
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 bg-brand-turquoise">
|
||||||
className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${
|
<Building2 className="w-6 h-6 text-white" />
|
||||||
office.isHQ ? 'bg-brand-turquoise' : 'bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Building2 className={`w-6 h-6 ${office.isHQ ? 'text-white' : 'text-gray-600'}`} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
<h3 className="text-xl font-bold text-brand-navy">{office.city}</h3>
|
<h3 className="text-xl font-bold text-brand-navy">{t('office.city')}</h3>
|
||||||
{office.isHQ && (
|
|
||||||
<span className="px-2 py-1 bg-brand-turquoise/10 text-brand-turquoise text-xs font-medium rounded-full">
|
<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>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-gray-600">
|
<div className="space-y-2 text-gray-600">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<MapPin className="w-4 h-4 text-gray-400" />
|
<MapPin className="w-4 h-4 text-gray-400" />
|
||||||
<span>{office.address}</span>
|
<span>{t('office.address')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<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>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Phone className="w-4 h-4 text-gray-400" />
|
<Phone className="w-4 h-4 text-gray-400" />
|
||||||
<a href={`tel:${office.phone.replace(/\s/g, '')}`} className="hover:text-brand-turquoise">
|
<a href={`tel:${t('office.phone').replace(/\s/g, '')}`} className="hover:text-brand-turquoise">
|
||||||
{office.phone}
|
{t('office.phone')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Mail className="w-4 h-4 text-gray-400" />
|
<Mail className="w-4 h-4 text-gray-400" />
|
||||||
<a href={`mailto:${office.email}`} className="hover:text-brand-turquoise">
|
<a href={`mailto:${t('office.email')}`} className="hover:text-brand-turquoise">
|
||||||
{office.email}
|
{t('office.email')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hours */}
|
{/* Hours */}
|
||||||
<div className="mt-8 bg-gray-50 p-6 rounded-2xl">
|
<div className="mt-8 bg-gray-50 p-6 rounded-2xl">
|
||||||
<div className="flex items-center space-x-3 mb-4">
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
<Clock className="w-6 h-6 text-brand-turquoise" />
|
<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>
|
||||||
<div className="space-y-2 text-gray-600">
|
<div className="space-y-2 text-gray-600">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Lundi - Vendredi</span>
|
<span>{t('hours.weekdays')}</span>
|
||||||
<span className="font-medium">9h00 - 18h00</span>
|
<span className="font-medium">{t('hours.weekdaysHours')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Samedi</span>
|
<span>{t('hours.saturday')}</span>
|
||||||
<span className="font-medium">10h00 - 14h00</span>
|
<span className="font-medium">{t('hours.saturdayHours')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Dimanche</span>
|
<span>{t('hours.sunday')}</span>
|
||||||
<span className="font-medium text-gray-400">Fermé</span>
|
<span className="font-medium text-gray-400">{t('hours.closed')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm text-gray-500">
|
<p className="mt-4 text-sm text-gray-500">
|
||||||
* Support technique disponible 24/7 pour les clients Enterprise
|
{t('hours.supportNote')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -524,7 +472,7 @@ export default function ContactPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Section 1 : Ce qui se passe après l'envoi */}
|
{/* Section 1 : After submit */}
|
||||||
<section ref={afterSubmitRef} className="py-16 bg-white">
|
<section ref={afterSubmitRef} className="py-16 bg-white">
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -533,7 +481,6 @@ export default function ContactPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
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"
|
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 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 -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" />
|
<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" />
|
<Mail className="w-5 h-5 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs">
|
<span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs">
|
||||||
Après votre envoi
|
{t('afterSubmit.badge')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl lg:text-3xl font-bold text-white mb-8">
|
<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>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Notre engagement */}
|
{/* Commitment */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={isAfterSubmitInView ? { opacity: 1, y: 0 } : {}}
|
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">
|
<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" />
|
<CheckCircle2 className="w-5 h-5 text-brand-turquoise" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<p className="text-white/80 leading-relaxed">
|
<p className="text-white/80 leading-relaxed">
|
||||||
Dès réception de votre demande, un de nos experts logistiques analyse votre
|
{t('afterSubmit.commitmentBody1')}
|
||||||
profil et vos besoins. Vous recevrez une réponse personnalisée ou une invitation
|
|
||||||
pour une démonstration de la plateforme{' '}
|
|
||||||
<span className="text-brand-turquoise font-semibold">
|
<span className="text-brand-turquoise font-semibold">
|
||||||
sous 48 heures ouvrées.
|
{t('afterSubmit.commitmentHighlight')}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Sécurité */}
|
{/* Security */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={isAfterSubmitInView ? { opacity: 1, y: 0 } : {}}
|
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">
|
<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" />
|
<Shield className="w-5 h-5 text-brand-green" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<p className="text-white/80 leading-relaxed">
|
<p className="text-white/80 leading-relaxed">
|
||||||
Vos informations sont protégées et traitées conformément à notre{' '}
|
{t('afterSubmit.securityBody1')}
|
||||||
<a href="/privacy" className="text-brand-turquoise font-semibold hover:underline">
|
<Link href="/privacy" className="text-brand-turquoise font-semibold hover:underline">
|
||||||
politique de confidentialité
|
{t('afterSubmit.privacyLink')}
|
||||||
</a>
|
</Link>
|
||||||
. Aucune donnée n'est partagée avec des tiers sans votre accord.
|
{t('afterSubmit.securityBody2')}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@ -603,7 +548,7 @@ export default function ContactPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Section 2 : Accès Rapide */}
|
{/* Section 2: Quick access */}
|
||||||
<section ref={quickAccessRef} className="py-16 bg-gray-50">
|
<section ref={quickAccessRef} className="py-16 bg-gray-50">
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -613,15 +558,15 @@ export default function ContactPage() {
|
|||||||
>
|
>
|
||||||
<div className="text-center mb-10">
|
<div className="text-center mb-10">
|
||||||
<span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs">
|
<span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs">
|
||||||
Accès rapide
|
{t('quickAccess.badge')}
|
||||||
</span>
|
</span>
|
||||||
<h2 className="text-2xl lg:text-3xl font-bold text-brand-navy mt-2">
|
<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>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
||||||
{/* Tarification instantanée */}
|
{/* Instant pricing */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: -30 }}
|
initial={{ opacity: 0, x: -30 }}
|
||||||
animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}}
|
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">
|
<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" />
|
<Zap className="w-7 h-7 text-white" />
|
||||||
</div>
|
</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">
|
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
|
||||||
N'attendez pas notre retour pour vos prix. Utilisez notre moteur{' '}
|
{t('quickAccess.pricingBody1')}
|
||||||
<span className="font-semibold text-brand-navy">Click&Ship</span> pour obtenir
|
<span className="font-semibold text-brand-navy">{t('quickAccess.pricingHighlight')}</span>
|
||||||
une cotation de fret maritime en moins de 60 secondes.
|
{t('quickAccess.pricingBody2')}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<Link
|
||||||
href="/dashboard"
|
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"
|
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" />
|
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||||
</a>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Wiki Maritime */}
|
{/* Wiki */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 30 }}
|
initial={{ opacity: 0, x: 30 }}
|
||||||
animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}}
|
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">
|
<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" />
|
<BookOpen className="w-7 h-7 text-brand-turquoise" />
|
||||||
</div>
|
</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">
|
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
|
||||||
Une question sur les Incoterms ou la documentation export ? Notre{' '}
|
{t('quickAccess.wikiBody1')}
|
||||||
<span className="font-semibold text-brand-navy">Wiki Maritime</span> contient déjà
|
<span className="font-semibold text-brand-navy">{t('quickAccess.wikiHighlight')}</span>
|
||||||
les réponses aux questions les plus fréquentes.
|
{t('quickAccess.wikiBody2')}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<Link
|
||||||
href="/dashboard/wiki"
|
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"
|
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" />
|
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||||
</a>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -2,63 +2,77 @@
|
|||||||
|
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { motion, useInView } from 'framer-motion';
|
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';
|
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() {
|
export default function CookiesPage() {
|
||||||
|
const t = useTranslations('marketing.cookies');
|
||||||
|
const tCommon = useTranslations('marketing.common');
|
||||||
const heroRef = useRef(null);
|
const heroRef = useRef(null);
|
||||||
const contentRef = useRef(null);
|
const contentRef = useRef(null);
|
||||||
|
|
||||||
const isHeroInView = useInView(heroRef, { once: true });
|
const isHeroInView = useInView(heroRef, { once: true });
|
||||||
const isContentInView = useInView(contentRef, { 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 = {
|
const containerVariants = {
|
||||||
hidden: { opacity: 0, y: 50 },
|
hidden: { opacity: 0, y: 50 },
|
||||||
visible: {
|
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"
|
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" />
|
<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>
|
</motion.div>
|
||||||
|
|
||||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||||
Politique de
|
{t('title1')}
|
||||||
<br />
|
<br />
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||||
Cookies
|
{t('title2')}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
|
<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
|
{t('intro')}
|
||||||
sur Xpeditis et comment vous pouvez gérer vos préférences.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-white/60 text-sm">
|
<p className="text-white/60 text-sm">{tCommon('lastUpdated')}</p>
|
||||||
Dernière mise à jour : Janvier 2025
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wave */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0">
|
<div className="absolute bottom-0 left-0 right-0">
|
||||||
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
||||||
<path
|
<path
|
||||||
@ -148,16 +158,9 @@ export default function CookiesPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
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>
|
<h2 className="text-2xl font-bold text-brand-navy mb-4">{t('introBoxTitle')}</h2>
|
||||||
<p className="text-gray-600 leading-relaxed mb-4">
|
<p className="text-gray-600 leading-relaxed mb-4">{t('introBoxBody1')}</p>
|
||||||
Un cookie est un petit fichier texte stocké sur votre appareil (ordinateur, tablette, smartphone)
|
<p className="text-gray-600 leading-relaxed">{t('introBoxBody2')}</p>
|
||||||
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>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -171,10 +174,8 @@ export default function CookiesPage() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="text-center mb-12"
|
className="text-center mb-12"
|
||||||
>
|
>
|
||||||
<h2 className="text-3xl font-bold text-brand-navy mb-4">Types de cookies utilisés</h2>
|
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('typesTitle')}</h2>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">{t('typesSubtitle')}</p>
|
||||||
Nous utilisons différents types de cookies sur notre plateforme
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -183,11 +184,11 @@ export default function CookiesPage() {
|
|||||||
animate={isContentInView ? 'visible' : 'hidden'}
|
animate={isContentInView ? 'visible' : 'hidden'}
|
||||||
className="space-y-8"
|
className="space-y-8"
|
||||||
>
|
>
|
||||||
{cookieTypes.map((type, index) => {
|
{COOKIE_TYPES.map((type) => {
|
||||||
const IconComponent = type.icon;
|
const IconComponent = type.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={type.key}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
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" />
|
<IconComponent className="w-6 h-6 text-brand-turquoise" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-brand-navy">{type.title}</h3>
|
<h3 className="text-xl font-bold text-brand-navy">
|
||||||
<p className="text-gray-500 text-sm">{type.description}</p>
|
{t(`types.${type.key}.title`)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
{t(`types.${type.key}.description`)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{type.required ? (
|
{type.required ? (
|
||||||
<span className="px-3 py-1 bg-brand-navy/10 text-brand-navy text-xs font-medium rounded-full">
|
<span className="px-3 py-1 bg-brand-navy/10 text-brand-navy text-xs font-medium rounded-full">
|
||||||
Requis
|
{t('required')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<ToggleLeft className="w-8 h-8 text-gray-400" />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -217,17 +222,27 @@ export default function CookiesPage() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200">
|
<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">
|
||||||
<th className="text-left py-3 px-4 font-semibold text-brand-navy">Finalité</th>
|
{t('tableHeaders.name')}
|
||||||
<th className="text-left py-3 px-4 font-semibold text-brand-navy">Durée</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{type.cookies.map((cookie, i) => (
|
{type.cookies.map((cookie) => (
|
||||||
<tr key={i} className="border-b border-gray-100 last:border-0">
|
<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 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-600">
|
||||||
<td className="py-3 px-4 text-gray-500">{cookie.duration}</td>
|
{t(`purposes.${cookie.purposeKey}` as any)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-500">
|
||||||
|
{t(`durations.${cookie.durationKey}` as any)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -245,19 +260,15 @@ export default function CookiesPage() {
|
|||||||
transition={{ duration: 0.8, delay: 0.4 }}
|
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"
|
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">
|
<div className="space-y-4 text-gray-600">
|
||||||
<p>
|
<p>{t('manageIntro')}</p>
|
||||||
Vous pouvez à tout moment modifier vos préférences en matière de cookies :
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc pl-6 space-y-2">
|
<ul className="list-disc pl-6 space-y-2">
|
||||||
<li>Via notre bandeau de consentement accessible en bas de chaque page</li>
|
<li>{t('manageBullet1')}</li>
|
||||||
<li>Dans les paramètres de votre navigateur (Chrome, Firefox, Safari, Edge)</li>
|
<li>{t('manageBullet2')}</li>
|
||||||
<li>En utilisant des outils tiers de gestion des cookies</li>
|
<li>{t('manageBullet3')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-sm text-gray-500 mt-4">
|
<p className="text-sm text-gray-500 mt-4">{t('manageNote')}</p>
|
||||||
Note : La désactivation de certains cookies peut affecter votre expérience sur notre plateforme.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.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"
|
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" />
|
<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>
|
<h3 className="text-2xl font-bold text-white mb-4">{t('contact.title')}</h3>
|
||||||
<p className="text-white/80 mb-6">
|
<p className="text-white/80 mb-6">{t('contact.body')}</p>
|
||||||
Notre équipe est disponible pour répondre à toutes vos questions
|
|
||||||
concernant l'utilisation des cookies sur notre plateforme.
|
|
||||||
</p>
|
|
||||||
<a
|
<a
|
||||||
href="mailto:privacy@xpeditis.com"
|
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"
|
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"
|
||||||
@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin';
|
import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin';
|
||||||
|
|
||||||
interface Booking {
|
interface Booking {
|
||||||
@ -26,6 +27,10 @@ interface Booking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminBookingsPage() {
|
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 [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -43,26 +48,26 @@ export default function AdminBookingsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDeleteBooking = async (bookingId: string) => {
|
const handleDeleteBooking = async (bookingId: string) => {
|
||||||
if (!window.confirm('Supprimer définitivement cette réservation ?')) return;
|
if (!window.confirm(t('confirmDelete'))) return;
|
||||||
setDeletingId(bookingId);
|
setDeletingId(bookingId);
|
||||||
try {
|
try {
|
||||||
await deleteAdminBooking(bookingId);
|
await deleteAdminBooking(bookingId);
|
||||||
setBookings(prev => prev.filter(b => b.id !== bookingId));
|
setBookings(prev => prev.filter(b => b.id !== bookingId));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Erreur lors de la suppression');
|
setError(err.message || t('deleteError'));
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null);
|
setDeletingId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleValidateTransfer = async (bookingId: string) => {
|
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);
|
setValidatingId(bookingId);
|
||||||
try {
|
try {
|
||||||
await validateBankTransfer(bookingId);
|
await validateBankTransfer(bookingId);
|
||||||
await fetchBookings();
|
await fetchBookings();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Erreur lors de la validation du virement');
|
setError(err.message || t('validateError'));
|
||||||
} finally {
|
} finally {
|
||||||
setValidatingId(null);
|
setValidatingId(null);
|
||||||
}
|
}
|
||||||
@ -75,7 +80,7 @@ export default function AdminBookingsPage() {
|
|||||||
setBookings(response.bookings || []);
|
setBookings(response.bookings || []);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Impossible de charger les réservations');
|
setError(err.message || t('loadError'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -94,15 +99,12 @@ export default function AdminBookingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
const getStatusLabel = (status: string) => {
|
||||||
const labels: Record<string, string> = {
|
const key = status.toUpperCase();
|
||||||
PENDING_PAYMENT: 'Paiement en attente',
|
const allowed = ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'];
|
||||||
PENDING_BANK_TRANSFER: 'Virement à valider',
|
if (allowed.includes(key)) {
|
||||||
PENDING: 'En attente transporteur',
|
return t(`status.${key}` as any);
|
||||||
ACCEPTED: 'Accepté',
|
}
|
||||||
REJECTED: 'Rejeté',
|
return status;
|
||||||
CANCELLED: 'Annulé',
|
|
||||||
};
|
|
||||||
return labels[status.toUpperCase()] || status;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`;
|
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="flex items-center justify-center h-96">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -140,38 +142,38 @@ export default function AdminBookingsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<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">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
Toutes les réservations de la plateforme
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
<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="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 className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4">
|
<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">
|
<div className="text-2xl font-bold text-amber-700 mt-1">
|
||||||
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
|
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-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">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">
|
<div className="text-2xl font-bold text-yellow-600 mt-1">
|
||||||
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
|
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-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">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">
|
<div className="text-2xl font-bold text-green-600 mt-1">
|
||||||
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
|
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-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">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">
|
<div className="text-2xl font-bold text-red-600 mt-1">
|
||||||
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
|
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
|
||||||
</div>
|
</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="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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="N° booking, transporteur, route, palettes, poids, CBM..."
|
placeholder={t('search.placeholder')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
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"
|
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>
|
||||||
<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
|
<select
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={e => setFilterStatus(e.target.value)}
|
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"
|
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="all">{t('filter.all')}</option>
|
||||||
<option value="pending_bank_transfer">Virement à valider</option>
|
<option value="pending_bank_transfer">{t('status.PENDING_BANK_TRANSFER')}</option>
|
||||||
<option value="pending_payment">Paiement en attente</option>
|
<option value="pending_payment">{t('status.PENDING_PAYMENT')}</option>
|
||||||
<option value="pending">En attente transporteur</option>
|
<option value="pending">{t('status.PENDING')}</option>
|
||||||
<option value="accepted">Accepté</option>
|
<option value="accepted">{t('status.ACCEPTED')}</option>
|
||||||
<option value="rejected">Rejeté</option>
|
<option value="rejected">{t('status.REJECTED')}</option>
|
||||||
<option value="cancelled">Annulé</option>
|
<option value="cancelled">{t('status.CANCELLED')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -224,25 +226,25 @@ export default function AdminBookingsPage() {
|
|||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<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>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Route
|
{t('table.route')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Cargo
|
{t('table.cargo')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Transporteur
|
{t('table.carrier')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Statut
|
{t('table.status')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Date
|
{t('table.date')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Actions
|
{t('table.actions')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -250,7 +252,7 @@ export default function AdminBookingsPage() {
|
|||||||
{filteredBookings.length === 0 ? (
|
{filteredBookings.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500">
|
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500">
|
||||||
Aucune réservation trouvée
|
{t('table.empty')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
@ -276,11 +278,11 @@ export default function AdminBookingsPage() {
|
|||||||
<div className="text-sm text-gray-900">
|
<div className="text-sm text-gray-900">
|
||||||
{booking.containerType}
|
{booking.containerType}
|
||||||
{booking.palletCount != null && (
|
{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>
|
||||||
<div className="text-xs text-gray-500 space-x-2">
|
<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>}
|
{booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -299,7 +301,7 @@ export default function AdminBookingsPage() {
|
|||||||
|
|
||||||
{/* Date */}
|
{/* Date */}
|
||||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
|
<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>
|
</td>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* 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="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" />
|
<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>
|
</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>
|
</button>
|
||||||
{(() => {
|
{(() => {
|
||||||
const booking = bookings.find(b => b.id === openMenuId);
|
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">
|
<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" />
|
<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>
|
</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>
|
</button>
|
||||||
) : null;
|
) : 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">
|
<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" />
|
<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>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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="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="bg-white rounded-lg p-6 max-w-2xl w-full">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<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
|
<button
|
||||||
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
|
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
|
||||||
className="text-gray-400 hover:text-gray-600"
|
className="text-gray-400 hover:text-gray-600"
|
||||||
@ -418,13 +420,13 @@ export default function AdminBookingsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<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">
|
<div className="mt-1 text-lg font-semibold text-gray-900">
|
||||||
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
|
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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)}`}>
|
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
|
||||||
{getStatusLabel(selectedBooking.status)}
|
{getStatusLabel(selectedBooking.status)}
|
||||||
</span>
|
</span>
|
||||||
@ -432,45 +434,45 @@ export default function AdminBookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
<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 className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">Origine</label>
|
<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 || '—'}</div>
|
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || t('modal.none')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">Destination</label>
|
<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 || '—'}</div>
|
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || t('modal.none')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
<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 className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">Transporteur</label>
|
<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 || '—'}</div>
|
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || t('modal.none')}</div>
|
||||||
</div>
|
</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 className="mt-1 font-semibold text-gray-900">{selectedBooking.containerType}</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedBooking.palletCount != null && (
|
{selectedBooking.palletCount != null && (
|
||||||
<div>
|
<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 className="mt-1 font-semibold text-gray-900">{selectedBooking.palletCount}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedBooking.weightKG != null && (
|
{selectedBooking.weightKG != null && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">Poids</label>
|
<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()} kg</div>
|
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString(dateLocale)} kg</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedBooking.volumeCBM != null && (
|
{selectedBooking.volumeCBM != null && (
|
||||||
<div>
|
<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 className="mt-1 font-semibold text-gray-900">{selectedBooking.volumeCBM} CBM</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -479,18 +481,18 @@ export default function AdminBookingsPage() {
|
|||||||
|
|
||||||
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
|
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
|
||||||
<div className="border-t pt-4">
|
<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">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{selectedBooking.priceEUR != null && (
|
{selectedBooking.priceEUR != null && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">EUR</label>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedBooking.priceUSD != null && (
|
{selectedBooking.priceUSD != null && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500">USD</label>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -498,18 +500,18 @@ export default function AdminBookingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
<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 className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{selectedBooking.updatedAt && (
|
{selectedBooking.updatedAt && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-gray-500">Mise à jour</label>
|
<label className="block text-gray-500">{t('modal.updatedAt')}</label>
|
||||||
<div className="mt-1 text-gray-900">{new Date(selectedBooking.updatedAt).toLocaleString('fr-FR')}</div>
|
<div className="mt-1 text-gray-900">{new Date(selectedBooking.updatedAt).toLocaleString(dateLocale)}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -526,7 +528,7 @@ export default function AdminBookingsPage() {
|
|||||||
disabled={validatingId === selectedBooking.id}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -537,7 +539,7 @@ export default function AdminBookingsPage() {
|
|||||||
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
|
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
|
||||||
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Fermer
|
{t('modal.close')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -10,6 +10,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -27,6 +28,10 @@ import {
|
|||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
|
||||||
export default function AdminCsvRatesPage() {
|
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 [files, setFiles] = useState<CsvFileInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -39,7 +44,7 @@ export default function AdminCsvRatesPage() {
|
|||||||
const data = await listCsvFiles();
|
const data = await listCsvFiles();
|
||||||
setFiles(data.files || []);
|
setFiles(data.files || []);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || 'Erreur lors du chargement des fichiers');
|
setError(err?.message || t('loadError'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -50,16 +55,16 @@ export default function AdminCsvRatesPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDelete = async (filename: string) => {
|
const handleDelete = async (filename: string) => {
|
||||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer le fichier ${filename} ?`)) {
|
if (!confirm(t('confirmDelete', { filename }))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteCsvFile(filename);
|
await deleteCsvFile(filename);
|
||||||
alert(`Fichier supprimé: ${filename}`);
|
alert(t('deleteSuccess', { filename }));
|
||||||
fetchFiles(); // Refresh list
|
fetchFiles(); // Refresh list
|
||||||
} catch (err: any) {
|
} 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">
|
<div className="container mx-auto py-8 space-y-6">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div>
|
<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">
|
<p className="text-muted-foreground mt-2">
|
||||||
Interface d'administration pour gérer les fichiers CSV de tarifs maritimes
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
<Badge variant="destructive" className="mt-2">
|
<Badge variant="destructive" className="mt-2">
|
||||||
ADMIN SEULEMENT
|
{t('adminBadge')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -84,9 +89,9 @@ export default function AdminCsvRatesPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Configurations CSV actives</CardTitle>
|
<CardTitle>{t('cardTitle')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Liste de toutes les compagnies avec fichiers CSV configurés
|
{t('cardDescription')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}>
|
<Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}>
|
||||||
@ -111,19 +116,19 @@ export default function AdminCsvRatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : files.length === 0 ? (
|
) : files.length === 0 ? (
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
Aucun fichier trouvé. Uploadez un fichier CSV pour commencer.
|
{t('empty')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Fichier</TableHead>
|
<TableHead>{t('table.filename')}</TableHead>
|
||||||
<TableHead>Taille</TableHead>
|
<TableHead>{t('table.size')}</TableHead>
|
||||||
<TableHead>Lignes</TableHead>
|
<TableHead>{t('table.rows')}</TableHead>
|
||||||
<TableHead>Date d'upload</TableHead>
|
<TableHead>{t('table.uploadedAt')}</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead>{t('table.email')}</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>{t('table.actions')}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -135,14 +140,14 @@ export default function AdminCsvRatesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{file.rowCount ? (
|
{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>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{new Date(file.uploadedAt).toLocaleDateString('fr-FR')}
|
{new Date(file.uploadedAt).toLocaleDateString(dateLocale)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@ -171,23 +176,20 @@ export default function AdminCsvRatesPage() {
|
|||||||
{/* Info Card */}
|
{/* Info Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Informations</CardTitle>
|
<CardTitle>{t('infoTitle')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 text-sm">
|
<CardContent className="space-y-2 text-sm">
|
||||||
<p>
|
<p>
|
||||||
<strong>Format CSV requis :</strong> Consultez la documentation pour la liste complète
|
<strong>{t('info.formatLabel')}</strong> {t('info.formatBody')}
|
||||||
des colonnes obligatoires.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Taille maximale :</strong> 10 MB par fichier
|
<strong>{t('info.sizeLabel')}</strong> {t('info.sizeBody')}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Mise à jour :</strong> Uploader un nouveau fichier pour une compagnie existante
|
<strong>{t('info.updateLabel')}</strong> {t('info.updateBody')}
|
||||||
écrasera l'ancien fichier.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Validation :</strong> Le système valide automatiquement la structure du CSV lors
|
<strong>{t('info.validationLabel')}</strong> {t('info.validationBody')}
|
||||||
de l'upload.
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { getAllBookings, getAllUsers, deleteAdminDocument } from '@/lib/api/admin';
|
import { getAllBookings, getAllUsers, deleteAdminDocument } from '@/lib/api/admin';
|
||||||
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
|
|
||||||
interface Document {
|
interface Document {
|
||||||
id: string;
|
id: string;
|
||||||
@ -45,6 +47,10 @@ interface DocumentWithBooking extends Document {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminDocumentsPage() {
|
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 [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
|
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -66,19 +72,6 @@ export default function AdminDocumentsPage() {
|
|||||||
return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`;
|
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
|
// Get file extension and type
|
||||||
const getFileType = (fileName: string): string => {
|
const getFileType = (fileName: string): string => {
|
||||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
@ -105,7 +98,6 @@ export default function AdminDocumentsPage() {
|
|||||||
const allBookings = response.bookings || [];
|
const allBookings = response.bookings || [];
|
||||||
setBookings(allBookings);
|
setBookings(allBookings);
|
||||||
|
|
||||||
// Extract all documents from all bookings
|
|
||||||
const allDocuments: DocumentWithBooking[] = [];
|
const allDocuments: DocumentWithBooking[] = [];
|
||||||
const userIds = new Set<string>();
|
const userIds = new Set<string>();
|
||||||
|
|
||||||
@ -113,28 +105,15 @@ export default function AdminDocumentsPage() {
|
|||||||
userIds.add(booking.userId);
|
userIds.add(booking.userId);
|
||||||
if (booking.documents && booking.documents.length > 0) {
|
if (booking.documents && booking.documents.length > 0) {
|
||||||
booking.documents.forEach((doc: Document) => {
|
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 actualFileName = doc.fileName || doc.name || 'document';
|
||||||
const actualFilePath = doc.filePath || doc.url || '';
|
const actualFilePath = doc.filePath || doc.url || '';
|
||||||
const actualMimeType = doc.mimeType || doc.type || '';
|
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 = '';
|
let fileType = '';
|
||||||
if (actualMimeType.includes('/')) {
|
if (actualMimeType.includes('/')) {
|
||||||
// It's a MIME type like "application/pdf"
|
|
||||||
const parts = actualMimeType.split('/');
|
const parts = actualMimeType.split('/');
|
||||||
fileType = getFileType(parts[1]);
|
fileType = getFileType(parts[1]);
|
||||||
} else {
|
} else {
|
||||||
// It's already a type or we extract from filename
|
|
||||||
fileType = getFileType(actualFileName);
|
fileType = getFileType(actualFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,10 +133,8 @@ export default function AdminDocumentsPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch user names using the API client
|
|
||||||
try {
|
try {
|
||||||
const usersData = await getAllUsers();
|
const usersData = await getAllUsers();
|
||||||
console.log('Users data:', usersData);
|
|
||||||
|
|
||||||
if (usersData && usersData.users) {
|
if (usersData && usersData.users) {
|
||||||
const usersMap = new Map(
|
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 => {
|
allDocuments.forEach(doc => {
|
||||||
const userName = usersMap.get(doc.userId);
|
const userName = usersMap.get(doc.userId);
|
||||||
doc.userName = userName || doc.userId.substring(0, 8) + '...';
|
doc.userName = userName || doc.userId.substring(0, 8) + '...';
|
||||||
console.log(`User ${doc.userId} mapped to: ${doc.userName}`);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (userError) {
|
} catch (userError) {
|
||||||
console.error('Failed to fetch user names:', userError);
|
console.error('Failed to fetch user names:', userError);
|
||||||
// If user fetch fails, keep the userId as fallback
|
|
||||||
allDocuments.forEach(doc => {
|
allDocuments.forEach(doc => {
|
||||||
doc.userName = doc.userId.substring(0, 8) + '...';
|
doc.userName = doc.userId.substring(0, 8) + '...';
|
||||||
});
|
});
|
||||||
@ -187,24 +159,22 @@ export default function AdminDocumentsPage() {
|
|||||||
setDocuments(allDocuments);
|
setDocuments(allDocuments);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to load documents');
|
setError(err.message || t('loadError'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBookingsAndDocuments();
|
fetchBookingsAndDocuments();
|
||||||
}, [fetchBookingsAndDocuments]);
|
}, [fetchBookingsAndDocuments]);
|
||||||
|
|
||||||
// Get unique users for filter (with names)
|
|
||||||
const uniqueUsers = Array.from(
|
const uniqueUsers = Array.from(
|
||||||
new Map(
|
new Map(
|
||||||
documents.map(doc => [doc.userId, { id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' }])
|
documents.map(doc => [doc.userId, { id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' }])
|
||||||
).values()
|
).values()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter documents
|
|
||||||
const filteredDocuments = documents.filter(doc => {
|
const filteredDocuments = documents.filter(doc => {
|
||||||
const matchesSearch = searchTerm === '' ||
|
const matchesSearch = searchTerm === '' ||
|
||||||
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
@ -220,13 +190,11 @@ export default function AdminDocumentsPage() {
|
|||||||
return matchesSearch && matchesUser && matchesQuote;
|
return matchesSearch && matchesUser && matchesQuote;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pagination
|
|
||||||
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
|
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
const endIndex = startIndex + itemsPerPage;
|
const endIndex = startIndex + itemsPerPage;
|
||||||
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
|
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
|
||||||
|
|
||||||
// Reset to page 1 when filters change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [searchTerm, filterUserId, filterQuoteNumber]);
|
}, [searchTerm, filterUserId, filterQuoteNumber]);
|
||||||
@ -269,13 +237,13 @@ export default function AdminDocumentsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteDocument = async (bookingId: string, documentId: string) => {
|
const handleDeleteDocument = async (bookingId: string, documentId: string) => {
|
||||||
if (!window.confirm('Supprimer définitivement ce document ?')) return;
|
if (!window.confirm(t('confirmDelete'))) return;
|
||||||
setDeletingId(documentId);
|
setDeletingId(documentId);
|
||||||
try {
|
try {
|
||||||
await deleteAdminDocument(bookingId, documentId);
|
await deleteAdminDocument(bookingId, documentId);
|
||||||
setDocuments(prev => prev.filter(d => d.id !== documentId));
|
setDocuments(prev => prev.filter(d => d.id !== documentId));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Erreur lors de la suppression');
|
setError(err.message || t('deleteError'));
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null);
|
setDeletingId(null);
|
||||||
}
|
}
|
||||||
@ -283,7 +251,6 @@ export default function AdminDocumentsPage() {
|
|||||||
|
|
||||||
const handleDownload = async (url: string, fileName: string) => {
|
const handleDownload = async (url: string, fileName: string) => {
|
||||||
try {
|
try {
|
||||||
// Try direct download first
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = fileName;
|
link.download = fileName;
|
||||||
@ -293,7 +260,6 @@ export default function AdminDocumentsPage() {
|
|||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
|
||||||
// If direct download doesn't work, try fetch with blob
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@ -320,7 +286,8 @@ export default function AdminDocumentsPage() {
|
|||||||
}, 100);
|
}, 100);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading file:', 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="flex items-center justify-center h-96">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -337,30 +304,25 @@ export default function AdminDocumentsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
title={t('title')}
|
||||||
<div>
|
description={t('subtitle')}
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<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="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 className="text-2xl font-bold text-gray-900">{documents.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<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">
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
{bookings.filter(b => b.documents && b.documents.length > 0).length}
|
{bookings.filter(b => b.documents && b.documents.length > 0).length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<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 className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -370,11 +332,11 @@ export default function AdminDocumentsPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Recherche
|
{t('filters.search')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Nom, type, route..."
|
placeholder={t('filters.searchPlaceholder')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
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"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Numéro de Devis
|
{t('filters.quoteNumber')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: #F2CAD5E1"
|
placeholder={t('filters.quoteNumberPlaceholder')}
|
||||||
value={filterQuoteNumber}
|
value={filterQuoteNumber}
|
||||||
onChange={e => setFilterQuoteNumber(e.target.value)}
|
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"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Utilisateur
|
{t('filters.user')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filterUserId}
|
value={filterUserId}
|
||||||
onChange={e => setFilterUserId(e.target.value)}
|
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"
|
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 => (
|
{uniqueUsers.map(user => (
|
||||||
<option key={user.id} value={user.id}>
|
<option key={user.id} value={user.id}>
|
||||||
{user.name}
|
{user.name}
|
||||||
@ -425,25 +387,25 @@ export default function AdminDocumentsPage() {
|
|||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<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>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Type
|
{t('table.type')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<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>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Route
|
{t('table.route')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Statut
|
{t('table.status')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Utilisateur
|
{t('table.user')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Actions
|
{t('table.actions')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -451,7 +413,7 @@ export default function AdminDocumentsPage() {
|
|||||||
{paginatedDocuments.length === 0 ? (
|
{paginatedDocuments.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
||||||
Aucun document trouvé
|
{t('table.empty')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
@ -519,27 +481,27 @@ export default function AdminDocumentsPage() {
|
|||||||
disabled={currentPage === 1}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||||
disabled={currentPage === totalPages}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-gray-700">
|
||||||
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
|
{t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span> {t('pagination.to')}{' '}
|
||||||
<span className="font-medium">{Math.min(endIndex, filteredDocuments.length)}</span> sur{' '}
|
<span className="font-medium">{Math.min(endIndex, filteredDocuments.length)}</span> {t('pagination.on')}{' '}
|
||||||
<span className="font-medium">{filteredDocuments.length}</span> résultats
|
<span className="font-medium">{filteredDocuments.length}</span> {t('pagination.results')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<select
|
||||||
value={itemsPerPage}
|
value={itemsPerPage}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@ -561,7 +523,7 @@ export default function AdminDocumentsPage() {
|
|||||||
disabled={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"
|
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">
|
<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>
|
</svg>
|
||||||
@ -600,7 +562,7 @@ export default function AdminDocumentsPage() {
|
|||||||
disabled={currentPage === totalPages}
|
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"
|
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">
|
<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>
|
</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">
|
<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" />
|
<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>
|
</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>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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">
|
<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" />
|
<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>
|
</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>
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { get, download } from '@/lib/api/client';
|
import { get, download } from '@/lib/api/client';
|
||||||
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
|
|
||||||
const LOGS_PREFIX = '/api/v1/logs';
|
const LOGS_PREFIX = '/api/v1/logs';
|
||||||
|
|
||||||
@ -103,6 +105,10 @@ function StatCard({
|
|||||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AdminLogsPage() {
|
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 [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
const [services, setServices] = useState<string[]>([]);
|
const [services, setServices] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -189,71 +195,68 @@ export default function AdminLogsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
title={t('title')}
|
||||||
<div>
|
description={t('subtitle')}
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Logs système</h1>
|
actions={
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Visualisation et export des logs applicatifs en temps réel
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={fetchLogs}
|
onClick={fetchLogs}
|
||||||
disabled={loading}
|
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' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
Actualiser
|
<span className="hidden sm:inline">{t('refresh')}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<button
|
<button
|
||||||
disabled={exportLoading || loading}
|
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" />
|
<Download className="h-4 w-4" />
|
||||||
{exportLoading ? 'Export...' : 'Exporter'}
|
<span className="hidden sm:inline">{exportLoading ? t('exporting') : t('export')}</span>
|
||||||
</button>
|
</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">
|
<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
|
<button
|
||||||
onClick={() => handleExport('csv')}
|
onClick={() => handleExport('csv')}
|
||||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleExport('json')}
|
onClick={() => handleExport('json')}
|
||||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Total logs"
|
label={t('stats.total')}
|
||||||
value={total}
|
value={total}
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
color="bg-blue-100 text-blue-600"
|
color="bg-blue-100 text-blue-600"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Erreurs"
|
label={t('stats.errors')}
|
||||||
value={countByLevel('error') + countByLevel('fatal')}
|
value={countByLevel('error') + countByLevel('fatal')}
|
||||||
icon={AlertTriangle}
|
icon={AlertTriangle}
|
||||||
color="bg-red-100 text-red-600"
|
color="bg-red-100 text-red-600"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Warnings"
|
label={t('stats.warnings')}
|
||||||
value={countByLevel('warn')}
|
value={countByLevel('warn')}
|
||||||
icon={AlertTriangle}
|
icon={AlertTriangle}
|
||||||
color="bg-yellow-100 text-yellow-600"
|
color="bg-yellow-100 text-yellow-600"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Info"
|
label={t('stats.info')}
|
||||||
value={countByLevel('info')}
|
value={countByLevel('info')}
|
||||||
icon={Info}
|
icon={Info}
|
||||||
color="bg-green-100 text-green-600"
|
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="bg-white rounded-lg border p-4">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<Filter className="h-4 w-4 text-gray-500" />
|
<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>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||||
{/* Service */}
|
{/* Service */}
|
||||||
<div>
|
<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
|
<select
|
||||||
value={filters.service}
|
value={filters.service}
|
||||||
onChange={e => setFilter('service', e.target.value)}
|
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"
|
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 => (
|
{services.map(s => (
|
||||||
<option key={s} value={s}>
|
<option key={s} value={s}>
|
||||||
{s}
|
{s}
|
||||||
@ -286,13 +289,13 @@ export default function AdminLogsPage() {
|
|||||||
|
|
||||||
{/* Level */}
|
{/* Level */}
|
||||||
<div>
|
<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
|
<select
|
||||||
value={filters.level}
|
value={filters.level}
|
||||||
onChange={e => setFilter('level', e.target.value)}
|
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"
|
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="error">Error</option>
|
||||||
<option value="fatal">Fatal</option>
|
<option value="fatal">Fatal</option>
|
||||||
<option value="warn">Warn</option>
|
<option value="warn">Warn</option>
|
||||||
@ -303,10 +306,10 @@ export default function AdminLogsPage() {
|
|||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Texte libre..."
|
placeholder={t('filters.searchPlaceholder')}
|
||||||
value={filters.search}
|
value={filters.search}
|
||||||
onChange={e => setFilter('search', e.target.value)}
|
onChange={e => setFilter('search', e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && fetchLogs()}
|
onKeyDown={e => e.key === 'Enter' && fetchLogs()}
|
||||||
@ -316,7 +319,7 @@ export default function AdminLogsPage() {
|
|||||||
|
|
||||||
{/* Start */}
|
{/* Start */}
|
||||||
<div>
|
<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
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={filters.startDate}
|
value={filters.startDate}
|
||||||
@ -327,7 +330,7 @@ export default function AdminLogsPage() {
|
|||||||
|
|
||||||
{/* End */}
|
{/* End */}
|
||||||
<div>
|
<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
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={filters.endDate}
|
value={filters.endDate}
|
||||||
@ -338,7 +341,7 @@ export default function AdminLogsPage() {
|
|||||||
|
|
||||||
{/* Limit + Apply */}
|
{/* Limit + Apply */}
|
||||||
<div className="flex flex-col justify-end gap-2">
|
<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">
|
<div className="flex gap-2">
|
||||||
<select
|
<select
|
||||||
value={filters.limit}
|
value={filters.limit}
|
||||||
@ -355,7 +358,7 @@ export default function AdminLogsPage() {
|
|||||||
disabled={loading}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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" />
|
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
Impossible de contacter le log-exporter : <strong>{error}</strong>
|
{t('errorBanner')} <strong>{error}</strong>
|
||||||
<br />
|
<br />
|
||||||
<span className="text-xs text-red-500">
|
<span className="text-xs text-red-500">
|
||||||
Vérifiez que le backend et le log-exporter sont démarrés.
|
{t('errorHint')}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -382,12 +385,12 @@ export default function AdminLogsPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Server className="h-4 w-4 text-gray-500" />
|
<Server className="h-4 w-4 text-gray-500" />
|
||||||
<span className="text-sm font-medium text-gray-700">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!loading && logs.length > 0 && (
|
{!loading && logs.length > 0 && (
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
Cliquer sur une ligne pour les détails
|
{t('clickHint')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -399,7 +402,7 @@ export default function AdminLogsPage() {
|
|||||||
) : logs.length === 0 && !error ? (
|
) : logs.length === 0 && !error ? (
|
||||||
<div className="flex flex-col items-center justify-center h-40 text-gray-400 gap-2">
|
<div className="flex flex-col items-center justify-center h-40 text-gray-400 gap-2">
|
||||||
<Bug className="h-8 w-8" />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@ -407,22 +410,22 @@ export default function AdminLogsPage() {
|
|||||||
<thead className="bg-gray-50 border-b">
|
<thead className="bg-gray-50 border-b">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
|
||||||
Timestamp
|
{t('table.timestamp')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
Service
|
{t('table.service')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
Niveau
|
{t('table.level')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
Contexte
|
{t('table.context')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
Message
|
{t('table.message')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
|
||||||
Req / Status
|
{t('table.req')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -435,7 +438,7 @@ export default function AdminLogsPage() {
|
|||||||
className={`cursor-pointer hover:bg-gray-50 transition-colors ${LEVEL_ROW_BG[log.level] || ''}`}
|
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">
|
<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',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -492,25 +495,25 @@ export default function AdminLogsPage() {
|
|||||||
<td colSpan={6} className="px-4 py-3">
|
<td colSpan={6} className="px-4 py-3">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||||
<div>
|
<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>
|
<p className="font-mono text-gray-800 mt-0.5">{log.timestamp}</p>
|
||||||
</div>
|
</div>
|
||||||
{log.reqId && (
|
{log.reqId && (
|
||||||
<div>
|
<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>
|
<p className="font-mono text-gray-800 mt-0.5 truncate">{log.reqId}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{log.response_time_ms && (
|
{log.response_time_ms && (
|
||||||
<div>
|
<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">
|
<p className="font-mono text-gray-800 mt-0.5">
|
||||||
{log.response_time_ms} ms
|
{log.response_time_ms} ms
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="col-span-2 md:col-span-4">
|
<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">
|
<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
|
{log.error
|
||||||
? `[ERROR] ${log.error}\n\n${log.message}`
|
? `[ERROR] ${log.error}\n\n${log.message}`
|
||||||
@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin';
|
import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin';
|
||||||
import { createOrganization, updateOrganization } from '@/lib/api/organizations';
|
import { createOrganization, updateOrganization } from '@/lib/api/organizations';
|
||||||
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
id: string;
|
id: string;
|
||||||
@ -29,6 +31,8 @@ interface Organization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminOrganizationsPage() {
|
export default function AdminOrganizationsPage() {
|
||||||
|
const t = useTranslations('dashboard.admin.organizations');
|
||||||
|
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -85,7 +89,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
setOrganizations(response.organizations || []);
|
setOrganizations(response.organizations || []);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to load organizations');
|
setError(err.message || t('loadError'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -94,10 +98,9 @@ export default function AdminOrganizationsPage() {
|
|||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
// Transform formData to match API expected format
|
|
||||||
const apiData = {
|
const apiData = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
type: formData.type as any, // OrganizationType
|
type: formData.type as any,
|
||||||
address_street: formData.address.street,
|
address_street: formData.address.street,
|
||||||
address_city: formData.address.city,
|
address_city: formData.address.city,
|
||||||
address_postal_code: formData.address.postalCode,
|
address_postal_code: formData.address.postalCode,
|
||||||
@ -111,7 +114,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (err: any) {
|
} 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);
|
setSelectedOrg(null);
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (err: any) {
|
} 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);
|
setVerifyingId(orgId);
|
||||||
const result = await verifySiret(orgId);
|
const result = await verifySiret(orgId);
|
||||||
if (result.verified) {
|
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();
|
await fetchOrganizations();
|
||||||
} else {
|
} else {
|
||||||
alert(result.message || 'SIRET invalide ou introuvable.');
|
alert(result.message || t('siretInvalid'));
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Erreur lors de la verification du SIRET');
|
alert(err.message || t('siretError'));
|
||||||
} finally {
|
} finally {
|
||||||
setVerifyingId(null);
|
setVerifyingId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApproveSiret = async (orgId: string) => {
|
const handleApproveSiret = async (orgId: string) => {
|
||||||
if (!confirm('Confirmer l\'approbation manuelle du SIRET/SIREN de cette organisation ?')) return;
|
if (!confirm(t('confirmApprove'))) return;
|
||||||
try {
|
try {
|
||||||
setVerifyingId(orgId);
|
setVerifyingId(orgId);
|
||||||
const result = await approveSiret(orgId);
|
const result = await approveSiret(orgId);
|
||||||
alert(result.message);
|
alert(result.message);
|
||||||
await fetchOrganizations();
|
await fetchOrganizations();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Erreur lors de l\'approbation');
|
alert(err.message || t('siretApproveError'));
|
||||||
} finally {
|
} finally {
|
||||||
setVerifyingId(null);
|
setVerifyingId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRejectSiret = async (orgId: string) => {
|
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 {
|
try {
|
||||||
setVerifyingId(orgId);
|
setVerifyingId(orgId);
|
||||||
const result = await rejectSiret(orgId);
|
const result = await rejectSiret(orgId);
|
||||||
alert(result.message);
|
alert(result.message);
|
||||||
await fetchOrganizations();
|
await fetchOrganizations();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Erreur lors du refus');
|
alert(err.message || t('siretRejectError'));
|
||||||
} finally {
|
} finally {
|
||||||
setVerifyingId(null);
|
setVerifyingId(null);
|
||||||
}
|
}
|
||||||
@ -213,12 +216,20 @@ export default function AdminOrganizationsPage() {
|
|||||||
setShowEditModal(true);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -226,21 +237,18 @@ export default function AdminOrganizationsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
title={t('title')}
|
||||||
<div>
|
description={t('subtitle')}
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Organization Management</h1>
|
actions={
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Manage all organizations in the system
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
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>
|
</button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
@ -261,53 +269,53 @@ export default function AdminOrganizationsPage() {
|
|||||||
org.type === 'CARRIER' ? 'bg-green-100 text-green-800' :
|
org.type === 'CARRIER' ? 'bg-green-100 text-green-800' :
|
||||||
'bg-purple-100 text-purple-800'
|
'bg-purple-100 text-purple-800'
|
||||||
}`}>
|
}`}>
|
||||||
{org.type.replace('_', ' ')}
|
{getTypeLabel(org.type)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
<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 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||||
}`}>
|
}`}>
|
||||||
{org.isActive ? 'Active' : 'Inactive'}
|
{org.isActive ? t('active') : t('inactive')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 text-sm text-gray-600 mb-4">
|
<div className="space-y-2 text-sm text-gray-600 mb-4">
|
||||||
{org.scac && (
|
{org.scac && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">SCAC:</span> {org.scac}
|
<span className="font-medium">{t('scac')}:</span> {org.scac}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{org.siren && (
|
{org.siren && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">SIREN:</span> {org.siren}
|
<span className="font-medium">{t('siren')}:</span> {org.siren}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">SIRET:</span>
|
<span className="font-medium">{t('siret')}:</span>
|
||||||
{org.siret ? (
|
{org.siret ? (
|
||||||
<>
|
<>
|
||||||
<span>{org.siret}</span>
|
<span>{org.siret}</span>
|
||||||
{org.siretVerified ? (
|
{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">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
<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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-400">Non renseigne</span>
|
<span className="text-gray-400">{t('notProvided')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{org.contact_email && (
|
{org.contact_email && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Email:</span> {org.contact_email}
|
<span className="font-medium">{t('email')}:</span> {org.contact_email}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -317,7 +325,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
onClick={() => openEditModal(org)}
|
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"
|
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>
|
</button>
|
||||||
{org.siret && !org.siretVerified && (
|
{org.siret && !org.siretVerified && (
|
||||||
<button
|
<button
|
||||||
@ -325,7 +333,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
disabled={verifyingId === org.id}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -337,7 +345,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
disabled={verifyingId === org.id}
|
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"
|
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>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@ -345,7 +353,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
disabled={verifyingId === org.id}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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="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">
|
<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">
|
<h2 className="text-xl font-bold mb-4">
|
||||||
{showCreateModal ? 'Create New Organization' : 'Edit Organization'}
|
{showCreateModal ? t('modal.createTitle') : t('modal.editTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4">
|
<form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="col-span-2">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -376,21 +384,21 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<select
|
||||||
value={formData.type}
|
value={formData.type}
|
||||||
onChange={e => setFormData({ ...formData, type: e.target.value })}
|
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"
|
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="FREIGHT_FORWARDER">{t('types.FREIGHT_FORWARDER')}</option>
|
||||||
<option value="CARRIER">Carrier</option>
|
<option value="CARRIER">{t('types.CARRIER')}</option>
|
||||||
<option value="SHIPPER">Shipper</option>
|
<option value="SHIPPER">{t('types.SHIPPER')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formData.type === 'CARRIER' && (
|
{formData.type === 'CARRIER' && (
|
||||||
<div>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required={formData.type === 'CARRIER'}
|
required={formData.type === 'CARRIER'}
|
||||||
@ -403,7 +411,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={9}
|
maxLength={9}
|
||||||
@ -414,19 +422,19 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={14}
|
maxLength={14}
|
||||||
value={formData.siret}
|
value={formData.siret}
|
||||||
onChange={e => setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })}
|
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"
|
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>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.eori}
|
value={formData.eori}
|
||||||
@ -436,7 +444,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={formData.contact_phone}
|
value={formData.contact_phone}
|
||||||
@ -446,7 +454,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.contact_email}
|
value={formData.contact_email}
|
||||||
@ -456,7 +464,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-2">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -470,7 +478,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -484,7 +492,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -498,7 +506,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.address.state}
|
value={formData.address.state}
|
||||||
@ -511,7 +519,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -526,7 +534,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-2">
|
<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
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={formData.logoUrl}
|
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"
|
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('modal.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin';
|
import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin';
|
||||||
import { createUser } from '@/lib/api/users';
|
import { createUser } from '@/lib/api/users';
|
||||||
import { getAllOrganizations } from '@/lib/api/admin';
|
import { getAllOrganizations } from '@/lib/api/admin';
|
||||||
import type { UserRole } from '@/types/api';
|
import type { UserRole } from '@/types/api';
|
||||||
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@ -24,6 +26,8 @@ interface Organization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
|
const t = useTranslations('dashboard.admin.users');
|
||||||
|
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -50,7 +54,6 @@ export default function AdminUsersPage() {
|
|||||||
password: '',
|
password: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch users and organizations
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
@ -67,7 +70,7 @@ export default function AdminUsersPage() {
|
|||||||
setOrganizations(orgsResponse.organizations || []);
|
setOrganizations(orgsResponse.organizations || []);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to load data');
|
setError(err.message || t('loadError'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -81,7 +84,7 @@ export default function AdminUsersPage() {
|
|||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (err: any) {
|
} 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);
|
setSelectedUser(null);
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (err: any) {
|
} 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);
|
setShowDeleteConfirm(false);
|
||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
} catch (err: any) {
|
} 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);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -160,21 +171,18 @@ export default function AdminUsersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
title={t('title')}
|
||||||
<div>
|
description={t('subtitle')}
|
||||||
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
|
actions={
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Manage all users in the system
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
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>
|
</button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
@ -189,22 +197,22 @@ export default function AdminUsersPage() {
|
|||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
User
|
{t('table.user')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Email
|
{t('table.email')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Role
|
{t('table.role')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Organization
|
{t('table.organization')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Status
|
{t('table.status')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Actions
|
{t('table.actions')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -225,7 +233,7 @@ export default function AdminUsersPage() {
|
|||||||
user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' :
|
user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' :
|
||||||
'bg-gray-100 text-gray-800'
|
'bg-gray-100 text-gray-800'
|
||||||
}`}>
|
}`}>
|
||||||
{user.role}
|
{getRoleLabel(user.role)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<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 ${
|
<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 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||||
}`}>
|
}`}>
|
||||||
{user.isActive ? 'Active' : 'Inactive'}
|
{user.isActive ? t('active') : t('inactive')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
<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)}
|
onClick={() => openEditModal(user)}
|
||||||
className="text-blue-600 hover:text-blue-900"
|
className="text-blue-600 hover:text-blue-900"
|
||||||
>
|
>
|
||||||
Edit
|
{t('edit')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => openDeleteConfirm(user)}
|
onClick={() => openDeleteConfirm(user)}
|
||||||
className="text-red-600 hover:text-red-900"
|
className="text-red-600 hover:text-red-900"
|
||||||
>
|
>
|
||||||
Delete
|
{t('delete')}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -262,10 +270,10 @@ export default function AdminUsersPage() {
|
|||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<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">
|
<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">
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
@ -275,7 +283,7 @@ export default function AdminUsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -285,7 +293,7 @@ export default function AdminUsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -295,27 +303,27 @@ export default function AdminUsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<select
|
||||||
value={formData.role}
|
value={formData.role}
|
||||||
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })}
|
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"
|
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="USER">{t('roles.USER')}</option>
|
||||||
<option value="MANAGER">Manager</option>
|
<option value="MANAGER">{t('roles.MANAGER')}</option>
|
||||||
<option value="ADMIN">Admin</option>
|
<option value="ADMIN">{t('roles.ADMIN')}</option>
|
||||||
<option value="VIEWER">Viewer</option>
|
<option value="VIEWER">{t('roles.VIEWER')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<select
|
||||||
required
|
required
|
||||||
value={formData.organizationId}
|
value={formData.organizationId}
|
||||||
onChange={e => setFormData({ ...formData, organizationId: e.target.value })}
|
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"
|
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 => (
|
{organizations.map(org => (
|
||||||
<option key={org.id} value={org.id}>
|
<option key={org.id} value={org.id}>
|
||||||
{org.name}
|
{org.name}
|
||||||
@ -325,7 +333,7 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Password (leave empty for auto-generated)
|
{t('modal.password')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
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"
|
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('modal.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Create
|
{t('modal.create')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -361,10 +369,10 @@ export default function AdminUsersPage() {
|
|||||||
{showEditModal && selectedUser && (
|
{showEditModal && selectedUser && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<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">
|
<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">
|
<form onSubmit={handleUpdate} className="space-y-4">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
disabled
|
disabled
|
||||||
@ -373,7 +381,7 @@ export default function AdminUsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -383,7 +391,7 @@ export default function AdminUsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -393,16 +401,16 @@ export default function AdminUsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<select
|
||||||
value={formData.role}
|
value={formData.role}
|
||||||
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })}
|
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"
|
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="USER">{t('roles.USER')}</option>
|
||||||
<option value="MANAGER">Manager</option>
|
<option value="MANAGER">{t('roles.MANAGER')}</option>
|
||||||
<option value="ADMIN">Admin</option>
|
<option value="ADMIN">{t('roles.ADMIN')}</option>
|
||||||
<option value="VIEWER">Viewer</option>
|
<option value="VIEWER">{t('roles.VIEWER')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end space-x-2 pt-4">
|
<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"
|
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('modal.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Update
|
{t('modal.update')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -433,10 +441,9 @@ export default function AdminUsersPage() {
|
|||||||
{showDeleteConfirm && selectedUser && (
|
{showDeleteConfirm && selectedUser && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<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">
|
<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">
|
<p className="text-gray-700 mb-6">
|
||||||
Are you sure you want to delete user <strong>{selectedUser.firstName} {selectedUser.lastName}</strong>?
|
{t('deleteConfirm.message', { firstName: selectedUser.firstName, lastName: selectedUser.lastName })}
|
||||||
This action cannot be undone.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-2">
|
||||||
<button
|
<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"
|
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('deleteConfirm.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||||
>
|
>
|
||||||
Delete
|
{t('deleteConfirm.confirm')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -21,9 +21,12 @@ interface BookingForm {
|
|||||||
volumeCBM: number;
|
volumeCBM: number;
|
||||||
weightKG: number;
|
weightKG: number;
|
||||||
palletCount: number;
|
palletCount: number;
|
||||||
priceUSD: number;
|
freightTotal: number;
|
||||||
priceEUR: number;
|
freightCurrency: string;
|
||||||
primaryCurrency: 'USD' | 'EUR';
|
fobTotal: number;
|
||||||
|
fobCurrency: string;
|
||||||
|
primaryCurrency: string;
|
||||||
|
totalPriceForSorting: number;
|
||||||
transitDays: number;
|
transitDays: number;
|
||||||
containerType: string;
|
containerType: string;
|
||||||
|
|
||||||
@ -61,9 +64,12 @@ function NewBookingPageContent() {
|
|||||||
volumeCBM: 0,
|
volumeCBM: 0,
|
||||||
weightKG: 0,
|
weightKG: 0,
|
||||||
palletCount: 0,
|
palletCount: 0,
|
||||||
priceUSD: 0,
|
freightTotal: 0,
|
||||||
priceEUR: 0,
|
freightCurrency: 'USD',
|
||||||
primaryCurrency: 'EUR',
|
fobTotal: 0,
|
||||||
|
fobCurrency: 'EUR',
|
||||||
|
primaryCurrency: 'USD',
|
||||||
|
totalPriceForSorting: 0,
|
||||||
transitDays: 0,
|
transitDays: 0,
|
||||||
containerType: '',
|
containerType: '',
|
||||||
documents: [],
|
documents: [],
|
||||||
@ -85,9 +91,12 @@ function NewBookingPageContent() {
|
|||||||
volumeCBM: parseFloat(searchParams.get('volumeCBM') || '0'),
|
volumeCBM: parseFloat(searchParams.get('volumeCBM') || '0'),
|
||||||
weightKG: parseFloat(searchParams.get('weightKG') || '0'),
|
weightKG: parseFloat(searchParams.get('weightKG') || '0'),
|
||||||
palletCount: parseInt(searchParams.get('palletCount') || '0'),
|
palletCount: parseInt(searchParams.get('palletCount') || '0'),
|
||||||
priceUSD: rateData.priceUSD,
|
freightTotal: rateData.priceBreakdown.totalFreight,
|
||||||
priceEUR: rateData.priceEUR,
|
freightCurrency: rateData.priceBreakdown.freightCurrency,
|
||||||
primaryCurrency: rateData.primaryCurrency as 'USD' | 'EUR',
|
fobTotal: rateData.priceBreakdown.totalFob,
|
||||||
|
fobCurrency: rateData.priceBreakdown.fobCurrency,
|
||||||
|
primaryCurrency: rateData.priceBreakdown.primaryCurrency || 'USD',
|
||||||
|
totalPriceForSorting: rateData.priceBreakdown.totalPriceForSorting,
|
||||||
transitDays: rateData.transitDays,
|
transitDays: rateData.transitDays,
|
||||||
containerType: rateData.containerType,
|
containerType: rateData.containerType,
|
||||||
}));
|
}));
|
||||||
@ -151,6 +160,14 @@ function NewBookingPageContent() {
|
|||||||
// Create FormData for multipart upload
|
// Create FormData for multipart upload
|
||||||
const formDataToSend = new FormData();
|
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
|
// Append all booking data
|
||||||
formDataToSend.append('carrierName', formData.carrierName);
|
formDataToSend.append('carrierName', formData.carrierName);
|
||||||
formDataToSend.append('carrierEmail', formData.carrierEmail);
|
formDataToSend.append('carrierEmail', formData.carrierEmail);
|
||||||
@ -159,8 +176,8 @@ function NewBookingPageContent() {
|
|||||||
formDataToSend.append('volumeCBM', formData.volumeCBM.toString());
|
formDataToSend.append('volumeCBM', formData.volumeCBM.toString());
|
||||||
formDataToSend.append('weightKG', formData.weightKG.toString());
|
formDataToSend.append('weightKG', formData.weightKG.toString());
|
||||||
formDataToSend.append('palletCount', formData.palletCount.toString());
|
formDataToSend.append('palletCount', formData.palletCount.toString());
|
||||||
formDataToSend.append('priceUSD', formData.priceUSD.toString());
|
formDataToSend.append('priceUSD', priceUSD.toString());
|
||||||
formDataToSend.append('priceEUR', formData.priceEUR.toString());
|
formDataToSend.append('priceEUR', priceEUR.toString());
|
||||||
formDataToSend.append('primaryCurrency', formData.primaryCurrency);
|
formDataToSend.append('primaryCurrency', formData.primaryCurrency);
|
||||||
formDataToSend.append('transitDays', formData.transitDays.toString());
|
formDataToSend.append('transitDays', formData.transitDays.toString());
|
||||||
formDataToSend.append('containerType', formData.containerType);
|
formDataToSend.append('containerType', formData.containerType);
|
||||||
@ -346,22 +363,28 @@ function NewBookingPageContent() {
|
|||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
Prix estimé
|
Prix estimé
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">Prix en EUR</p>
|
<p className="text-sm text-gray-600">Fret ({formData.freightCurrency})</p>
|
||||||
<p className="text-3xl font-bold text-green-600">
|
<p className="text-2xl font-bold text-gray-800">
|
||||||
{formatPrice(formData.priceEUR, 'EUR')}
|
{formatPrice(formData.freightTotal, formData.freightCurrency)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{formData.priceUSD > 0 && (
|
{formData.fobTotal > 0 && (
|
||||||
<div className="text-right">
|
<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">
|
<p className="text-xl font-semibold text-gray-700">
|
||||||
{formatPrice(formData.priceUSD, 'USD')}
|
{formatPrice(formData.fobTotal, formData.fobCurrency)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -562,10 +585,24 @@ function NewBookingPageContent() {
|
|||||||
{formData.documents.length} fichier(s)
|
{formData.documents.length} fichier(s)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex justify-between border-t pt-3 mt-3">
|
||||||
<span className="text-gray-900 font-semibold">Prix total :</span>
|
<span className="text-gray-900 font-semibold">Prix total :</span>
|
||||||
<span className="text-2xl font-bold text-green-600">
|
<span className="text-2xl font-bold text-green-600">
|
||||||
{formatPrice(formData.priceEUR, 'EUR')}
|
{formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,17 +1,15 @@
|
|||||||
/**
|
|
||||||
* Booking Detail Page
|
|
||||||
*
|
|
||||||
* Display full booking information
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getBooking } from '@/lib/api';
|
import { getBooking } from '@/lib/api';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
|
||||||
export default function BookingDetailPage() {
|
export default function BookingDetailPage() {
|
||||||
|
const t = useTranslations('dashboard.bookingDetail');
|
||||||
|
const locale = useLocale();
|
||||||
|
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const bookingId = params.id as string;
|
const bookingId = params.id as string;
|
||||||
|
|
||||||
@ -33,10 +31,18 @@ export default function BookingDetailPage() {
|
|||||||
return colors[status] || 'bg-gray-100 text-gray-800';
|
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 () => {
|
const downloadPDF = async () => {
|
||||||
try {
|
try {
|
||||||
// TODO: Implement PDF download functionality
|
alert(t('pdfNotImplemented'));
|
||||||
alert('PDF download functionality is not yet implemented');
|
|
||||||
console.log('Download PDF for booking:', bookingId);
|
console.log('Download PDF for booking:', bookingId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to download PDF:', error);
|
console.error('Failed to download PDF:', error);
|
||||||
@ -54,12 +60,12 @@ export default function BookingDetailPage() {
|
|||||||
if (!booking) {
|
if (!booking) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<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
|
<Link
|
||||||
href="/dashboard/bookings"
|
href="/dashboard/bookings"
|
||||||
className="mt-4 inline-block text-blue-600 hover:text-blue-700"
|
className="mt-4 inline-block text-blue-600 hover:text-blue-700"
|
||||||
>
|
>
|
||||||
← Back to bookings
|
{t('back')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -67,14 +73,13 @@ export default function BookingDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/bookings"
|
href="/dashboard/bookings"
|
||||||
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
|
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
|
||||||
>
|
>
|
||||||
← Back to bookings
|
{t('back')}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{booking.bookingNumber}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">{booking.bookingNumber}</h1>
|
||||||
@ -83,11 +88,11 @@ export default function BookingDetailPage() {
|
|||||||
booking.status
|
booking.status
|
||||||
)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
{booking.status}
|
{getStatusLabel(booking.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-3">
|
<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"
|
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>
|
</svg>
|
||||||
Download PDF
|
{t('downloadPdf')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Main Content */}
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
{/* Cargo Details */}
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<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">
|
<dl className="grid grid-cols-1 gap-4">
|
||||||
<div>
|
<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>
|
<dd className="mt-1 text-sm text-gray-900">{booking.cargoDescription}</dd>
|
||||||
</div>
|
</div>
|
||||||
{booking.specialInstructions && (
|
{booking.specialInstructions && (
|
||||||
<div>
|
<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>
|
<dd className="mt-1 text-sm text-gray-900">{booking.specialInstructions}</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Containers */}
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
<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>
|
</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{booking.containers?.map((container, index) => (
|
{booking.containers?.map((container, index) => (
|
||||||
<div key={container.id || index} className="border rounded-lg p-4">
|
<div key={container.id || index} className="border rounded-lg p-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<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>
|
<p className="text-sm text-gray-900">{container.type}</p>
|
||||||
</div>
|
</div>
|
||||||
{container.containerNumber && (
|
{container.containerNumber && (
|
||||||
<div>
|
<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>
|
<p className="text-sm text-gray-900">{container.containerNumber}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{container.sealNumber && (
|
{container.sealNumber && (
|
||||||
<div>
|
<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>
|
<p className="text-sm text-gray-900">{container.sealNumber}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{container.vgm && (
|
{container.vgm && (
|
||||||
<div>
|
<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>
|
<p className="text-sm text-gray-900">{container.vgm}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -165,47 +167,46 @@ export default function BookingDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shipper & Consignee */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="bg-white rounded-lg shadow p-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">
|
<dl className="space-y-2">
|
||||||
<div>
|
<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>
|
<dd className="text-sm text-gray-900">{booking.shipper.name}</dd>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<dd className="text-sm text-gray-900">{booking.shipper.contactName}</dd>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<dd className="text-sm text-gray-900">{booking.shipper.contactEmail}</dd>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<dd className="text-sm text-gray-900">{booking.shipper.contactPhone}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<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">
|
<dl className="space-y-2">
|
||||||
<div>
|
<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>
|
<dd className="text-sm text-gray-900">{booking.consignee.name}</dd>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<dd className="text-sm text-gray-900">{booking.consignee.contactName}</dd>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<dd className="text-sm text-gray-900">{booking.consignee.contactEmail}</dd>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<dd className="text-sm text-gray-900">{booking.consignee.contactPhone}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@ -213,11 +214,9 @@ export default function BookingDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Timeline */}
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<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">
|
<div className="flow-root">
|
||||||
<ul className="-mb-8">
|
<ul className="-mb-8">
|
||||||
<li>
|
<li>
|
||||||
@ -244,9 +243,9 @@ export default function BookingDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 pt-1.5">
|
<div className="min-w-0 flex-1 pt-1.5">
|
||||||
<div>
|
<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">
|
<p className="text-sm text-gray-500">
|
||||||
{new Date(booking.createdAt).toLocaleString()}
|
{new Date(booking.createdAt).toLocaleString(dateLocale)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -257,18 +256,17 @@ export default function BookingDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Info */}
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<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">
|
<dl className="space-y-3">
|
||||||
<div>
|
<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>
|
<dd className="mt-1 text-sm text-gray-900">{booking.id}</dd>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<dd className="mt-1 text-sm text-gray-900">
|
||||||
{new Date(booking.updatedAt).toLocaleString()}
|
{new Date(booking.updatedAt).toLocaleString(dateLocale)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@ -1,22 +1,21 @@
|
|||||||
/**
|
|
||||||
* Bookings List Page
|
|
||||||
*
|
|
||||||
* Display all bookings (standard + CSV) with filters and search
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { listBookings, listCsvBookings } from '@/lib/api';
|
import { listCsvBookings } from '@/lib/api';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { Plus, Clock } from 'lucide-react';
|
import { Plus, Clock } from 'lucide-react';
|
||||||
import ExportButton from '@/components/ExportButton';
|
import ExportButton from '@/components/ExportButton';
|
||||||
import { useSearchParams } from 'next/navigation';
|
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';
|
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
||||||
|
|
||||||
export default function BookingsListPage() {
|
export default function BookingsListPage() {
|
||||||
|
const t = useTranslations('dashboard.bookingsList');
|
||||||
|
const locale = useLocale();
|
||||||
|
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [searchType, setSearchType] = useState<SearchType>('route');
|
const [searchType, setSearchType] = useState<SearchType>('route');
|
||||||
@ -31,29 +30,24 @@ export default function BookingsListPage() {
|
|||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
// Fetch CSV bookings (fetch all for client-side filtering and pagination)
|
|
||||||
const { data: csvData, isLoading, error: csvError } = useQuery({
|
const { data: csvData, isLoading, error: csvError } = useQuery({
|
||||||
queryKey: ['csv-bookings'],
|
queryKey: ['csv-bookings'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
listCsvBookings({
|
listCsvBookings({
|
||||||
page: 1,
|
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);
|
if (csvError) console.error('CSV bookings error:', csvError);
|
||||||
|
|
||||||
// Filter bookings based on search term, search type, and status
|
|
||||||
const filterBookings = (bookings: any[]) => {
|
const filterBookings = (bookings: any[]) => {
|
||||||
let filtered = bookings;
|
let filtered = bookings;
|
||||||
|
|
||||||
// Filter by status first (if status filter is active)
|
|
||||||
if (statusFilter) {
|
if (statusFilter) {
|
||||||
filtered = filtered.filter((booking: any) => booking.status === statusFilter);
|
filtered = filtered.filter((booking: any) => booking.status === statusFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then filter by search term if provided
|
|
||||||
if (searchTerm.trim()) {
|
if (searchTerm.trim()) {
|
||||||
const term = searchTerm.toLowerCase();
|
const term = searchTerm.toLowerCase();
|
||||||
|
|
||||||
@ -70,7 +64,7 @@ export default function BookingsListPage() {
|
|||||||
case 'status':
|
case 'status':
|
||||||
return booking.status?.toLowerCase().includes(term);
|
return booking.status?.toLowerCase().includes(term);
|
||||||
case 'date':
|
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);
|
return date.includes(term);
|
||||||
case 'quote':
|
case 'quote':
|
||||||
return booking.id?.toLowerCase().includes(term) || booking.quoteNumber?.toLowerCase().includes(term);
|
return booking.id?.toLowerCase().includes(term) || booking.quoteNumber?.toLowerCase().includes(term);
|
||||||
@ -83,52 +77,42 @@ export default function BookingsListPage() {
|
|||||||
return filtered;
|
return filtered;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get all filtered bookings
|
|
||||||
const filteredBookings = filterBookings((csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const })));
|
const filteredBookings = filterBookings((csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const })));
|
||||||
|
|
||||||
// Calculate pagination
|
|
||||||
const totalBookings = filteredBookings.length;
|
const totalBookings = filteredBookings.length;
|
||||||
const totalPages = Math.ceil(totalBookings / ITEMS_PER_PAGE);
|
const totalPages = Math.ceil(totalBookings / ITEMS_PER_PAGE);
|
||||||
const startIndex = (page - 1) * ITEMS_PER_PAGE;
|
const startIndex = (page - 1) * ITEMS_PER_PAGE;
|
||||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||||
const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
|
const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
|
||||||
|
|
||||||
// Reset page to 1 when filters change
|
|
||||||
const resetPage = () => setPage(1);
|
const resetPage = () => setPage(1);
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ value: '', label: 'Tous les statuts' },
|
{ value: '', label: t('statusFilter.all') },
|
||||||
{ value: 'PENDING', label: 'En attente' },
|
{ value: 'PENDING', label: t('status.pending') },
|
||||||
{ value: 'ACCEPTED', label: 'Accepté' },
|
{ value: 'ACCEPTED', label: t('status.accepted') },
|
||||||
{ value: 'REJECTED', label: 'Refusé' },
|
{ value: 'REJECTED', label: t('status.rejected') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const searchTypeOptions = [
|
const searchTypeOptions: { value: SearchType; label: string }[] = [
|
||||||
{ value: 'route', label: 'Route (Origine/Destination)' },
|
{ value: 'route', label: t('searchType.route') },
|
||||||
{ value: 'pallets', label: 'Palettes/Colis' },
|
{ value: 'pallets', label: t('searchType.pallets') },
|
||||||
{ value: 'weight', label: 'Poids (kg)' },
|
{ value: 'weight', label: t('searchType.weight') },
|
||||||
{ value: 'status', label: 'Statut' },
|
{ value: 'status', label: t('searchType.status') },
|
||||||
{ value: 'date', label: 'Date' },
|
{ value: 'date', label: t('searchType.date') },
|
||||||
{ value: 'quote', label: 'N° Devis' },
|
{ value: 'quote', label: t('searchType.quote') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getPlaceholder = () => {
|
const getPlaceholder = () => {
|
||||||
switch (searchType) {
|
const keyMap: Record<SearchType, string> = {
|
||||||
case 'pallets':
|
route: 'searchPlaceholder.route',
|
||||||
return 'Rechercher par nombre de palettes...';
|
pallets: 'searchPlaceholder.pallets',
|
||||||
case 'weight':
|
weight: 'searchPlaceholder.weight',
|
||||||
return 'Rechercher par poids en kg...';
|
status: 'searchPlaceholder.status',
|
||||||
case 'route':
|
date: 'searchPlaceholder.date',
|
||||||
return 'Rechercher par ville (origine ou destination)...';
|
quote: 'searchPlaceholder.quote',
|
||||||
case 'status':
|
};
|
||||||
return 'Rechercher par statut...';
|
return t(keyMap[searchType] as any);
|
||||||
case 'date':
|
|
||||||
return 'Rechercher par date (JJ/MM/AAAA)...';
|
|
||||||
case 'quote':
|
|
||||||
return 'Rechercher par numéro de devis...';
|
|
||||||
default:
|
|
||||||
return 'Rechercher...';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
@ -141,76 +125,66 @@ export default function BookingsListPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
const getStatusLabel = (status: string) => {
|
||||||
const labels: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
PENDING: 'En attente',
|
PENDING: t('status.pending'),
|
||||||
ACCEPTED: 'Accepté',
|
ACCEPTED: t('status.accepted'),
|
||||||
REJECTED: 'Refusé',
|
REJECTED: t('status.rejected'),
|
||||||
};
|
};
|
||||||
return labels[status] || status;
|
return map[status] || status;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Bank transfer declared banner */}
|
|
||||||
{showTransferBanner && (
|
{showTransferBanner && (
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start justify-between">
|
<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">
|
<div className="flex items-start space-x-3">
|
||||||
<Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
<Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<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">
|
<p className="text-sm text-amber-700 mt-0.5">
|
||||||
Votre virement a été enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation.
|
{t('transferBanner.message')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowTransferBanner(false)} className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0">✕</button>
|
<button onClick={() => setShowTransferBanner(false)} className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0">✕</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
title={t('title')}
|
||||||
<div>
|
description={t('description')}
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Réservations</h1>
|
actions={
|
||||||
<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">
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
data={filteredBookings}
|
data={filteredBookings}
|
||||||
filename="reservations"
|
filename={t('exportFilename')}
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'id', label: 'ID' },
|
{ key: 'id', label: t('export.id') },
|
||||||
{ key: 'palletCount', label: 'Palettes', format: (v) => `${v || 0}` },
|
{ key: 'palletCount', label: t('export.pallets'), format: (v) => `${v || 0}` },
|
||||||
{ key: 'weightKG', label: 'Poids (kg)', format: (v) => `${v || 0}` },
|
{ key: 'weightKG', label: t('export.weight'), format: (v) => `${v || 0}` },
|
||||||
{ key: 'volumeCBM', label: 'Volume (CBM)', format: (v) => `${v || 0}` },
|
{ key: 'volumeCBM', label: t('export.volume'), format: (v) => `${v || 0}` },
|
||||||
{ key: 'origin', label: 'Origine' },
|
{ key: 'origin', label: t('export.origin') },
|
||||||
{ key: 'destination', label: 'Destination' },
|
{ key: 'destination', label: t('export.destination') },
|
||||||
{ key: 'carrierName', label: 'Transporteur' },
|
{ key: 'carrierName', label: t('export.carrier') },
|
||||||
{ key: 'status', label: 'Statut', format: (v) => {
|
{ key: 'status', label: t('export.status'), format: (v) => getStatusLabel(v) },
|
||||||
const labels: Record<string, string> = {
|
{ key: 'createdAt', label: t('export.createdAt'), format: (v) => v ? new Date(v).toLocaleDateString(dateLocale) : '' },
|
||||||
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') : '' },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/search-advanced"
|
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" />
|
<Plus className="h-4 w-4 sm:mr-2" />
|
||||||
Nouvelle Réservation
|
<span className="hidden sm:inline">{t('new')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="searchType" className="sr-only">
|
<label htmlFor="searchType" className="sr-only">
|
||||||
Type de recherche
|
{t('searchType.label')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="searchType"
|
id="searchType"
|
||||||
@ -230,7 +204,7 @@ export default function BookingsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label htmlFor="search" className="sr-only">
|
<label htmlFor="search" className="sr-only">
|
||||||
Rechercher
|
{t('search')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="status" className="sr-only">
|
<label htmlFor="status" className="sr-only">
|
||||||
Statut
|
{t('statusFilter.label')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="status"
|
id="status"
|
||||||
@ -284,39 +258,91 @@ export default function BookingsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bookings Table */}
|
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="px-6 py-12 text-center text-gray-500">
|
<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>
|
<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>
|
</div>
|
||||||
) : paginatedBookings && paginatedBookings.length > 0 ? (
|
) : 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">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<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>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Poids
|
{t('columns.weight')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Route
|
{t('columns.route')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Statut
|
{t('columns.status')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Date
|
{t('columns.date')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<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>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
N° Booking
|
{t('columns.bookingNumber')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -326,8 +352,8 @@ export default function BookingsListPage() {
|
|||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-medium text-gray-900">
|
||||||
{booking.type === 'csv'
|
{booking.type === 'csv'
|
||||||
? `${booking.palletCount} palette${booking.palletCount > 1 ? 's' : ''}`
|
? t('units.palletsCount', { count: booking.palletCount })
|
||||||
: `${booking.containers?.length || 0} conteneur${booking.containers?.length > 1 ? 's' : ''}`}
|
: t('units.containersCount', { count: booking.containers?.length || 0 })}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
|
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
|
||||||
@ -336,16 +362,16 @@ export default function BookingsListPage() {
|
|||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-medium text-gray-900">
|
||||||
{booking.type === 'csv'
|
{booking.type === 'csv'
|
||||||
? `${booking.weightKG} kg`
|
? t('units.kg', { value: booking.weightKG })
|
||||||
: booking.totalWeight
|
: booking.totalWeight
|
||||||
? `${booking.totalWeight} kg`
|
? t('units.kg', { value: booking.totalWeight })
|
||||||
: 'N/A'}
|
: 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
{booking.type === 'csv'
|
{booking.type === 'csv'
|
||||||
? `${booking.volumeCBM} CBM`
|
? t('units.cbm', { value: booking.volumeCBM })
|
||||||
: booking.totalVolume
|
: booking.totalVolume
|
||||||
? `${booking.totalVolume} CBM`
|
? t('units.cbm', { value: booking.totalVolume })
|
||||||
: ''}
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -372,7 +398,7 @@ export default function BookingsListPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{(booking.createdAt || booking.requestedAt)
|
{(booking.createdAt || booking.requestedAt)
|
||||||
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString('fr-FR', {
|
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString(dateLocale, {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@ -393,7 +419,6 @@ export default function BookingsListPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
{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="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">
|
<div className="flex-1 flex justify-between sm:hidden">
|
||||||
@ -402,22 +427,25 @@ export default function BookingsListPage() {
|
|||||||
disabled={page === 1}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage(page + 1)}
|
onClick={() => setPage(page + 1)}
|
||||||
disabled={page >= totalPages}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-gray-700">
|
||||||
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
|
{t.rich('pagination.showing', {
|
||||||
<span className="font-medium">{Math.min(endIndex, totalBookings)}</span> sur{' '}
|
start: startIndex + 1,
|
||||||
<span className="font-medium">{totalBookings}</span> résultat{totalBookings > 1 ? 's' : ''}
|
end: Math.min(endIndex, totalBookings),
|
||||||
|
total: totalBookings,
|
||||||
|
b: (chunks) => <span className="font-medium">{chunks}</span>,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -427,16 +455,14 @@ export default function BookingsListPage() {
|
|||||||
disabled={page === 1}
|
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"
|
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">
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Page numbers */}
|
|
||||||
{[...Array(totalPages)].map((_, idx) => {
|
{[...Array(totalPages)].map((_, idx) => {
|
||||||
const pageNum = idx + 1;
|
const pageNum = idx + 1;
|
||||||
// Show first page, last page, current page, and pages around current
|
|
||||||
const showPage = pageNum === 1 ||
|
const showPage = pageNum === 1 ||
|
||||||
pageNum === totalPages ||
|
pageNum === totalPages ||
|
||||||
(pageNum >= page - 1 && pageNum <= page + 1);
|
(pageNum >= page - 1 && pageNum <= page + 1);
|
||||||
@ -469,7 +495,7 @@ export default function BookingsListPage() {
|
|||||||
disabled={page >= totalPages}
|
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"
|
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">
|
<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>
|
</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"
|
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>
|
</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">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
{searchTerm || statusFilter
|
{searchTerm || statusFilter
|
||||||
? 'Essayez d\'ajuster vos filtres'
|
? t('empty.hasFilters')
|
||||||
: 'Commencez par créer votre première réservation'}
|
: t('empty.noBookings')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Link
|
<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"
|
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" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Nouvelle Réservation
|
{t('new')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
||||||
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import ExportButton from '@/components/ExportButton';
|
import ExportButton from '@/components/ExportButton';
|
||||||
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
|
|
||||||
interface Document {
|
interface Document {
|
||||||
id: string;
|
id: string;
|
||||||
@ -14,7 +16,6 @@ interface Document {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
uploadedAt?: Date;
|
uploadedAt?: Date;
|
||||||
// Legacy fields for compatibility
|
|
||||||
name?: string;
|
name?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
}
|
}
|
||||||
@ -29,6 +30,8 @@ interface DocumentWithBooking extends Document {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function UserDocumentsPage() {
|
export default function UserDocumentsPage() {
|
||||||
|
const t = useTranslations('dashboard.userDocuments');
|
||||||
|
const locale = useLocale();
|
||||||
const [bookings, setBookings] = useState<CsvBookingResponse[]>([]);
|
const [bookings, setBookings] = useState<CsvBookingResponse[]>([]);
|
||||||
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
|
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -39,41 +42,27 @@ export default function UserDocumentsPage() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
|
||||||
// Modal state for adding documents
|
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
|
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
|
||||||
const [uploadingFiles, setUploadingFiles] = useState(false);
|
const [uploadingFiles, setUploadingFiles] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Modal state for replacing documents
|
|
||||||
const [showReplaceModal, setShowReplaceModal] = useState(false);
|
const [showReplaceModal, setShowReplaceModal] = useState(false);
|
||||||
const [documentToReplace, setDocumentToReplace] = useState<DocumentWithBooking | null>(null);
|
const [documentToReplace, setDocumentToReplace] = useState<DocumentWithBooking | null>(null);
|
||||||
const [replacingFile, setReplacingFile] = useState(false);
|
const [replacingFile, setReplacingFile] = useState(false);
|
||||||
const replaceFileInputRef = useRef<HTMLInputElement>(null);
|
const replaceFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Dropdown menu state
|
|
||||||
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Helper function to get formatted quote number
|
|
||||||
const getQuoteNumber = (booking: CsvBookingResponse): string => {
|
const getQuoteNumber = (booking: CsvBookingResponse): string => {
|
||||||
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
|
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get file extension and type
|
|
||||||
const getFileType = (fileName: string): string => {
|
const getFileType = (fileName: string): string => {
|
||||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
pdf: 'PDF',
|
pdf: 'PDF', doc: 'Word', docx: 'Word', xls: 'Excel', xlsx: 'Excel',
|
||||||
doc: 'Word',
|
jpg: 'Image', jpeg: 'Image', png: 'Image', gif: 'Image', txt: 'Text', csv: 'CSV',
|
||||||
docx: 'Word',
|
|
||||||
xls: 'Excel',
|
|
||||||
xlsx: 'Excel',
|
|
||||||
jpg: 'Image',
|
|
||||||
jpeg: 'Image',
|
|
||||||
png: 'Image',
|
|
||||||
gif: 'Image',
|
|
||||||
txt: 'Text',
|
|
||||||
csv: 'CSV',
|
|
||||||
};
|
};
|
||||||
return typeMap[ext] || ext.toUpperCase();
|
return typeMap[ext] || ext.toUpperCase();
|
||||||
};
|
};
|
||||||
@ -81,23 +70,19 @@ export default function UserDocumentsPage() {
|
|||||||
const fetchBookingsAndDocuments = useCallback(async () => {
|
const fetchBookingsAndDocuments = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// Fetch all user's bookings (paginated, get all pages)
|
|
||||||
const response = await listCsvBookings({ page: 1, limit: 1000 });
|
const response = await listCsvBookings({ page: 1, limit: 1000 });
|
||||||
const allBookings = response.bookings || [];
|
const allBookings = response.bookings || [];
|
||||||
setBookings(allBookings);
|
setBookings(allBookings);
|
||||||
|
|
||||||
// Extract all documents from all bookings
|
|
||||||
const allDocuments: DocumentWithBooking[] = [];
|
const allDocuments: DocumentWithBooking[] = [];
|
||||||
|
|
||||||
allBookings.forEach((booking: CsvBookingResponse) => {
|
allBookings.forEach((booking: CsvBookingResponse) => {
|
||||||
if (booking.documents && booking.documents.length > 0) {
|
if (booking.documents && booking.documents.length > 0) {
|
||||||
booking.documents.forEach((doc: any, index: number) => {
|
booking.documents.forEach((doc: any, index: number) => {
|
||||||
// Use the correct field names from the backend
|
|
||||||
const actualFileName = doc.fileName || doc.name || 'document';
|
const actualFileName = doc.fileName || doc.name || 'document';
|
||||||
const actualFilePath = doc.filePath || doc.url || '';
|
const actualFilePath = doc.filePath || doc.url || '';
|
||||||
const actualMimeType = doc.mimeType || doc.type || '';
|
const actualMimeType = doc.mimeType || doc.type || '';
|
||||||
|
|
||||||
// Extract clean file type from mimeType or fileName
|
|
||||||
let fileType = '';
|
let fileType = '';
|
||||||
if (actualMimeType.includes('/')) {
|
if (actualMimeType.includes('/')) {
|
||||||
const parts = actualMimeType.split('/');
|
const parts = actualMimeType.split('/');
|
||||||
@ -128,17 +113,16 @@ export default function UserDocumentsPage() {
|
|||||||
setDocuments(allDocuments);
|
setDocuments(allDocuments);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Erreur lors du chargement des documents');
|
setError(err.message || t('error'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBookingsAndDocuments();
|
fetchBookingsAndDocuments();
|
||||||
}, [fetchBookingsAndDocuments]);
|
}, [fetchBookingsAndDocuments]);
|
||||||
|
|
||||||
// Filter documents
|
|
||||||
const filteredDocuments = documents.filter(doc => {
|
const filteredDocuments = documents.filter(doc => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
searchTerm === '' ||
|
searchTerm === '' ||
|
||||||
@ -156,13 +140,11 @@ export default function UserDocumentsPage() {
|
|||||||
return matchesSearch && matchesStatus && matchesQuote;
|
return matchesSearch && matchesStatus && matchesQuote;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pagination
|
|
||||||
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
|
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
const endIndex = startIndex + itemsPerPage;
|
const endIndex = startIndex + itemsPerPage;
|
||||||
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
|
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
|
||||||
|
|
||||||
// Reset to page 1 when filters change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [searchTerm, filterStatus, filterQuoteNumber]);
|
}, [searchTerm, filterStatus, filterQuoteNumber]);
|
||||||
@ -205,18 +187,12 @@ export default function UserDocumentsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
const getStatusLabel = (status: string) => {
|
||||||
const labels: Record<string, string> = {
|
const key = status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
|
||||||
PENDING: 'En attente',
|
return t(`statuses.${key}` as any) || status;
|
||||||
ACCEPTED: 'Accepté',
|
|
||||||
REJECTED: 'Refusé',
|
|
||||||
CANCELLED: 'Annulé',
|
|
||||||
};
|
|
||||||
return labels[status] || status;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (url: string, fileName: string) => {
|
const handleDownload = async (url: string, fileName: string) => {
|
||||||
try {
|
try {
|
||||||
// Try direct download first
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = fileName;
|
link.download = fileName;
|
||||||
@ -226,18 +202,10 @@ export default function UserDocumentsPage() {
|
|||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
|
||||||
// If direct download doesn't work, try fetch with blob
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, { mode: 'cors', credentials: 'include' });
|
||||||
mode: 'cors',
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const blobUrl = window.URL.createObjectURL(blob);
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
const link2 = document.createElement('a');
|
const link2 = document.createElement('a');
|
||||||
@ -251,15 +219,12 @@ export default function UserDocumentsPage() {
|
|||||||
console.error('Fetch download failed:', fetchError);
|
console.error('Fetch download failed:', fetchError);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Error downloading file:', error);
|
console.error('Error downloading file:', err);
|
||||||
alert(
|
alert(`${t('downloadError')}: ${err instanceof Error ? err.message : ''}`);
|
||||||
`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get bookings available for adding documents (PENDING or ACCEPTED)
|
|
||||||
const bookingsAvailableForDocuments = bookings.filter(
|
const bookingsAvailableForDocuments = bookings.filter(
|
||||||
b => b.status === 'PENDING' || b.status === 'ACCEPTED'
|
b => b.status === 'PENDING' || b.status === 'ACCEPTED'
|
||||||
);
|
);
|
||||||
@ -271,14 +236,12 @@ export default function UserDocumentsPage() {
|
|||||||
const handleCloseModal = () => {
|
const handleCloseModal = () => {
|
||||||
setShowAddModal(false);
|
setShowAddModal(false);
|
||||||
setSelectedBookingId(null);
|
setSelectedBookingId(null);
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileUpload = async () => {
|
const handleFileUpload = async () => {
|
||||||
if (!selectedBookingId || !fileInputRef.current?.files?.length) {
|
if (!selectedBookingId || !fileInputRef.current?.files?.length) {
|
||||||
alert('Veuillez sélectionner une réservation et au moins un fichier');
|
alert(t('addDocument.noBookingError'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,50 +256,34 @@ export default function UserDocumentsPage() {
|
|||||||
const token = localStorage.getItem('access_token');
|
const token = localStorage.getItem('access_token');
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`,
|
`${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) {
|
if (!response.ok) throw new Error(t('addDocument.errorMessage'));
|
||||||
throw new Error('Erreur lors de l\'ajout des documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
alert('Documents ajoutés avec succès!');
|
alert(t('addDocument.successMessage'));
|
||||||
handleCloseModal();
|
handleCloseModal();
|
||||||
fetchBookingsAndDocuments(); // Refresh the list
|
fetchBookingsAndDocuments();
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Error uploading documents:', error);
|
console.error('Error uploading documents:', err);
|
||||||
alert(
|
alert(`${t('addDocument.errorMessage')}: ${err instanceof Error ? err.message : ''}`);
|
||||||
`Erreur lors de l'ajout des documents: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingFiles(false);
|
setUploadingFiles(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle dropdown menu
|
|
||||||
const toggleDropdown = (docId: string) => {
|
const toggleDropdown = (docId: string) => {
|
||||||
setOpenDropdownId(openDropdownId === docId ? null : docId);
|
setOpenDropdownId(openDropdownId === docId ? null : docId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = () => {
|
const handleClickOutside = () => setOpenDropdownId(null);
|
||||||
setOpenDropdownId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (openDropdownId) {
|
if (openDropdownId) {
|
||||||
document.addEventListener('click', handleClickOutside);
|
document.addEventListener('click', handleClickOutside);
|
||||||
return () => document.removeEventListener('click', handleClickOutside);
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
}
|
}
|
||||||
}, [openDropdownId]);
|
}, [openDropdownId]);
|
||||||
|
|
||||||
// Replace document handlers
|
|
||||||
const handleReplaceClick = (doc: DocumentWithBooking) => {
|
const handleReplaceClick = (doc: DocumentWithBooking) => {
|
||||||
setOpenDropdownId(null);
|
setOpenDropdownId(null);
|
||||||
setDocumentToReplace(doc);
|
setDocumentToReplace(doc);
|
||||||
@ -346,14 +293,12 @@ export default function UserDocumentsPage() {
|
|||||||
const handleCloseReplaceModal = () => {
|
const handleCloseReplaceModal = () => {
|
||||||
setShowReplaceModal(false);
|
setShowReplaceModal(false);
|
||||||
setDocumentToReplace(null);
|
setDocumentToReplace(null);
|
||||||
if (replaceFileInputRef.current) {
|
if (replaceFileInputRef.current) replaceFileInputRef.current.value = '';
|
||||||
replaceFileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReplaceDocument = async () => {
|
const handleReplaceDocument = async () => {
|
||||||
if (!documentToReplace || !replaceFileInputRef.current?.files?.length) {
|
if (!documentToReplace || !replaceFileInputRef.current?.files?.length) {
|
||||||
alert('Veuillez sélectionner un fichier de remplacement');
|
alert(t('replaceDocument.noFileError'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -365,28 +310,20 @@ export default function UserDocumentsPage() {
|
|||||||
const token = localStorage.getItem('access_token');
|
const token = localStorage.getItem('access_token');
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`,
|
`${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) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
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();
|
handleCloseReplaceModal();
|
||||||
fetchBookingsAndDocuments(); // Refresh the list
|
fetchBookingsAndDocuments();
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Error replacing document:', error);
|
console.error('Error replacing document:', err);
|
||||||
alert(
|
alert(`${t('replaceDocument.errorMessage')}: ${err instanceof Error ? err.message : ''}`);
|
||||||
`Erreur lors du remplacement: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setReplacingFile(false);
|
setReplacingFile(false);
|
||||||
}
|
}
|
||||||
@ -397,7 +334,7 @@ export default function UserDocumentsPage() {
|
|||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -405,68 +342,52 @@ export default function UserDocumentsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
title={t('title')}
|
||||||
<div>
|
description={t('description')}
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Mes Documents</h1>
|
actions={
|
||||||
<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">
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
data={filteredDocuments}
|
data={filteredDocuments}
|
||||||
filename="documents"
|
filename="documents"
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'fileName', label: 'Nom du fichier' },
|
{ key: 'fileName', label: t('export.fileName') },
|
||||||
{ key: 'fileType', label: 'Type' },
|
{ key: 'fileType', label: t('export.type') },
|
||||||
{ key: 'quoteNumber', label: 'N° de Devis' },
|
{ key: 'quoteNumber', label: t('export.quoteNumber') },
|
||||||
{ key: 'route', label: 'Route' },
|
{ key: 'route', label: t('export.route') },
|
||||||
{ key: 'carrierName', label: 'Transporteur' },
|
{ key: 'carrierName', label: t('export.carrier') },
|
||||||
{ key: 'status', label: 'Statut', format: (v) => {
|
{ key: 'status', label: t('export.status'), format: (v) => getStatusLabel(v) },
|
||||||
const labels: Record<string, string> = {
|
{ key: 'uploadedAt', label: t('export.uploadedAt'), format: (v) => v ? new Date(v).toLocaleDateString(locale) : '' },
|
||||||
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') : '' },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddDocumentClick}
|
onClick={handleAddDocumentClick}
|
||||||
disabled={bookingsAvailableForDocuments.length === 0}
|
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">
|
<svg className="w-4 h-4 sm:mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
Ajouter un document
|
<span className="hidden sm:inline">{t('addDocument.buttonLabel')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<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="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 className="text-2xl font-bold text-gray-900">{documents.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<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">
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
{bookings.filter(b => b.documents && b.documents.length > 0).length}
|
{bookings.filter(b => b.documents && b.documents.length > 0).length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<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 className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div>
|
||||||
</div>
|
</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="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 className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Nom, type, route, transporteur..."
|
placeholder={t('filters.searchPlaceholder')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
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"
|
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>
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: #F2CAD5E1"
|
placeholder="Ex: #F2CAD5E1"
|
||||||
@ -495,16 +416,16 @@ export default function UserDocumentsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<select
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={e => setFilterStatus(e.target.value)}
|
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"
|
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="all">{t('filters.allStatuses')}</option>
|
||||||
<option value="PENDING">En attente</option>
|
<option value="PENDING">{t('statuses.PENDING')}</option>
|
||||||
<option value="ACCEPTED">Accepté</option>
|
<option value="ACCEPTED">{t('statuses.ACCEPTED')}</option>
|
||||||
<option value="REJECTED">Refusé</option>
|
<option value="REJECTED">{t('statuses.REJECTED')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -522,36 +443,20 @@ export default function UserDocumentsPage() {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.documentName')}</th>
|
||||||
Nom du Document
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.type')}</th>
|
||||||
</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">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.route')}</th>
|
||||||
Type
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.carrier')}</th>
|
||||||
</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-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{t('table.actions')}</th>
|
||||||
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>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{paginatedDocuments.length === 0 ? (
|
{paginatedDocuments.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
||||||
{documents.length === 0
|
{documents.length === 0 ? t('empty.noDocuments') : t('empty.noMatch')}
|
||||||
? 'Aucun document trouvé. Ajoutez des documents à vos réservations.'
|
|
||||||
: 'Aucun document ne correspond aux filtres sélectionnés.'}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
@ -562,9 +467,7 @@ export default function UserDocumentsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="mr-2">
|
<span className="mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
|
||||||
{getDocumentIcon(doc.fileType || doc.type)}
|
|
||||||
</span>
|
|
||||||
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
|
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -578,9 +481,7 @@ export default function UserDocumentsPage() {
|
|||||||
<div className="text-sm text-gray-900">{doc.carrierName}</div>
|
<div className="text-sm text-gray-900">{doc.carrierName}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span
|
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}>
|
||||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}
|
|
||||||
>
|
|
||||||
{getStatusLabel(doc.status)}
|
{getStatusLabel(doc.status)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@ -592,20 +493,15 @@ export default function UserDocumentsPage() {
|
|||||||
toggleDropdown(`${doc.bookingId}-${doc.id}`);
|
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"
|
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"
|
title={t('table.actions')}
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<circle cx="12" cy="5" r="2" />
|
<circle cx="12" cy="5" r="2" />
|
||||||
<circle cx="12" cy="12" r="2" />
|
<circle cx="12" cy="12" r="2" />
|
||||||
<circle cx="12" cy="19" r="2" />
|
<circle cx="12" cy="19" r="2" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
|
||||||
{openDropdownId === `${doc.bookingId}-${doc.id}` && (
|
{openDropdownId === `${doc.bookingId}-${doc.id}` && (
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"
|
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"
|
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
<svg
|
<svg className="w-4 h-4 mr-3 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="w-4 h-4 mr-3 text-green-600"
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
Télécharger
|
{t('actions.download')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleReplaceClick(doc)}
|
onClick={() => handleReplaceClick(doc)}
|
||||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
<svg
|
<svg className="w-4 h-4 mr-3 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="w-4 h-4 mr-3 text-blue-600"
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
Remplacer
|
{t('actions.replace')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -673,29 +549,29 @@ export default function UserDocumentsPage() {
|
|||||||
disabled={currentPage === 1}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||||
disabled={currentPage === totalPages}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-gray-700">
|
||||||
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
|
{t('pagination.showing', {
|
||||||
<span className="font-medium">
|
from: startIndex + 1,
|
||||||
{Math.min(endIndex, filteredDocuments.length)}
|
to: Math.min(endIndex, filteredDocuments.length),
|
||||||
</span>{' '}
|
total: filteredDocuments.length,
|
||||||
sur <span className="font-medium">{filteredDocuments.length}</span> résultats
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<select
|
||||||
value={itemsPerPage}
|
value={itemsPerPage}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
@ -711,26 +587,18 @@ export default function UserDocumentsPage() {
|
|||||||
<option value={100}>100</option>
|
<option value={100}>100</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<nav
|
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||||
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
|
||||||
aria-label="Pagination"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||||
disabled={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"
|
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">
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Page numbers */}
|
|
||||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
let pageNum;
|
let pageNum;
|
||||||
if (totalPages <= 5) {
|
if (totalPages <= 5) {
|
||||||
@ -763,13 +631,9 @@ export default function UserDocumentsPage() {
|
|||||||
disabled={currentPage === totalPages}
|
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"
|
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">
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
@ -783,57 +647,35 @@ export default function UserDocumentsPage() {
|
|||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<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">
|
<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} />
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal panel */}
|
|
||||||
<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="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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
<div className="sm:flex sm:items-start">
|
<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">
|
<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
|
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="h-6 w-6 text-blue-600"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
<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">
|
<h3 className="text-lg leading-6 font-medium text-gray-900">{t('addDocument.modalTitle')}</h3>
|
||||||
Ajouter un document
|
|
||||||
</h3>
|
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('addDocument.selectBooking')}</label>
|
||||||
Sélectionner une réservation
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={selectedBookingId || ''}
|
value={selectedBookingId || ''}
|
||||||
onChange={e => setSelectedBookingId(e.target.value)}
|
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"
|
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 => (
|
{bookingsAvailableForDocuments.map(booking => (
|
||||||
<option key={booking.id} value={booking.id}>
|
<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>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('addDocument.filesToAdd')}</label>
|
||||||
Fichiers à ajouter
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@ -841,9 +683,7 @@ export default function UserDocumentsPage() {
|
|||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
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"
|
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">
|
<p className="mt-1 text-xs text-gray-500">{t('addDocument.acceptedFormats')}</p>
|
||||||
Formats acceptés: PDF, Word, Excel, Images (max 10 fichiers)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -858,37 +698,20 @@ export default function UserDocumentsPage() {
|
|||||||
>
|
>
|
||||||
{uploadingFiles ? (
|
{uploadingFiles ? (
|
||||||
<>
|
<>
|
||||||
<svg
|
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
fill="none"
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
Envoi en cours...
|
{t('addDocument.uploading')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : t('addDocument.add')}
|
||||||
'Ajouter'
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCloseModal}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -900,60 +723,34 @@ export default function UserDocumentsPage() {
|
|||||||
{showReplaceModal && documentToReplace && (
|
{showReplaceModal && documentToReplace && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<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">
|
<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} />
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
|
||||||
onClick={handleCloseReplaceModal}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal panel */}
|
|
||||||
<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="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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
<div className="sm:flex sm:items-start">
|
<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">
|
<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
|
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="h-6 w-6 text-blue-600"
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
<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">
|
<h3 className="text-lg leading-6 font-medium text-gray-900">{t('replaceDocument.modalTitle')}</h3>
|
||||||
Remplacer le document
|
|
||||||
</h3>
|
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
{/* Current document info */}
|
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
<p className="text-sm text-gray-500">Document actuel:</p>
|
<p className="text-sm text-gray-500">{t('replaceDocument.currentDocument')}</p>
|
||||||
<p className="text-sm font-medium text-gray-900 mt-1">
|
<p className="text-sm font-medium text-gray-900 mt-1">{documentToReplace.fileName}</p>
|
||||||
{documentToReplace.fileName}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Réservation: {documentToReplace.quoteNumber} - {documentToReplace.route}
|
{t('replaceDocument.booking')}: {documentToReplace.quoteNumber} - {documentToReplace.route}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('replaceDocument.newFile')}</label>
|
||||||
Nouveau fichier
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
ref={replaceFileInputRef}
|
ref={replaceFileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
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"
|
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">
|
<p className="mt-1 text-xs text-gray-500">{t('replaceDocument.acceptedFormats')}</p>
|
||||||
Formats acceptés: PDF, Word, Excel, Images
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -968,37 +765,20 @@ export default function UserDocumentsPage() {
|
|||||||
>
|
>
|
||||||
{replacingFile ? (
|
{replacingFile ? (
|
||||||
<>
|
<>
|
||||||
<svg
|
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
fill="none"
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
Remplacement en cours...
|
{t('replaceDocument.replacing')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : t('replaceDocument.replace')}
|
||||||
'Remplacer'
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCloseReplaceModal}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,16 +1,11 @@
|
|||||||
/**
|
|
||||||
* Dashboard Layout
|
|
||||||
*
|
|
||||||
* Layout with sidebar navigation for dashboard pages
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useAuth } from '@/lib/context/auth-context';
|
import { useAuth } from '@/lib/context/auth-context';
|
||||||
import Link from 'next/link';
|
import { Link, usePathname, useRouter } from '@/i18n/navigation';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import NotificationDropdown from '@/components/NotificationDropdown';
|
import NotificationDropdown from '@/components/NotificationDropdown';
|
||||||
|
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||||
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
|
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import {
|
import {
|
||||||
@ -24,6 +19,8 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Lock,
|
Lock,
|
||||||
Key,
|
Key,
|
||||||
|
Home,
|
||||||
|
User,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSubscription } from '@/lib/context/subscription-context';
|
import { useSubscription } from '@/lib/context/subscription-context';
|
||||||
import StatusBadge from '@/components/ui/StatusBadge';
|
import StatusBadge from '@/components/ui/StatusBadge';
|
||||||
@ -34,6 +31,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
const { hasFeature, subscription } = useSubscription();
|
const { hasFeature, subscription } = useSubscription();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const t = useTranslations('dashboard');
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -55,16 +53,15 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: PlanFeature }> = [
|
const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: PlanFeature }> = [
|
||||||
{ name: 'Tableau de bord', href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
|
{ name: t('nav.dashboard'), href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
|
||||||
{ name: 'Réservations', href: '/dashboard/bookings', icon: Package },
|
{ name: t('nav.bookings'), href: '/dashboard/bookings', icon: Package },
|
||||||
{ name: 'Documents', href: '/dashboard/documents', icon: FileText },
|
{ name: t('nav.documents'), href: '/dashboard/documents', icon: FileText },
|
||||||
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' },
|
{ name: t('nav.tracking'), href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' },
|
||||||
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
|
{ name: t('nav.wiki'), href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
|
||||||
{ name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 },
|
{ name: t('nav.organization'), href: '/dashboard/settings/organization', icon: Building2 },
|
||||||
{ name: 'Clés API', href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature },
|
{ name: t('nav.apiKeys'), href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature },
|
||||||
// ADMIN and MANAGER only navigation items
|
|
||||||
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
|
...(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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Mobile sidebar backdrop */}
|
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
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
|
<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 ${
|
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'
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Logo */}
|
|
||||||
<div className="flex items-center justify-between h-16 px-6 border-b">
|
<div className="flex items-center justify-between h-16 px-6 border-b">
|
||||||
<Link href="/dashboard" className="text-2xl font-bold text-blue-600">
|
<Link href="/dashboard" className="text-2xl font-bold text-blue-600">
|
||||||
<Image
|
<Image
|
||||||
@ -119,7 +113,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto">
|
<nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto">
|
||||||
{navigation.map(item => {
|
{navigation.map(item => {
|
||||||
const locked = item.requiredFeature && !hasFeature(item.requiredFeature);
|
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' && (
|
{user?.role === 'ADMIN' && (
|
||||||
<div className="pt-4 mt-4 border-t">
|
<div className="pt-4 mt-4 border-t">
|
||||||
<AdminPanelDropdown />
|
<AdminPanelDropdown />
|
||||||
@ -150,7 +142,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User section */}
|
|
||||||
<div className="border-t p-4">
|
<div className="border-t p-4">
|
||||||
<div className="flex items-center space-x-3 mb-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">
|
<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"
|
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" />
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
Déconnexion
|
{t('logout')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="lg:pl-64">
|
<div className="lg:pl-64">
|
||||||
{/* Top bar */}
|
<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">
|
||||||
<div className="sticky top-0 z-10 flex items-center justify-between h-16 px-6 bg-white border-b">
|
|
||||||
<button
|
<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)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 lg:flex-none">
|
<div className="flex-1 lg:flex-none">
|
||||||
<h1 className="text-xl font-semibold text-gray-900">
|
<h1 className="text-base lg:text-xl font-semibold text-gray-900 ml-3 lg:ml-0">
|
||||||
{navigation.find(item => isActive(item.href))?.name || 'Tableau de bord'}
|
{navigation.find(item => isActive(item.href))?.name || t('topbar.defaultTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-3 lg:space-x-4">
|
||||||
{/* Notifications */}
|
<LanguageSwitcher variant="light" />
|
||||||
<NotificationDropdown />
|
<NotificationDropdown />
|
||||||
|
|
||||||
{/* User Initials */}
|
<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">
|
||||||
<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">
|
|
||||||
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page content */}
|
<main className="p-4 lg:p-6 pb-24 lg:pb-6">{children}</main>
|
||||||
<main className="p-6">{children}</main>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,14 +1,9 @@
|
|||||||
/**
|
|
||||||
* Notifications Page
|
|
||||||
*
|
|
||||||
* Full page view for managing all user notifications
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
listNotifications,
|
listNotifications,
|
||||||
markNotificationAsRead,
|
markNotificationAsRead,
|
||||||
@ -20,12 +15,14 @@ import { Trash2, CheckCheck, Filter, Bell, ChevronLeft, ChevronRight, Package, R
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
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 [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Fetch notifications with pagination
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['notifications', 'page', selectedFilter, currentPage],
|
queryKey: ['notifications', 'page', selectedFilter, currentPage],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@ -41,7 +38,6 @@ export default function NotificationsPage() {
|
|||||||
const totalPages = Math.ceil(total / 20);
|
const totalPages = Math.ceil(total / 20);
|
||||||
const unreadCount = notifications.filter((n: NotificationResponse) => !n.read).length;
|
const unreadCount = notifications.filter((n: NotificationResponse) => !n.read).length;
|
||||||
|
|
||||||
// Mark single notification as read
|
|
||||||
const markAsReadMutation = useMutation({
|
const markAsReadMutation = useMutation({
|
||||||
mutationFn: markNotificationAsRead,
|
mutationFn: markNotificationAsRead,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@ -49,7 +45,6 @@ export default function NotificationsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark all as read
|
|
||||||
const markAllAsReadMutation = useMutation({
|
const markAllAsReadMutation = useMutation({
|
||||||
mutationFn: markAllNotificationsAsRead,
|
mutationFn: markAllNotificationsAsRead,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@ -57,7 +52,6 @@ export default function NotificationsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete notification
|
|
||||||
const deleteNotificationMutation = useMutation({
|
const deleteNotificationMutation = useMutation({
|
||||||
mutationFn: deleteNotification,
|
mutationFn: deleteNotification,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@ -70,7 +64,6 @@ export default function NotificationsPage() {
|
|||||||
markAsReadMutation.mutate(notification.id);
|
markAsReadMutation.mutate(notification.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to actionUrl if available
|
|
||||||
if (notification.actionUrl) {
|
if (notification.actionUrl) {
|
||||||
router.push(notification.actionUrl);
|
router.push(notification.actionUrl);
|
||||||
}
|
}
|
||||||
@ -78,7 +71,7 @@ export default function NotificationsPage() {
|
|||||||
|
|
||||||
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
|
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')) {
|
if (confirm(t('deleteConfirm'))) {
|
||||||
deleteNotificationMutation.mutate(notificationId);
|
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';
|
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 getNotificationIcon = (type: string): ReactNode => {
|
||||||
const iconClass = "h-8 w-8";
|
const iconClass = "h-8 w-8";
|
||||||
const icons: Record<string, ReactNode> = {
|
const icons: Record<string, ReactNode> = {
|
||||||
@ -120,11 +123,11 @@ export default function NotificationsPage() {
|
|||||||
const diffHours = Math.floor(diffMs / 3600000);
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
if (diffMins < 1) return 'A l\'instant';
|
if (diffMins < 1) return t('time.now');
|
||||||
if (diffMins < 60) return `Il y a ${diffMins}min`;
|
if (diffMins < 60) return t('time.minutes', { count: diffMins });
|
||||||
if (diffHours < 24) return `Il y a ${diffHours}h`;
|
if (diffHours < 24) return t('time.hours', { count: diffHours });
|
||||||
if (diffDays < 7) return `Il y a ${diffDays}j`;
|
if (diffDays < 7) return t('time.days', { count: diffDays });
|
||||||
return date.toLocaleDateString('fr-FR', {
|
return date.toLocaleDateString(dateLocale, {
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||||
@ -135,7 +138,6 @@ export default function NotificationsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-white border-b shadow-sm">
|
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -144,10 +146,10 @@ export default function NotificationsPage() {
|
|||||||
<Bell className="w-8 h-8 text-blue-600" />
|
<Bell className="w-8 h-8 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
{total} notification{total !== 1 ? 's' : ''} au total
|
{t('totalLabel', { count: total })}
|
||||||
{unreadCount > 0 && ` • ${unreadCount} non lue${unreadCount > 1 ? 's' : ''}`}
|
{unreadCount > 0 && t('unreadSuffix', { count: unreadCount })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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" />
|
<CheckCheck className="w-5 h-5" />
|
||||||
<span>Tout marquer comme lu</span>
|
<span>{t('markAllRead')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Filter className="w-5 h-5 text-gray-500" />
|
<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">
|
<div className="flex space-x-2">
|
||||||
{(['all', 'unread', 'read'] as const).map((filter) => (
|
{(['all', 'unread', 'read'] as const).map((filter) => (
|
||||||
<button
|
<button
|
||||||
@ -186,7 +186,7 @@ export default function NotificationsPage() {
|
|||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: '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 && (
|
{filter === 'unread' && unreadCount > 0 && (
|
||||||
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-full text-xs">
|
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-full text-xs">
|
||||||
{unreadCount}
|
{unreadCount}
|
||||||
@ -198,24 +198,21 @@ export default function NotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notifications List */}
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border">
|
<div className="bg-white rounded-lg shadow-sm border">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : notifications.length === 0 ? (
|
) : notifications.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-4 flex justify-center"><Bell className="h-16 w-16 text-gray-300" /></div>
|
<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">
|
<p className="text-gray-500">
|
||||||
{selectedFilter === 'unread'
|
{selectedFilter === 'unread' ? t('empty.upToDate') : t('empty.none')}
|
||||||
? 'Vous êtes à jour !'
|
|
||||||
: 'Aucune notification à afficher'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -230,12 +227,10 @@ export default function NotificationsPage() {
|
|||||||
} ${getPriorityColor(notification.priority || 'low')}`}
|
} ${getPriorityColor(notification.priority || 'low')}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
{/* Icon */}
|
|
||||||
<div className="flex-shrink-0 flex items-center justify-center w-12 h-12">
|
<div className="flex-shrink-0 flex items-center justify-center w-12 h-12">
|
||||||
{getNotificationIcon(notification.type)}
|
{getNotificationIcon(notification.type)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
@ -245,14 +240,14 @@ export default function NotificationsPage() {
|
|||||||
{!notification.read && (
|
{!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="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 className="w-2 h-2 bg-white rounded-full animate-pulse" />
|
||||||
<span>NOUVEAU</span>
|
<span>{t('new')}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDelete(e, notification.id)}
|
onClick={(e) => handleDelete(e, notification.id)}
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg"
|
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" />
|
<Trash2 className="w-5 h-5 text-red-600" />
|
||||||
</button>
|
</button>
|
||||||
@ -262,7 +257,6 @@ export default function NotificationsPage() {
|
|||||||
{notification.message}
|
{notification.message}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Metadata */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center flex-wrap gap-3 text-xs">
|
<div className="flex items-center flex-wrap gap-3 text-xs">
|
||||||
<span className="flex items-center space-x-1 text-gray-600">
|
<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'
|
: 'bg-blue-100 text-blue-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{notification.priority.toUpperCase()}
|
{getPriorityLabel(notification.priority)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{notification.actionUrl && (
|
{notification.actionUrl && (
|
||||||
<span className="text-sm text-blue-600 font-medium group-hover:underline flex items-center space-x-1">
|
<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
|
<svg
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
@ -327,15 +321,16 @@ export default function NotificationsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-6 bg-white rounded-lg shadow-sm border p-4">
|
<div className="mt-6 bg-white rounded-lg shadow-sm border p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
Page <span className="font-semibold">{currentPage}</span> sur{' '}
|
{t.rich('pagination.info', {
|
||||||
<span className="font-semibold">{totalPages}</span>
|
current: currentPage,
|
||||||
{' • '}
|
total: totalPages,
|
||||||
<span className="font-semibold">{total}</span> notification{total !== 1 ? 's' : ''} au total
|
items: total,
|
||||||
|
b: (chunks) => <span className="font-semibold">{chunks}</span>,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<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"
|
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" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
<span>Précédent</span>
|
<span>{t('pagination.previous')}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
@ -379,7 +374,7 @@ export default function NotificationsPage() {
|
|||||||
disabled={currentPage === totalPages}
|
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"
|
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" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1,18 +1,13 @@
|
|||||||
/**
|
|
||||||
* Dashboard Home Page - Clean & Colorful with Charts
|
|
||||||
* Professional design with data visualization
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { dashboardApi } from '@/lib/api';
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
@ -36,96 +31,92 @@ import {
|
|||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
Legend,
|
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { hasFeature, loading: subLoading } = useSubscription();
|
const { hasFeature, loading: subLoading } = useSubscription();
|
||||||
|
const t = useTranslations('dashboard.home');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
// Redirect Bronze users (no dashboard feature) to bookings
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!subLoading && !hasFeature('dashboard')) {
|
if (!subLoading && !hasFeature('dashboard')) {
|
||||||
router.replace('/dashboard/bookings');
|
router.replace('/dashboard/bookings');
|
||||||
}
|
}
|
||||||
}, [subLoading, hasFeature, router]);
|
}, [subLoading, hasFeature, router]);
|
||||||
|
|
||||||
// Fetch CSV booking KPIs
|
|
||||||
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
|
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
|
||||||
queryKey: ['dashboard', 'csv-booking-kpis'],
|
queryKey: ['dashboard', 'csv-booking-kpis'],
|
||||||
queryFn: () => dashboardApi.getCsvBookingKPIs(),
|
queryFn: () => dashboardApi.getCsvBookingKPIs(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch top carriers
|
|
||||||
const { data: topCarriers, isLoading: carriersLoading } = useQuery({
|
const { data: topCarriers, isLoading: carriersLoading } = useQuery({
|
||||||
queryKey: ['dashboard', 'top-carriers'],
|
queryKey: ['dashboard', 'top-carriers'],
|
||||||
queryFn: () => dashboardApi.getTopCarriers(),
|
queryFn: () => dashboardApi.getTopCarriers(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prepare data for charts
|
const numberFormat = new Intl.NumberFormat(locale === 'fr' ? 'fr-FR' : 'en-US');
|
||||||
|
|
||||||
const statusDistribution = csvKpis
|
const statusDistribution = csvKpis
|
||||||
? [
|
? [
|
||||||
{ name: 'Acceptés', value: csvKpis.totalAccepted, color: '#10b981' },
|
{ name: t('charts.distribution.accepted'), value: csvKpis.totalAccepted, color: '#10b981' },
|
||||||
{ name: 'Refusés', value: csvKpis.totalRejected, color: '#ef4444' },
|
{ name: t('charts.distribution.rejected'), value: csvKpis.totalRejected, color: '#ef4444' },
|
||||||
{ name: 'En Attente', value: csvKpis.totalPending, color: '#f59e0b' },
|
{ name: t('charts.distribution.pending'), value: csvKpis.totalPending, color: '#f59e0b' },
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const carrierWeightData = topCarriers
|
const carrierWeightData = topCarriers
|
||||||
? topCarriers.slice(0, 5).map(c => ({
|
? topCarriers.slice(0, 5).map(c => ({
|
||||||
name: c.carrierName.length > 15 ? c.carrierName.substring(0, 15) + '...' : c.carrierName,
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="max-w-7xl mx-auto px-6 py-8 space-y-6">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-4 sm:py-8 space-y-4 sm:space-y-6">
|
||||||
{/* Header - Compact */}
|
<div className="flex items-center justify-between pb-3 sm:pb-4 border-b border-gray-200">
|
||||||
<div className="flex items-center justify-between pb-4 border-b border-gray-200">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-semibold text-gray-900">Tableau de Bord</h1>
|
<h1 className="text-xl sm:text-3xl font-semibold text-gray-900">{t('title')}</h1>
|
||||||
<p className="text-gray-600 mt-1 text-sm">
|
<p className="text-gray-600 mt-0.5 sm:mt-1 text-xs sm:text-sm">
|
||||||
Vue d'ensemble de vos réservations et performances
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||||
<ExportButton
|
<ExportButton
|
||||||
data={topCarriers || []}
|
data={topCarriers || []}
|
||||||
filename="tableau-de-bord-transporteurs"
|
filename={t('exportFilename')}
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'carrierName', label: 'Transporteur' },
|
{ key: 'carrierName', label: t('export.carrier') },
|
||||||
{ key: 'totalBookings', label: 'Total Réservations' },
|
{ key: 'totalBookings', label: t('export.totalBookings') },
|
||||||
{ key: 'acceptedBookings', label: 'Acceptées' },
|
{ key: 'acceptedBookings', label: t('export.accepted') },
|
||||||
{ key: 'rejectedBookings', label: 'Refusées' },
|
{ key: 'rejectedBookings', label: t('export.rejected') },
|
||||||
{ key: 'totalWeightKG', label: 'Poids Total (KG)', format: (v) => v?.toLocaleString('fr-FR') || '0' },
|
{ key: 'totalWeightKG', label: t('export.totalWeight'), format: (v) => v?.toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-US') || '0' },
|
||||||
{ key: 'totalVolumeCBM', label: 'Volume Total (CBM)', format: (v) => v?.toFixed(2) || '0' },
|
{ key: 'totalVolumeCBM', label: t('export.totalVolume'), format: (v) => v?.toFixed(2) || '0' },
|
||||||
{ key: 'acceptanceRate', label: 'Taux d\'acceptation (%)', format: (v) => v?.toFixed(1) || '0' },
|
{ key: 'acceptanceRate', label: t('export.acceptanceRate'), format: (v) => v?.toFixed(1) || '0' },
|
||||||
{ key: 'avgPriceUSD', label: 'Prix moyen ($)', format: (v) => v?.toFixed(2) || '0' },
|
{ key: 'avgPriceUSD', label: t('export.avgPrice'), format: (v) => v?.toFixed(2) || '0' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Link href="/dashboard/search-advanced">
|
<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">
|
<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-5 w-5" />
|
<Plus className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
|
||||||
Nouvelle Réservation
|
<span className="hidden sm:inline">{t('newBooking')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards - Compact with Color */}
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<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">
|
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex flex-col items-center text-center">
|
<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">
|
<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" />
|
<PackageCheck className="h-5 w-5 text-green-600" />
|
||||||
</div>
|
</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 ? (
|
{csvKpisLoading ? (
|
||||||
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
|
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
|
||||||
) : (
|
) : (
|
||||||
@ -134,7 +125,7 @@ export default function DashboardPage() {
|
|||||||
{csvKpis?.totalAccepted || 0}
|
{csvKpis?.totalAccepted || 0}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
+{csvKpis?.acceptedThisMonth || 0} ce mois
|
{t('kpi.thisMonth', { count: csvKpis?.acceptedThisMonth || 0 })}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -142,14 +133,13 @@ export default function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Bookings Refusés */}
|
|
||||||
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
|
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex flex-col items-center text-center">
|
<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">
|
<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" />
|
<PackageX className="h-5 w-5 text-red-600" />
|
||||||
</div>
|
</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 ? (
|
{csvKpisLoading ? (
|
||||||
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
|
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
|
||||||
) : (
|
) : (
|
||||||
@ -158,7 +148,7 @@ export default function DashboardPage() {
|
|||||||
{csvKpis?.totalRejected || 0}
|
{csvKpis?.totalRejected || 0}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
+{csvKpis?.rejectedThisMonth || 0} ce mois
|
{t('kpi.thisMonth', { count: csvKpis?.rejectedThisMonth || 0 })}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -166,14 +156,13 @@ export default function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Bookings En Attente */}
|
|
||||||
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
|
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex flex-col items-center text-center">
|
<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">
|
<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" />
|
<Clock className="h-5 w-5 text-amber-600" />
|
||||||
</div>
|
</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 ? (
|
{csvKpisLoading ? (
|
||||||
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
|
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
|
||||||
) : (
|
) : (
|
||||||
@ -182,7 +171,7 @@ export default function DashboardPage() {
|
|||||||
{csvKpis?.totalPending || 0}
|
{csvKpis?.totalPending || 0}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<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>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -190,20 +179,19 @@ export default function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Poids Total */}
|
|
||||||
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
|
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex flex-col items-center text-center">
|
<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">
|
<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" />
|
<Weight className="h-5 w-5 text-blue-600" />
|
||||||
</div>
|
</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 ? (
|
{csvKpisLoading ? (
|
||||||
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
|
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{(csvKpis?.totalWeightAcceptedKG || 0).toLocaleString()}
|
{numberFormat.format(csvKpis?.totalWeightAcceptedKG || 0)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
KG • {(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)} CBM
|
KG • {(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)} CBM
|
||||||
@ -215,16 +203,14 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts Section */}
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{/* Distribution des Statuts - Pie Chart */}
|
|
||||||
<Card className="border border-gray-200 shadow-sm bg-white">
|
<Card className="border border-gray-200 shadow-sm bg-white">
|
||||||
<CardHeader className="pb-4 border-b border-gray-100">
|
<CardHeader className="pb-4 border-b border-gray-100">
|
||||||
<CardTitle className="text-base font-semibold text-gray-900">
|
<CardTitle className="text-base font-semibold text-gray-900">
|
||||||
Distribution des Réservations
|
{t('charts.distribution.title')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs text-gray-600">
|
<CardDescription className="text-xs text-gray-600">
|
||||||
Répartition par statut
|
{t('charts.distribution.description')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
@ -256,14 +242,13 @@ export default function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Poids par Transporteur - Bar Chart */}
|
|
||||||
<Card className="border border-gray-200 shadow-sm bg-white">
|
<Card className="border border-gray-200 shadow-sm bg-white">
|
||||||
<CardHeader className="pb-4 border-b border-gray-100">
|
<CardHeader className="pb-4 border-b border-gray-100">
|
||||||
<CardTitle className="text-base font-semibold text-gray-900">
|
<CardTitle className="text-base font-semibold text-gray-900">
|
||||||
Poids par Transporteur
|
{t('charts.weightByCarrier.title')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs text-gray-600">
|
<CardDescription className="text-xs text-gray-600">
|
||||||
Top 5 transporteurs par poids (KG)
|
{t('charts.weightByCarrier.description')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
@ -282,7 +267,7 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
<YAxis tick={{ fontSize: 11 }} />
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Bar dataKey="poids" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
<Bar dataKey={weightDataKey} fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
@ -290,7 +275,6 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Performance Overview - Compact */}
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<Card className="border border-gray-200 shadow-sm bg-white">
|
<Card className="border border-gray-200 shadow-sm bg-white">
|
||||||
<CardContent className="p-4">
|
<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">
|
<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" />
|
<TrendingUp className="h-5 w-5 text-green-600" />
|
||||||
</div>
|
</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">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{csvKpisLoading ? '--' : `${csvKpis?.acceptanceRate.toFixed(1)}%`}
|
{csvKpisLoading ? '--' : `${(csvKpis?.acceptanceRate ?? 0).toFixed(1)}%`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</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">
|
<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" />
|
<Package className="h-5 w-5 text-blue-600" />
|
||||||
</div>
|
</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">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{csvKpisLoading
|
{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">
|
<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" />
|
<Weight className="h-5 w-5 text-purple-600" />
|
||||||
</div>
|
</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">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`}
|
{csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`}
|
||||||
<span className="text-sm font-normal text-gray-500 ml-1">CBM</span>
|
<span className="text-sm font-normal text-gray-500 ml-1">CBM</span>
|
||||||
@ -340,16 +324,15 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Carriers - Compact Table */}
|
|
||||||
<Card className="border border-gray-200 shadow-sm bg-white">
|
<Card className="border border-gray-200 shadow-sm bg-white">
|
||||||
<CardHeader className="pb-3 border-b border-gray-100">
|
<CardHeader className="pb-3 border-b border-gray-100">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base font-semibold text-gray-900">
|
<CardTitle className="text-base font-semibold text-gray-900">
|
||||||
Top Transporteurs
|
{t('topCarriers.title')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs text-gray-600 mt-1">
|
<CardDescription className="text-xs text-gray-600 mt-1">
|
||||||
Classement des meilleures compagnies
|
{t('topCarriers.description')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/dashboard/bookings">
|
<Link href="/dashboard/bookings">
|
||||||
@ -358,7 +341,7 @@ export default function DashboardPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="gap-2 text-gray-600 hover:text-gray-900 text-xs"
|
className="gap-2 text-gray-600 hover:text-gray-900 text-xs"
|
||||||
>
|
>
|
||||||
Voir tout
|
{t('topCarriers.viewAll')}
|
||||||
<ArrowRight className="h-3 w-3" />
|
<ArrowRight className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@ -390,9 +373,9 @@ export default function DashboardPage() {
|
|||||||
{carrier.carrierName}
|
{carrier.carrierName}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
|
<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>•</span>
|
||||||
<span>{carrier.totalWeightKG.toLocaleString()} KG</span>
|
<span>{numberFormat.format(carrier.totalWeightKG)} KG</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -430,15 +413,15 @@ export default function DashboardPage() {
|
|||||||
<Package className="h-6 w-6 text-gray-400" />
|
<Package className="h-6 w-6 text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">
|
||||||
Aucune réservation
|
{t('topCarriers.empty.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mb-4 max-w-sm mx-auto">
|
<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>
|
</p>
|
||||||
<Link href="/dashboard/bookings">
|
<Link href="/dashboard/bookings">
|
||||||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
|
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
|
||||||
<Plus className="mr-1.5 h-3 w-3" />
|
<Plus className="mr-1.5 h-3 w-3" />
|
||||||
Créer une réservation
|
{t('topCarriers.empty.cta')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -1,9 +1,3 @@
|
|||||||
/**
|
|
||||||
* User Profile Page
|
|
||||||
*
|
|
||||||
* Allows users to view and update their profile information
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
@ -12,45 +6,44 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { updateUser, changePassword } from '@/lib/api';
|
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() {
|
export default function ProfilePage() {
|
||||||
|
const t = useTranslations('dashboard.profile');
|
||||||
const { user, refreshUser, loading } = useAuth();
|
const { user, refreshUser, loading } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
|
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
const [errorMessage, setErrorMessage] = 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>({
|
const profileForm = useForm<ProfileFormData>({
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -60,7 +53,6 @@ export default function ProfilePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Password form
|
|
||||||
const passwordForm = useForm<PasswordFormData>({
|
const passwordForm = useForm<PasswordFormData>({
|
||||||
resolver: zodResolver(passwordSchema),
|
resolver: zodResolver(passwordSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -70,7 +62,6 @@ export default function ProfilePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update form values when user data loads
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
profileForm.reset({
|
profileForm.reset({
|
||||||
@ -82,7 +73,6 @@ export default function ProfilePage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
// Reset password form when switching to password tab
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'password') {
|
if (activeTab === 'password') {
|
||||||
passwordForm.reset({
|
passwordForm.reset({
|
||||||
@ -94,26 +84,24 @@ export default function ProfilePage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
// Update profile mutation
|
|
||||||
const updateProfileMutation = useMutation({
|
const updateProfileMutation = useMutation({
|
||||||
mutationFn: (data: ProfileFormData) => {
|
mutationFn: (data: ProfileFormData) => {
|
||||||
if (!user?.id) throw new Error('User ID not found');
|
if (!user?.id) throw new Error('User ID not found');
|
||||||
return updateUser(user.id, data);
|
return updateUser(user.id, data);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSuccessMessage('Profil mis à jour avec succès !');
|
setSuccessMessage(t('profileForm.successUpdate'));
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
refreshUser();
|
refreshUser();
|
||||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||||
setTimeout(() => setSuccessMessage(''), 3000);
|
setTimeout(() => setSuccessMessage(''), 3000);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
setErrorMessage(error.message || 'Échec de la mise à jour du profil');
|
setErrorMessage(error.message || t('profileForm.errorUpdate'));
|
||||||
setSuccessMessage('');
|
setSuccessMessage('');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update password mutation
|
|
||||||
const updatePasswordMutation = useMutation({
|
const updatePasswordMutation = useMutation({
|
||||||
mutationFn: async (data: PasswordFormData) => {
|
mutationFn: async (data: PasswordFormData) => {
|
||||||
return changePassword({
|
return changePassword({
|
||||||
@ -122,7 +110,7 @@ export default function ProfilePage() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSuccessMessage('Mot de passe mis à jour avec succès !');
|
setSuccessMessage(t('passwordForm.successUpdate'));
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
passwordForm.reset({
|
passwordForm.reset({
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
@ -132,7 +120,7 @@ export default function ProfilePage() {
|
|||||||
setTimeout(() => setSuccessMessage(''), 3000);
|
setTimeout(() => setSuccessMessage(''), 3000);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
setErrorMessage(error.message || 'Échec de la mise à jour du mot de passe');
|
setErrorMessage(error.message || t('passwordForm.errorUpdate'));
|
||||||
setSuccessMessage('');
|
setSuccessMessage('');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -145,29 +133,27 @@ export default function ProfilePage() {
|
|||||||
updatePasswordMutation.mutate(data);
|
updatePasswordMutation.mutate(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show loading state while user data is being fetched
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show error if user is not found after loading
|
|
||||||
if (!loading && !user) {
|
if (!loading && !user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">
|
<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
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Réessayer
|
{t('retry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -176,13 +162,11 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<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">
|
<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>
|
<h1 className="text-3xl font-bold mb-2">{t('header.title')}</h1>
|
||||||
<p className="text-blue-100">Gérez vos paramètres de compte et préférences</p>
|
<p className="text-blue-100">{t('header.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Success/Error Messages */}
|
|
||||||
{successMessage && (
|
{successMessage && (
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@ -213,7 +197,6 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User Info Card */}
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
<div className="flex items-center space-x-4">
|
<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">
|
<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}
|
{user?.role}
|
||||||
</span>
|
</span>
|
||||||
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
|
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
|
||||||
Actif
|
{t('active')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-white rounded-lg shadow">
|
||||||
<div className="border-b">
|
<div className="border-b">
|
||||||
<nav className="flex space-x-8 px-6" aria-label="Tabs">
|
<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'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Informations personnelles
|
{t('tabs.profile')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('password')}
|
onClick={() => setActiveTab('password')}
|
||||||
@ -259,7 +241,7 @@ export default function ProfilePage() {
|
|||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Modifier le mot de passe
|
{t('tabs.password')}
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@ -268,13 +250,9 @@ export default function ProfilePage() {
|
|||||||
{activeTab === 'profile' ? (
|
{activeTab === 'profile' ? (
|
||||||
<form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6">
|
<form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* First Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
htmlFor="firstName"
|
{t('profileForm.firstName')}
|
||||||
className="block text-sm font-medium text-gray-700 mb-2"
|
|
||||||
>
|
|
||||||
Prénom
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...profileForm.register('firstName')}
|
{...profileForm.register('firstName')}
|
||||||
@ -289,13 +267,9 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Last Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
htmlFor="lastName"
|
{t('profileForm.lastName')}
|
||||||
className="block text-sm font-medium text-gray-700 mb-2"
|
|
||||||
>
|
|
||||||
Nom
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...profileForm.register('lastName')}
|
{...profileForm.register('lastName')}
|
||||||
@ -311,10 +285,9 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Adresse email
|
{t('profileForm.email')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...profileForm.register('email')}
|
{...profileForm.register('email')}
|
||||||
@ -323,29 +296,24 @@ export default function ProfilePage() {
|
|||||||
disabled
|
disabled
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
|
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'adresse email ne peut pas être modifiée</p>
|
<p className="mt-1 text-xs text-gray-500">{t('profileForm.emailHelp')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={updateProfileMutation.isPending}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)} className="space-y-6">
|
<form onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)} className="space-y-6">
|
||||||
{/* Current Password */}
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
htmlFor="currentPassword"
|
{t('passwordForm.current')}
|
||||||
className="block text-sm font-medium text-gray-700 mb-2"
|
|
||||||
>
|
|
||||||
Mot de passe actuel
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...passwordForm.register('currentPassword')}
|
{...passwordForm.register('currentPassword')}
|
||||||
@ -361,13 +329,9 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New Password */}
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
htmlFor="newPassword"
|
{t('passwordForm.new')}
|
||||||
className="block text-sm font-medium text-gray-700 mb-2"
|
|
||||||
>
|
|
||||||
Nouveau mot de passe
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...passwordForm.register('newPassword')}
|
{...passwordForm.register('newPassword')}
|
||||||
@ -381,18 +345,12 @@ export default function ProfilePage() {
|
|||||||
{passwordForm.formState.errors.newPassword.message}
|
{passwordForm.formState.errors.newPassword.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">{t('passwordForm.newHint')}</p>
|
||||||
Au moins 12 caractères avec majuscule, minuscule, chiffre et caractère spécial
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Confirm Password */}
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
htmlFor="confirmPassword"
|
{t('passwordForm.confirm')}
|
||||||
className="block text-sm font-medium text-gray-700 mb-2"
|
|
||||||
>
|
|
||||||
Confirmer le nouveau mot de passe
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...passwordForm.register('confirmPassword')}
|
{...passwordForm.register('confirmPassword')}
|
||||||
@ -408,14 +366,13 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={updatePasswordMutation.isPending}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { Search, Loader2 } from 'lucide-react';
|
import { Search, Loader2 } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
getAvailableOrigins,
|
getAvailableOrigins,
|
||||||
getAvailableDestinations,
|
getAvailableDestinations,
|
||||||
@ -18,10 +12,14 @@ import {
|
|||||||
} from '@/lib/api/rates';
|
} from '@/lib/api/rates';
|
||||||
import dynamic from 'next/dynamic';
|
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'), {
|
const PortRouteMap = dynamic(() => import('@/components/PortRouteMap'), {
|
||||||
ssr: false,
|
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 {
|
interface Package {
|
||||||
@ -35,28 +33,17 @@ interface Package {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SearchForm {
|
interface SearchForm {
|
||||||
// General
|
|
||||||
origin: string;
|
origin: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
|
|
||||||
// Conditionnement
|
|
||||||
packages: Package[];
|
packages: Package[];
|
||||||
|
|
||||||
// Douane
|
|
||||||
eurDocument: boolean;
|
eurDocument: boolean;
|
||||||
customsStop: boolean;
|
customsStop: boolean;
|
||||||
exportAssistance: boolean;
|
exportAssistance: boolean;
|
||||||
|
|
||||||
// Marchandise
|
|
||||||
dangerousGoods: boolean;
|
dangerousGoods: boolean;
|
||||||
specialHandling: boolean;
|
specialHandling: boolean;
|
||||||
|
|
||||||
// Manutention
|
|
||||||
tailgate: boolean;
|
tailgate: boolean;
|
||||||
straps: boolean;
|
straps: boolean;
|
||||||
thermalCover: boolean;
|
thermalCover: boolean;
|
||||||
|
|
||||||
// Autres
|
|
||||||
regulatedProducts: boolean;
|
regulatedProducts: boolean;
|
||||||
appointment: boolean;
|
appointment: boolean;
|
||||||
insurance: boolean;
|
insurance: boolean;
|
||||||
@ -64,6 +51,7 @@ interface SearchForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdvancedSearchPage() {
|
export default function AdvancedSearchPage() {
|
||||||
|
const t = useTranslations('dashboard.rateSearch');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [searchForm, setSearchForm] = useState<SearchForm>({
|
const [searchForm, setSearchForm] = useState<SearchForm>({
|
||||||
origin: '',
|
origin: '',
|
||||||
@ -101,20 +89,17 @@ export default function AdvancedSearchPage() {
|
|||||||
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
|
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
|
||||||
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(null);
|
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(null);
|
||||||
|
|
||||||
// Fetch available origins from CSV rates
|
|
||||||
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
|
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
|
||||||
queryKey: ['available-origins'],
|
queryKey: ['available-origins'],
|
||||||
queryFn: getAvailableOrigins,
|
queryFn: getAvailableOrigins,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch available destinations based on selected origin
|
|
||||||
const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({
|
const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({
|
||||||
queryKey: ['available-destinations', searchForm.origin],
|
queryKey: ['available-destinations', searchForm.origin],
|
||||||
queryFn: () => getAvailableDestinations(searchForm.origin),
|
queryFn: () => getAvailableDestinations(searchForm.origin),
|
||||||
enabled: !!searchForm.origin,
|
enabled: !!searchForm.origin,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter origins based on search input
|
|
||||||
const filteredOrigins = (originsData?.origins || []).filter(port => {
|
const filteredOrigins = (originsData?.origins || []).filter(port => {
|
||||||
if (!originSearch || originSearch.length < 1) return true;
|
if (!originSearch || originSearch.length < 1) return true;
|
||||||
const searchLower = originSearch.toLowerCase();
|
const searchLower = originSearch.toLowerCase();
|
||||||
@ -126,7 +111,6 @@ export default function AdvancedSearchPage() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter destinations based on search input
|
|
||||||
const filteredDestinations = (destinationsData?.destinations || []).filter(port => {
|
const filteredDestinations = (destinationsData?.destinations || []).filter(port => {
|
||||||
if (!destinationSearch || destinationSearch.length < 1) return true;
|
if (!destinationSearch || destinationSearch.length < 1) return true;
|
||||||
const searchLower = destinationSearch.toLowerCase();
|
const searchLower = destinationSearch.toLowerCase();
|
||||||
@ -138,10 +122,8 @@ export default function AdvancedSearchPage() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset destination when origin changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchForm.origin && selectedDestinationPort) {
|
if (searchForm.origin && selectedDestinationPort) {
|
||||||
// Check if current destination is still valid for new origin
|
|
||||||
const isValidDestination = destinationsData?.destinations?.some(
|
const isValidDestination = destinationsData?.destinations?.some(
|
||||||
d => d.code === searchForm.destination
|
d => d.code === searchForm.destination
|
||||||
);
|
);
|
||||||
@ -153,7 +135,6 @@ export default function AdvancedSearchPage() {
|
|||||||
}
|
}
|
||||||
}, [searchForm.origin, destinationsData]);
|
}, [searchForm.origin, destinationsData]);
|
||||||
|
|
||||||
// Calculate total volume and weight
|
|
||||||
const calculateTotals = () => {
|
const calculateTotals = () => {
|
||||||
let totalVolumeCBM = 0;
|
let totalVolumeCBM = 0;
|
||||||
let totalWeightKG = 0;
|
let totalWeightKG = 0;
|
||||||
@ -174,7 +155,6 @@ export default function AdvancedSearchPage() {
|
|||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
const { totalVolumeCBM, totalWeightKG, totalPallets } = calculateTotals();
|
const { totalVolumeCBM, totalWeightKG, totalPallets } = calculateTotals();
|
||||||
|
|
||||||
// Build query parameters
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
origin: searchForm.origin,
|
origin: searchForm.origin,
|
||||||
destination: searchForm.destination,
|
destination: searchForm.destination,
|
||||||
@ -190,7 +170,6 @@ export default function AdvancedSearchPage() {
|
|||||||
requiresAppointment: searchForm.appointment.toString(),
|
requiresAppointment: searchForm.appointment.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirect to results page
|
|
||||||
router.push(`/dashboard/search-advanced/results?${params.toString()}`);
|
router.push(`/dashboard/search-advanced/results?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -227,13 +206,12 @@ export default function AdvancedSearchPage() {
|
|||||||
|
|
||||||
const renderStep1 = () => (
|
const renderStep1 = () => (
|
||||||
<div className="space-y-6">
|
<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">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{/* Origin Port with Autocomplete - Limited to CSV routes */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<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>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@ -242,7 +220,6 @@ export default function AdvancedSearchPage() {
|
|||||||
onChange={e => {
|
onChange={e => {
|
||||||
setOriginSearch(e.target.value);
|
setOriginSearch(e.target.value);
|
||||||
setShowOriginDropdown(true);
|
setShowOriginDropdown(true);
|
||||||
// Clear selection if user modifies the input
|
|
||||||
if (selectedOriginPort && e.target.value !== selectedOriginPort.displayName) {
|
if (selectedOriginPort && e.target.value !== selectedOriginPort.displayName) {
|
||||||
setSearchForm({ ...searchForm, origin: '', destination: '' });
|
setSearchForm({ ...searchForm, origin: '', destination: '' });
|
||||||
setSelectedOriginPort(null);
|
setSelectedOriginPort(null);
|
||||||
@ -252,7 +229,7 @@ export default function AdvancedSearchPage() {
|
|||||||
}}
|
}}
|
||||||
onFocus={() => setShowOriginDropdown(true)}
|
onFocus={() => setShowOriginDropdown(true)}
|
||||||
onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)}
|
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 ${
|
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'
|
searchForm.origin ? 'border-green-500 bg-green-50' : 'border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
@ -287,22 +264,21 @@ export default function AdvancedSearchPage() {
|
|||||||
))}
|
))}
|
||||||
{filteredOrigins.length > 15 && (
|
{filteredOrigins.length > 15 && (
|
||||||
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showOriginDropdown && filteredOrigins.length === 0 && !isLoadingOrigins && originsData && (
|
{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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Destination Port with Autocomplete - Limited to routes from selected origin */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<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>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@ -311,7 +287,6 @@ export default function AdvancedSearchPage() {
|
|||||||
onChange={e => {
|
onChange={e => {
|
||||||
setDestinationSearch(e.target.value);
|
setDestinationSearch(e.target.value);
|
||||||
setShowDestinationDropdown(true);
|
setShowDestinationDropdown(true);
|
||||||
// Clear selection if user modifies the input
|
|
||||||
if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) {
|
if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) {
|
||||||
setSearchForm({ ...searchForm, destination: '' });
|
setSearchForm({ ...searchForm, destination: '' });
|
||||||
setSelectedDestinationPort(null);
|
setSelectedDestinationPort(null);
|
||||||
@ -320,7 +295,7 @@ export default function AdvancedSearchPage() {
|
|||||||
onFocus={() => setShowDestinationDropdown(true)}
|
onFocus={() => setShowDestinationDropdown(true)}
|
||||||
onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
|
onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
|
||||||
disabled={!searchForm.origin}
|
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 ${
|
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.destination ? 'border-green-500 bg-green-50' : 'border-gray-300'
|
||||||
} ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
} ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||||||
@ -333,7 +308,7 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
{searchForm.origin && destinationsData?.total !== undefined && (
|
{searchForm.origin && destinationsData?.total !== undefined && (
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
{showDestinationDropdown && filteredDestinations.length > 0 && (
|
{showDestinationDropdown && filteredDestinations.length > 0 && (
|
||||||
@ -358,28 +333,27 @@ export default function AdvancedSearchPage() {
|
|||||||
))}
|
))}
|
||||||
{filteredDestinations.length > 15 && (
|
{filteredDestinations.length > 15 && (
|
||||||
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showDestinationDropdown && filteredDestinations.length === 0 && !isLoadingDestinations && searchForm.origin && destinationsData && (
|
{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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Carte interactive de la route maritime */}
|
|
||||||
{selectedOriginPort && selectedDestinationPort && selectedOriginPort.latitude && selectedDestinationPort.latitude && (
|
{selectedOriginPort && selectedDestinationPort && selectedOriginPort.latitude && selectedDestinationPort.latitude && (
|
||||||
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden">
|
<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">
|
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
<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>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Distance approximative et visualisation de la route
|
{t('step1.routeDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<PortRouteMap
|
<PortRouteMap
|
||||||
@ -398,51 +372,53 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderStep2 = () => (
|
const renderStep2 = () => {
|
||||||
|
const totals = calculateTotals();
|
||||||
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">2. Conditionnement</h2>
|
<h2 className="text-xl font-semibold text-gray-900">{t('step2.title')}</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addPackage}
|
onClick={addPackage}
|
||||||
className="px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 rounded-md hover:bg-blue-100"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{searchForm.packages.map((pkg, index) => (
|
{searchForm.packages.map((pkg, index) => (
|
||||||
<div key={index} className="border border-gray-200 rounded-lg p-4 space-y-4">
|
<div key={index} className="border border-gray-200 rounded-lg p-4 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<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 && (
|
{searchForm.packages.length > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removePackage(index)}
|
onClick={() => removePackage(index)}
|
||||||
className="text-sm text-red-600 hover:text-red-700"
|
className="text-sm text-red-600 hover:text-red-700"
|
||||||
>
|
>
|
||||||
Supprimer
|
{t('step2.remove')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-5 gap-3">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
<div>
|
<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
|
<select
|
||||||
value={pkg.type}
|
value={pkg.type}
|
||||||
onChange={e => updatePackage(index, 'type', e.target.value)}
|
onChange={e => updatePackage(index, 'type', e.target.value)}
|
||||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
|
||||||
>
|
>
|
||||||
<option value="caisse">Caisse</option>
|
<option value="caisse">{t('step2.packageTypes.caisse')}</option>
|
||||||
<option value="colis">Colis</option>
|
<option value="colis">{t('step2.packageTypes.colis')}</option>
|
||||||
<option value="palette">Palette</option>
|
<option value="palette">{t('step2.packageTypes.palette')}</option>
|
||||||
<option value="autre">Autre</option>
|
<option value="autre">{t('step2.packageTypes.autre')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@ -453,7 +429,7 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@ -464,7 +440,7 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@ -475,7 +451,7 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@ -488,7 +464,7 @@ export default function AdvancedSearchPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@ -505,30 +481,31 @@ export default function AdvancedSearchPage() {
|
|||||||
onChange={e => updatePackage(index, 'stackable', e.target.checked)}
|
onChange={e => updatePackage(index, 'stackable', e.target.checked)}
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
<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 className="text-sm text-blue-800 space-y-1">
|
||||||
<div>Volume total: {calculateTotals().totalVolumeCBM.toFixed(2)} m³</div>
|
<div>{t('step2.summary.volume', { value: totals.totalVolumeCBM.toFixed(2) })}</div>
|
||||||
<div>Poids total: {calculateTotals().totalWeightKG} kg</div>
|
<div>{t('step2.summary.weight', { value: totals.totalWeightKG })}</div>
|
||||||
<div>Palettes: {calculateTotals().totalPallets}</div>
|
<div>{t('step2.summary.pallets', { value: totals.totalPallets })}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderStep3 = () => (
|
const renderStep3 = () => (
|
||||||
<div className="space-y-6">
|
<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="space-y-4">
|
||||||
<div className="border-b pb-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">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -537,7 +514,7 @@ export default function AdvancedSearchPage() {
|
|||||||
onChange={e => setSearchForm({ ...searchForm, eurDocument: e.target.checked })}
|
onChange={e => setSearchForm({ ...searchForm, eurDocument: e.target.checked })}
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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>
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -546,7 +523,7 @@ export default function AdvancedSearchPage() {
|
|||||||
onChange={e => setSearchForm({ ...searchForm, t1Document: e.target.checked })}
|
onChange={e => setSearchForm({ ...searchForm, t1Document: e.target.checked })}
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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>
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -555,7 +532,7 @@ export default function AdvancedSearchPage() {
|
|||||||
onChange={e => setSearchForm({ ...searchForm, customsStop: e.target.checked })}
|
onChange={e => setSearchForm({ ...searchForm, customsStop: e.target.checked })}
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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>
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -566,13 +543,13 @@ export default function AdvancedSearchPage() {
|
|||||||
}
|
}
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-b pb-4">
|
<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">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -581,7 +558,7 @@ export default function AdvancedSearchPage() {
|
|||||||
onChange={e => setSearchForm({ ...searchForm, dangerousGoods: e.target.checked })}
|
onChange={e => setSearchForm({ ...searchForm, dangerousGoods: e.target.checked })}
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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>
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -592,13 +569,13 @@ export default function AdvancedSearchPage() {
|
|||||||
}
|
}
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-b pb-4">
|
<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">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -609,7 +586,7 @@ export default function AdvancedSearchPage() {
|
|||||||
}
|
}
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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>
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -618,7 +595,7 @@ export default function AdvancedSearchPage() {
|
|||||||
onChange={e => setSearchForm({ ...searchForm, tailgate: e.target.checked })}
|
onChange={e => setSearchForm({ ...searchForm, tailgate: e.target.checked })}
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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>
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -627,7 +604,7 @@ export default function AdvancedSearchPage() {
|
|||||||
onChange={e => setSearchForm({ ...searchForm, straps: e.target.checked })}
|
onChange={e => setSearchForm({ ...searchForm, straps: e.target.checked })}
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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>
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -636,13 +613,13 @@ export default function AdvancedSearchPage() {
|
|||||||
onChange={e => setSearchForm({ ...searchForm, thermalCover: e.target.checked })}
|
onChange={e => setSearchForm({ ...searchForm, thermalCover: e.target.checked })}
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -651,7 +628,7 @@ export default function AdvancedSearchPage() {
|
|||||||
onChange={e => setSearchForm({ ...searchForm, appointment: e.target.checked })}
|
onChange={e => setSearchForm({ ...searchForm, appointment: e.target.checked })}
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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>
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -660,7 +637,7 @@ export default function AdvancedSearchPage() {
|
|||||||
onChange={e => setSearchForm({ ...searchForm, insurance: e.target.checked })}
|
onChange={e => setSearchForm({ ...searchForm, insurance: e.target.checked })}
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -670,15 +647,13 @@ export default function AdvancedSearchPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div>
|
<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">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
Formulaire complet avec toutes les options de transport
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress Steps */}
|
|
||||||
<div className="flex items-center justify-center space-x-4">
|
<div className="flex items-center justify-center space-x-4">
|
||||||
{[1, 2, 3].map(step => (
|
{[1, 2, 3].map(step => (
|
||||||
<div key={step} className="flex items-center">
|
<div key={step} className="flex items-center">
|
||||||
@ -700,13 +675,11 @@ export default function AdvancedSearchPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<div className="bg-white rounded-lg shadow p-8">
|
<div className="bg-white rounded-lg shadow p-8">
|
||||||
{currentStep === 1 && renderStep1()}
|
{currentStep === 1 && renderStep1()}
|
||||||
{currentStep === 2 && renderStep2()}
|
{currentStep === 2 && renderStep2()}
|
||||||
{currentStep === 3 && renderStep3()}
|
{currentStep === 3 && renderStep3()}
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="mt-8 flex items-center justify-between pt-6 border-t">
|
<div className="mt-8 flex items-center justify-between pt-6 border-t">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -714,7 +687,7 @@ export default function AdvancedSearchPage() {
|
|||||||
disabled={currentStep === 1}
|
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"
|
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>
|
</button>
|
||||||
|
|
||||||
{currentStep < 3 ? (
|
{currentStep < 3 ? (
|
||||||
@ -724,7 +697,7 @@ export default function AdvancedSearchPage() {
|
|||||||
disabled={!searchForm.origin || !searchForm.destination}
|
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"
|
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>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@ -733,7 +706,7 @@ export default function AdvancedSearchPage() {
|
|||||||
disabled={!searchForm.origin || !searchForm.destination}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
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 { searchCsvRatesWithOffers } from '@/lib/api/rates';
|
||||||
import type { CsvRateSearchResult } from '@/types/rates';
|
import type { CsvRateSearchResult } from '@/types/rates';
|
||||||
import { Search, Lightbulb, DollarSign, Scale, Zap, Trophy, XCircle, AlertTriangle } from 'lucide-react';
|
import { Search, Lightbulb, DollarSign, Scale, Zap, Trophy, XCircle, AlertTriangle } from 'lucide-react';
|
||||||
@ -13,13 +15,15 @@ interface BestOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchResultsPage() {
|
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 router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [results, setResults] = useState<CsvRateSearchResult[]>([]);
|
const [results, setResults] = useState<CsvRateSearchResult[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Parse search parameters from URL
|
|
||||||
const origin = searchParams.get('origin') || '';
|
const origin = searchParams.get('origin') || '';
|
||||||
const destination = searchParams.get('destination') || '';
|
const destination = searchParams.get('destination') || '';
|
||||||
const volumeCBM = parseFloat(searchParams.get('volumeCBM') || '0');
|
const volumeCBM = parseFloat(searchParams.get('volumeCBM') || '0');
|
||||||
@ -36,24 +40,17 @@ export default function SearchResultsPage() {
|
|||||||
destination,
|
destination,
|
||||||
volumeCBM,
|
volumeCBM,
|
||||||
weightKG,
|
weightKG,
|
||||||
palletCount,
|
|
||||||
hasDangerousGoods: searchParams.get('hasDangerousGoods') === 'true',
|
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);
|
setResults(response.results);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Search error:', 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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [origin, destination, volumeCBM, weightKG, palletCount, searchParams]);
|
}, [origin, destination, volumeCBM, weightKG, palletCount, searchParams, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!origin || !destination || !volumeCBM || !weightKG) {
|
if (!origin || !destination || !volumeCBM || !weightKG) {
|
||||||
@ -67,12 +64,10 @@ export default function SearchResultsPage() {
|
|||||||
const getBestOptions = (): BestOptions | null => {
|
const getBestOptions = (): BestOptions | null => {
|
||||||
if (results.length === 0) return 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 economic = results.find(r => r.serviceLevel === 'ECONOMIC');
|
||||||
const standard = results.find(r => r.serviceLevel === 'STANDARD');
|
const standard = results.find(r => r.serviceLevel === 'STANDARD');
|
||||||
const rapid = results.find(r => r.serviceLevel === 'RAPID');
|
const rapid = results.find(r => r.serviceLevel === 'RAPID');
|
||||||
|
|
||||||
// If we have all 3 service levels, return them
|
|
||||||
if (economic && standard && rapid) {
|
if (economic && standard && rapid) {
|
||||||
return {
|
return {
|
||||||
eco: economic,
|
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.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting);
|
||||||
const sorted = [...results].sort((a, b) => a.priceEUR - b.priceEUR);
|
const fastest = [...results].sort((a, b) => (a.adjustedTransitDays ?? a.transitDays) - (b.adjustedTransitDays ?? b.transitDays));
|
||||||
const fastest = [...results].sort((a, b) => a.transitDays - b.transitDays);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
eco: sorted[0],
|
eco: sorted[0],
|
||||||
@ -95,7 +89,7 @@ export default function SearchResultsPage() {
|
|||||||
const bestOptions = getBestOptions();
|
const bestOptions = getBestOptions();
|
||||||
|
|
||||||
const formatPrice = (price: number) => {
|
const formatPrice = (price: number) => {
|
||||||
return new Intl.NumberFormat('fr-FR', {
|
return new Intl.NumberFormat(dateLocale, {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
}).format(price);
|
}).format(price);
|
||||||
@ -107,7 +101,7 @@ export default function SearchResultsPage() {
|
|||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="text-center">
|
<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>
|
<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">
|
<p className="text-gray-500 mt-2">
|
||||||
{origin} → {destination}
|
{origin} → {destination}
|
||||||
</p>
|
</p>
|
||||||
@ -123,13 +117,13 @@ export default function SearchResultsPage() {
|
|||||||
<div className="max-w-7xl mx-auto">
|
<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="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>
|
<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>
|
<p className="text-red-700 mb-4">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/dashboard/search-advanced')}
|
onClick={() => router.push('/dashboard/search-advanced')}
|
||||||
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Retour à la recherche
|
{t('backToSearch')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -145,35 +139,30 @@ export default function SearchResultsPage() {
|
|||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
||||||
>
|
>
|
||||||
← Retour à la recherche
|
{t('backToSearch')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
|
<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>
|
<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">
|
<p className="text-gray-600 mb-4">
|
||||||
Aucun tarif ne correspond à votre recherche pour le trajet {origin} → {destination}
|
{t('noResultsMessage', { origin, destination })}
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6">
|
<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">
|
<ul className="text-sm text-gray-700 space-y-2">
|
||||||
<li>
|
<li>{t('suggestionPorts')}</li>
|
||||||
• <strong>Ports disponibles :</strong> NLRTM, DEHAM, FRLEH, BEGNE (origine) → USNYC, USLAX,
|
<li>{t('suggestionVolume')}</li>
|
||||||
CNSHG, SGSIN (destination)
|
<li>{t('suggestionWeight')}</li>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• <strong>Volume :</strong> Essayez entre 1 et 200 CBM
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• <strong>Poids :</strong> Essayez entre 100 et 30000 kg
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
Modifier la recherche
|
{t('modifySearch')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -183,7 +172,7 @@ export default function SearchResultsPage() {
|
|||||||
|
|
||||||
const optionCards = [
|
const optionCards = [
|
||||||
{
|
{
|
||||||
type: 'Économique',
|
type: t('options.economic'),
|
||||||
option: bestOptions?.eco,
|
option: bestOptions?.eco,
|
||||||
colors: {
|
colors: {
|
||||||
border: 'border-green-200',
|
border: 'border-green-200',
|
||||||
@ -192,10 +181,10 @@ export default function SearchResultsPage() {
|
|||||||
button: 'bg-green-600 hover:bg-green-700',
|
button: 'bg-green-600 hover:bg-green-700',
|
||||||
},
|
},
|
||||||
icon: <DollarSign className="h-10 w-10 text-green-600" />,
|
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,
|
option: bestOptions?.standard,
|
||||||
colors: {
|
colors: {
|
||||||
border: 'border-blue-200',
|
border: 'border-blue-200',
|
||||||
@ -204,10 +193,10 @@ export default function SearchResultsPage() {
|
|||||||
button: 'bg-blue-600 hover:bg-blue-700',
|
button: 'bg-blue-600 hover:bg-blue-700',
|
||||||
},
|
},
|
||||||
icon: <Scale className="h-10 w-10 text-blue-600" />,
|
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,
|
option: bestOptions?.fast,
|
||||||
colors: {
|
colors: {
|
||||||
border: 'border-purple-200',
|
border: 'border-purple-200',
|
||||||
@ -216,7 +205,7 @@ export default function SearchResultsPage() {
|
|||||||
button: 'bg-purple-600 hover:bg-purple-700',
|
button: 'bg-purple-600 hover:bg-purple-700',
|
||||||
},
|
},
|
||||||
icon: <Zap className="h-10 w-10 text-purple-600" />,
|
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()}
|
onClick={() => router.back()}
|
||||||
className="mb-4 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
className="mb-4 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
||||||
>
|
>
|
||||||
← Retour à la recherche
|
{t('backToSearch')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<p className="text-gray-600">
|
||||||
<span className="font-semibold">{origin}</span> → <span className="font-semibold">{destination}</span>{' '}
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<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>
|
<p className="text-3xl font-bold text-blue-600">{results.length}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -255,7 +246,7 @@ export default function SearchResultsPage() {
|
|||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
|
<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" />
|
<Trophy className="h-8 w-8 mr-3 text-yellow-500" />
|
||||||
Meilleurs choix pour votre recherche
|
{t('bestChoices')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<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="bg-white rounded-lg p-4 mb-4">
|
||||||
<div className="text-center mb-3">
|
<div className="text-center mb-3">
|
||||||
<p className="text-sm text-gray-600 mb-1">Prix total</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.priceEUR)}</p>
|
<p className="text-3xl font-bold text-gray-900">{formatPrice(card.option.priceBreakdown.totalPriceForSorting)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-200 pt-3 space-y-2 text-sm">
|
<div className="border-t border-gray-200 pt-3 space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<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>
|
<span className="font-semibold text-gray-900">{card.option.companyName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Transit :</span>
|
<span className="text-gray-600">{t('transit')}</span>
|
||||||
<span className="font-semibold text-gray-900">{card.option.transitDays} jours</span>
|
<span className="font-semibold text-gray-900">
|
||||||
|
{t('transitDays', { days: card.option.adjustedTransitDays ?? card.option.transitDays })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<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>
|
<span className="font-semibold text-gray-900">{card.option.containerType}</span>
|
||||||
</div>
|
</div>
|
||||||
</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`}
|
className={`w-full py-3 ${card.colors.button} text-white rounded-lg font-semibold transition-colors`}
|
||||||
>
|
>
|
||||||
Sélectionner cette option
|
{t('select')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -321,7 +314,7 @@ export default function SearchResultsPage() {
|
|||||||
|
|
||||||
{/* All Results */}
|
{/* All Results */}
|
||||||
<div>
|
<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">
|
<div className="space-y-4">
|
||||||
{results.map((result, index) => (
|
{results.map((result, index) => (
|
||||||
@ -334,40 +327,49 @@ export default function SearchResultsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-3xl font-bold text-blue-600">{formatPrice(result.priceEUR)}</p>
|
<p className="text-3xl font-bold text-blue-600">{formatPrice(result.priceBreakdown.totalPriceForSorting)}</p>
|
||||||
<p className="text-sm text-gray-500">Prix total</p>
|
<p className="text-sm text-gray-500">{t('totalPrice')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
<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">
|
<p className="font-semibold text-gray-900">
|
||||||
{formatPrice(result.priceBreakdown.basePrice)}
|
{formatPrice(result.priceBreakdown.totalFreight)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
<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">
|
<p className="font-semibold text-gray-900">
|
||||||
{formatPrice(result.priceBreakdown.volumeCharge)}
|
{formatPrice(result.priceBreakdown.totalFob)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
<p className="text-xs text-gray-600 mb-1">Frais poids</p>
|
<p className="text-xs text-gray-600 mb-1">Routage</p>
|
||||||
<p className="font-semibold text-gray-900">
|
<p className="font-semibold text-gray-900">{result.routing}</p>
|
||||||
{formatPrice(result.priceBreakdown.weightCharge)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
<p className="text-xs text-gray-600 mb-1">Délai transit</p>
|
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.transit')}</p>
|
||||||
<p className="font-semibold text-gray-900">{result.transitDays} jours</p>
|
<p className="font-semibold text-gray-900">
|
||||||
|
{t('transitDays', { days: result.adjustedTransitDays ?? result.transitDays })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
<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>
|
<span>{t('validUntil', { date: new Date(result.validUntil).toLocaleDateString(dateLocale) })}</span>
|
||||||
{result.hasSurcharges && <span className="text-orange-600 flex items-center"><AlertTriangle className="h-4 w-4 mr-1" /> Surcharges applicables</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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"
|
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
Sélectionner
|
{t('selectShort')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { listApiKeys, createApiKey, revokeApiKey } from '@/lib/api/api-keys';
|
import { listApiKeys, createApiKey, revokeApiKey } from '@/lib/api/api-keys';
|
||||||
import type { ApiKeyDto, CreateApiKeyResultDto } from '@/lib/api/api-keys';
|
import type { ApiKeyDto, CreateApiKeyResultDto } from '@/lib/api/api-keys';
|
||||||
import { useSubscription } from '@/lib/context/subscription-context';
|
import { useSubscription } from '@/lib/context/subscription-context';
|
||||||
@ -17,40 +18,10 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Lock,
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
// ─── 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 ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CopyButton({ text }: { text: string }) {
|
function CopyButton({ text }: { text: string }) {
|
||||||
|
const t = useTranslations('dashboard.apiKeys.copy');
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
@ -65,20 +36,18 @@ function CopyButton({ text }: { text: string }) {
|
|||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
<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" />
|
<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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Creation success modal ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CreatedKeyModal({
|
function CreatedKeyModal({
|
||||||
result,
|
result,
|
||||||
onClose,
|
onClose,
|
||||||
@ -86,17 +55,17 @@ function CreatedKeyModal({
|
|||||||
result: CreateApiKeyResultDto;
|
result: CreateApiKeyResultDto;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations('dashboard.apiKeys.createdModal');
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
<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">
|
<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 justify-between p-6 border-b">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
|
<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" />
|
<ShieldCheck className="w-5 h-5 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-sm text-gray-500">{result.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -105,19 +74,16 @@ function CreatedKeyModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Warning */}
|
|
||||||
<div className="mx-6 mt-6 p-4 bg-amber-50 border border-amber-200 rounded-xl flex gap-3">
|
<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" />
|
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
<p className="text-sm text-amber-800">
|
<p className="text-sm text-amber-800">
|
||||||
<strong>Copiez cette clé maintenant.</strong> Elle ne sera plus jamais affichée après
|
<strong>{t('warning')}</strong> {t('warningRest')}
|
||||||
la fermeture de cette fenêtre.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key */}
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<label className="block text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide">
|
<label className="block text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide">
|
||||||
Clé API complète
|
{t('fullKey')}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2 p-3 bg-gray-950 rounded-xl border border-gray-800">
|
<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">
|
<code className="flex-1 text-xs font-mono text-green-400 break-all">
|
||||||
@ -125,18 +91,15 @@ function CreatedKeyModal({
|
|||||||
</code>
|
</code>
|
||||||
<CopyButton text={result.fullKey} />
|
<CopyButton text={result.fullKey} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-xs text-gray-500">
|
<p className="mt-3 text-xs text-gray-500">{t('storeHint')}</p>
|
||||||
Stockez-la dans vos variables d'environnement ou un gestionnaire de secrets.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="p-6 pt-0">
|
<div className="p-6 pt-0">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-full py-2.5 bg-[#10183A] hover:bg-[#1a2550] text-white text-sm font-medium rounded-xl transition-colors"
|
className="w-full py-2.5 bg-[#10183A] hover:bg-[#1a2550] text-white text-sm font-medium rounded-xl transition-colors"
|
||||||
>
|
>
|
||||||
J'ai copié ma clé, fermer
|
{t('close')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -144,8 +107,6 @@ function CreatedKeyModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Create key form modal ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CreateKeyModal({
|
function CreateKeyModal({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onClose,
|
onClose,
|
||||||
@ -153,6 +114,7 @@ function CreateKeyModal({
|
|||||||
onSuccess: (result: CreateApiKeyResultDto) => void;
|
onSuccess: (result: CreateApiKeyResultDto) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations('dashboard.apiKeys.createModal');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [expiresAt, setExpiresAt] = useState('');
|
const [expiresAt, setExpiresAt] = useState('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@ -176,13 +138,12 @@ function CreateKeyModal({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
<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">
|
<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 justify-between p-6 border-b">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
|
<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" />
|
<Key className="w-5 h-5 text-blue-600" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
@ -190,28 +151,26 @@ function CreateKeyModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||||
{/* Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={e => setName(e.target.value)}
|
||||||
placeholder="ex: Intégration ERP Production"
|
placeholder={t('namePlaceholder')}
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
required
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Expiry */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
Date d'expiration{' '}
|
{t('expiry')}{' '}
|
||||||
<span className="text-gray-400 font-normal">(optionnel)</span>
|
<span className="text-gray-400 font-normal">{t('optional')}</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@ -220,34 +179,30 @@ function CreateKeyModal({
|
|||||||
min={new Date().toISOString().split('T')[0]}
|
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"
|
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">
|
<p className="mt-1 text-xs text-gray-400">{t('expiryHint')}</p>
|
||||||
Si vide, la clé n'expire jamais.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{mutation.isError && (
|
{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">
|
<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" />
|
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||||
Une erreur est survenue. Veuillez réessayer.
|
{t('errorGeneric')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!name.trim() || mutation.isPending}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -256,8 +211,6 @@ function CreateKeyModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Revoke confirm modal ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function RevokeConfirmModal({
|
function RevokeConfirmModal({
|
||||||
apiKey,
|
apiKey,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
@ -267,6 +220,7 @@ function RevokeConfirmModal({
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations('dashboard.apiKeys.revokeModal');
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
<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">
|
<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">
|
<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" />
|
<Trash2 className="w-6 h-6 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 text-center mb-2">
|
<h2 className="text-lg font-semibold text-gray-900 text-center mb-2">{t('title')}</h2>
|
||||||
Révoquer cette clé ?
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-600 text-center mb-1">
|
<p className="text-sm text-gray-600 text-center mb-1">
|
||||||
<strong className="text-gray-900">{apiKey.name}</strong>
|
<strong className="text-gray-900">{apiKey.name}</strong>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 text-center">
|
<p className="text-sm text-gray-500 text-center">
|
||||||
Cette action est <strong>immédiate et irréversible</strong>. Toute requête utilisant
|
{t('description')} <strong>{t('descriptionEmphasis')}</strong>
|
||||||
cette clé sera refusée.
|
{t('descriptionRest')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 pb-6 flex gap-3">
|
<div className="px-6 pb-6 flex gap-3">
|
||||||
@ -290,13 +242,13 @@ function RevokeConfirmModal({
|
|||||||
onClick={onClose}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={onConfirm}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -304,9 +256,10 @@ function RevokeConfirmModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function ApiKeysPage() {
|
export default function ApiKeysPage() {
|
||||||
|
const t = useTranslations('dashboard.apiKeys');
|
||||||
|
const locale = useLocale();
|
||||||
|
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
||||||
const { hasFeature } = useSubscription();
|
const { hasFeature } = useSubscription();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const hasApiAccess = hasFeature('api_access');
|
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) {
|
if (!hasApiAccess) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg mx-auto mt-16 text-center">
|
<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">
|
<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" />
|
<Lock className="w-8 h-8 text-gray-400" />
|
||||||
</div>
|
</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">
|
<p className="text-gray-600 mb-8">
|
||||||
L'accès programmatique à l'API Xpeditis est disponible sur les plans{' '}
|
{t.rich('noAccess.description', {
|
||||||
<strong>Gold</strong> et <strong>Platinium</strong> uniquement.
|
gold: () => <strong>{t('noAccess.gold')}</strong>,
|
||||||
|
platinium: () => <strong>{t('noAccess.platinium')}</strong>,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/pricing"
|
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"
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -355,7 +337,6 @@ export default function ApiKeysPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Modals */}
|
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<CreateKeyModal
|
<CreateKeyModal
|
||||||
onSuccess={result => {
|
onSuccess={result => {
|
||||||
@ -376,46 +357,45 @@ export default function ApiKeysPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Page header */}
|
<PageHeader
|
||||||
<div className="flex items-start justify-between mb-8">
|
title={t('title')}
|
||||||
<div>
|
description={t('description')}
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Clés API</h1>
|
actions={
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Gérez les clés d'accès programmatique à l'API Xpeditis.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
disabled={activeKeys.length >= 20}
|
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"
|
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" />
|
<Plus className="w-4 h-4" />
|
||||||
Nouvelle clé
|
{t('newKey')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Info banner */}
|
|
||||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-100 rounded-xl flex gap-3">
|
<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" />
|
<ShieldCheck className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
<div className="text-sm text-blue-800">
|
<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>
|
<p>
|
||||||
Ajoutez l'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">
|
<code className="px-1.5 py-0.5 bg-blue-100 rounded text-blue-900 font-mono text-xs">
|
||||||
X-API-Key: xped_live_...
|
X-API-Key: xped_live_...
|
||||||
</code>{' '}
|
</code>
|
||||||
à chaque requête HTTP.{' '}
|
),
|
||||||
|
link: () => (
|
||||||
<a
|
<a
|
||||||
href="/dashboard/docs?section=authentication"
|
href="/dashboard/docs?section=authentication"
|
||||||
className="font-medium underline underline-offset-2"
|
className="font-medium underline underline-offset-2"
|
||||||
>
|
>
|
||||||
Voir la documentation
|
{t('viewDocs')}
|
||||||
</a>
|
</a>
|
||||||
|
),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keys list */}
|
|
||||||
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden">
|
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
@ -424,22 +404,21 @@ export default function ApiKeysPage() {
|
|||||||
) : !apiKeys || apiKeys.length === 0 ? (
|
) : !apiKeys || apiKeys.length === 0 ? (
|
||||||
<div className="py-16 text-center">
|
<div className="py-16 text-center">
|
||||||
<Key className="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
<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
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
className="mt-4 text-sm font-medium text-[#10183A] hover:underline"
|
className="mt-4 text-sm font-medium text-[#10183A] hover:underline"
|
||||||
>
|
>
|
||||||
Créer votre première clé
|
{t('createFirst')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-100">
|
<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">
|
<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>{t('table.name')}</span>
|
||||||
<span>Dernière utilisation</span>
|
<span>{t('table.lastUsed')}</span>
|
||||||
<span>Expiration</span>
|
<span>{t('table.expiry')}</span>
|
||||||
<span>Statut</span>
|
<span>{t('table.status')}</span>
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -448,26 +427,19 @@ export default function ApiKeysPage() {
|
|||||||
key={key.id}
|
key={key.id}
|
||||||
className="grid grid-cols-[2fr_1.5fr_1fr_1fr_auto] gap-4 items-center px-6 py-4"
|
className="grid grid-cols-[2fr_1.5fr_1fr_1fr_auto] gap-4 items-center px-6 py-4"
|
||||||
>
|
>
|
||||||
{/* Name + prefix */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">{key.name}</p>
|
<p className="text-sm font-medium text-gray-900">{key.name}</p>
|
||||||
<code className="text-xs font-mono text-gray-400">{key.keyPrefix}…</code>
|
<code className="text-xs font-mono text-gray-400">{key.keyPrefix}…</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Last used */}
|
|
||||||
<span className="text-sm text-gray-600">{formatDate(key.lastUsedAt)}</span>
|
<span className="text-sm text-gray-600">{formatDate(key.lastUsedAt)}</span>
|
||||||
|
|
||||||
{/* Expiry */}
|
|
||||||
<span className="text-sm text-gray-600">{formatDate(key.expiresAt)}</span>
|
<span className="text-sm text-gray-600">{formatDate(key.expiresAt)}</span>
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div>{keyStatusBadge(key)}</div>
|
<div>{keyStatusBadge(key)}</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setRevokeTarget(key)}
|
onClick={() => setRevokeTarget(key)}
|
||||||
disabled={!key.isActive || revokeMutation.isPending}
|
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"
|
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" />
|
<Trash2 className="w-4 h-4" />
|
||||||
@ -478,10 +450,9 @@ export default function ApiKeysPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quota */}
|
|
||||||
{apiKeys && apiKeys.length > 0 && (
|
{apiKeys && apiKeys.length > 0 && (
|
||||||
<p className="mt-4 text-xs text-gray-400 text-right">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { useAuth } from '@/lib/context/auth-context';
|
import { useAuth } from '@/lib/context/auth-context';
|
||||||
import { getOrganization, updateOrganization } from '@/lib/api/organizations';
|
import { getOrganization, updateOrganization } from '@/lib/api/organizations';
|
||||||
import type { OrganizationResponse } from '@/types/api';
|
import type { OrganizationResponse } from '@/types/api';
|
||||||
@ -23,11 +24,11 @@ interface OrganizationForm {
|
|||||||
type TabType = 'information' | 'address' | 'subscription' | 'licenses';
|
type TabType = 'information' | 'address' | 'subscription' | 'licenses';
|
||||||
|
|
||||||
export default function OrganizationSettingsPage() {
|
export default function OrganizationSettingsPage() {
|
||||||
|
const t = useTranslations('dashboard.organizationSettings');
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('information');
|
const [activeTab, setActiveTab] = useState<TabType>('information');
|
||||||
|
|
||||||
// Auto-switch to subscription tab if coming back from Stripe (only for ADMIN/MANAGER)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isSuccess = searchParams.get('success') === 'true';
|
const isSuccess = searchParams.get('success') === 'true';
|
||||||
const isCanceled = searchParams.get('canceled') === 'true';
|
const isCanceled = searchParams.get('canceled') === 'true';
|
||||||
@ -53,7 +54,6 @@ export default function OrganizationSettingsPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [successMessage, setSuccessMessage] = 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 canEdit = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
||||||
|
|
||||||
const loadOrganization = useCallback(async () => {
|
const loadOrganization = useCallback(async () => {
|
||||||
@ -77,11 +77,11 @@ export default function OrganizationSettingsPage() {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load organization:', 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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.organizationId) {
|
if (user?.organizationId) {
|
||||||
@ -135,10 +135,10 @@ export default function OrganizationSettingsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setOrganization(updatedOrg);
|
setOrganization(updatedOrg);
|
||||||
setSuccessMessage('Informations sauvegardées avec succès');
|
setSuccessMessage(t('saveSuccess'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update organization:', 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 {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@ -149,7 +149,7 @@ export default function OrganizationSettingsPage() {
|
|||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -159,20 +159,19 @@ export default function OrganizationSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
<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>
|
<h3 className="text-lg font-semibold text-red-900 mb-2">{t('errorTitle')}</h3>
|
||||||
<p className="text-red-700">{error || "Impossible de charger l'organisation"}</p>
|
<p className="text-red-700">{error || t('loadError')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user can view subscription and licenses (only ADMIN and MANAGER)
|
|
||||||
const canViewBilling = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
const canViewBilling = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
id: 'information' as TabType,
|
id: 'information' as TabType,
|
||||||
label: 'Informations',
|
label: t('tabs.information'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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,
|
id: 'address' as TabType,
|
||||||
label: 'Adresse',
|
label: t('tabs.address'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
// Only show subscription and licenses tabs for ADMIN and MANAGER roles
|
|
||||||
...(canViewBilling ? [
|
...(canViewBilling ? [
|
||||||
{
|
{
|
||||||
id: 'subscription' as TabType,
|
id: 'subscription' as TabType,
|
||||||
label: 'Abonnement',
|
label: t('tabs.subscription'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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,
|
id: 'licenses' as TabType,
|
||||||
label: 'Licences',
|
label: t('tabs.licenses'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Paramètres de l'organisation</h1>
|
<h1 className="text-3xl font-bold text-gray-900">{t('header.title')}</h1>
|
||||||
<p className="text-gray-600 mt-2">Gérez les informations de votre organisation</p>
|
<p className="text-gray-600 mt-2">{t('header.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Success Message */}
|
|
||||||
{successMessage && (activeTab === 'information' || activeTab === 'address') && (
|
{successMessage && (activeTab === 'information' || activeTab === 'address') && (
|
||||||
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
|
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@ -232,7 +228,6 @@ export default function OrganizationSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (activeTab === 'information' || activeTab === 'address') && (
|
{error && (activeTab === 'information' || activeTab === 'address') && (
|
||||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@ -244,19 +239,17 @@ export default function OrganizationSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Read-only warning for USER role */}
|
|
||||||
{!canEdit && (activeTab === 'information' || activeTab === 'address') && (
|
{!canEdit && (activeTab === 'information' || activeTab === 'address') && (
|
||||||
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<p className="text-blue-800 font-medium">Mode lecture seule - Seuls les administrateurs et managers peuvent modifier l'organisation</p>
|
<p className="text-blue-800 font-medium">{t('readOnlyWarning')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="bg-white rounded-lg shadow-md">
|
<div className="bg-white rounded-lg shadow-md">
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<nav className="flex -mb-px overflow-x-auto">
|
<nav className="flex -mb-px overflow-x-auto">
|
||||||
@ -279,14 +272,12 @@ export default function OrganizationSettingsPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
{activeTab === 'information' && (
|
{activeTab === 'information' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Nom de la société */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -294,16 +285,15 @@ export default function OrganizationSettingsPage() {
|
|||||||
onChange={e => handleChange('name', e.target.value)}
|
onChange={e => handleChange('name', e.target.value)}
|
||||||
disabled={!canEdit}
|
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"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SIREN */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
SIREN
|
{t('information.siren')}
|
||||||
<span className="ml-2 text-xs text-gray-500">(Système d'Identification du Répertoire des Entreprises)</span>
|
<span className="ml-2 text-xs text-gray-500">({t('information.sirenHint')})</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -311,17 +301,16 @@ export default function OrganizationSettingsPage() {
|
|||||||
onChange={e => handleChange('siren', e.target.value.replace(/\D/g, '').slice(0, 9))}
|
onChange={e => handleChange('siren', e.target.value.replace(/\D/g, '').slice(0, 9))}
|
||||||
disabled={!canEdit}
|
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"
|
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}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Numéro EORI */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Numéro EORI
|
{t('information.eori')}
|
||||||
<span className="ml-2 text-xs text-gray-500">(Economic Operators Registration and Identification)</span>
|
<span className="ml-2 text-xs text-gray-500">({t('information.eoriHint')})</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -329,35 +318,33 @@ export default function OrganizationSettingsPage() {
|
|||||||
onChange={e => handleChange('eori', e.target.value.toUpperCase())}
|
onChange={e => handleChange('eori', e.target.value.toUpperCase())}
|
||||||
disabled={!canEdit}
|
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"
|
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}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Téléphone */}
|
|
||||||
<div>
|
<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
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={formData.contact_phone}
|
value={formData.contact_phone}
|
||||||
onChange={e => handleChange('contact_phone', e.target.value)}
|
onChange={e => handleChange('contact_phone', e.target.value)}
|
||||||
disabled={!canEdit}
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<div>
|
<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
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.contact_email}
|
value={formData.contact_email}
|
||||||
onChange={e => handleChange('contact_email', e.target.value)}
|
onChange={e => handleChange('contact_email', e.target.value)}
|
||||||
disabled={!canEdit}
|
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -365,10 +352,9 @@ export default function OrganizationSettingsPage() {
|
|||||||
|
|
||||||
{activeTab === 'address' && (
|
{activeTab === 'address' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Rue */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -376,16 +362,15 @@ export default function OrganizationSettingsPage() {
|
|||||||
onChange={e => handleChange('address_street', e.target.value)}
|
onChange={e => handleChange('address_street', e.target.value)}
|
||||||
disabled={!canEdit}
|
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"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ville et Code postal */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -393,13 +378,13 @@ export default function OrganizationSettingsPage() {
|
|||||||
onChange={e => handleChange('address_postal_code', e.target.value)}
|
onChange={e => handleChange('address_postal_code', e.target.value)}
|
||||||
disabled={!canEdit}
|
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"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -407,16 +392,15 @@ export default function OrganizationSettingsPage() {
|
|||||||
onChange={e => handleChange('address_city', e.target.value)}
|
onChange={e => handleChange('address_city', e.target.value)}
|
||||||
disabled={!canEdit}
|
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"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pays */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<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>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.address_country}
|
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"
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="FR">France</option>
|
<option value="FR">{t('address.countries.FR')}</option>
|
||||||
<option value="BE">Belgique</option>
|
<option value="BE">{t('address.countries.BE')}</option>
|
||||||
<option value="DE">Allemagne</option>
|
<option value="DE">{t('address.countries.DE')}</option>
|
||||||
<option value="ES">Espagne</option>
|
<option value="ES">{t('address.countries.ES')}</option>
|
||||||
<option value="IT">Italie</option>
|
<option value="IT">{t('address.countries.IT')}</option>
|
||||||
<option value="NL">Pays-Bas</option>
|
<option value="NL">{t('address.countries.NL')}</option>
|
||||||
<option value="GB">Royaume-Uni</option>
|
<option value="GB">{t('address.countries.GB')}</option>
|
||||||
<option value="US">États-Unis</option>
|
<option value="US">{t('address.countries.US')}</option>
|
||||||
<option value="CN">Chine</option>
|
<option value="CN">{t('address.countries.CN')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -444,7 +428,6 @@ export default function OrganizationSettingsPage() {
|
|||||||
{activeTab === 'licenses' && canViewBilling && <LicensesTab />}
|
{activeTab === 'licenses' && canViewBilling && <LicensesTab />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions (only for information and address tabs) */}
|
|
||||||
{canEdit && (activeTab === 'information' || activeTab === 'address') && (
|
{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">
|
<div className="bg-gray-50 px-8 py-4 border-t border-gray-200 flex items-center justify-end space-x-4">
|
||||||
<button
|
<button
|
||||||
@ -453,7 +436,7 @@ export default function OrganizationSettingsPage() {
|
|||||||
disabled={isSaving}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -464,10 +447,10 @@ export default function OrganizationSettingsPage() {
|
|||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1,20 +1,16 @@
|
|||||||
/**
|
|
||||||
* Subscription Management Page
|
|
||||||
*
|
|
||||||
* Redirects to Organization settings with Subscription tab
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
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() {
|
export default function SubscriptionPage() {
|
||||||
|
const t = useTranslations('dashboard.subscriptionRedirect');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Preserve any query parameters (success, canceled) from Stripe redirects
|
|
||||||
const params = searchParams.toString();
|
const params = searchParams.toString();
|
||||||
const redirectUrl = `/dashboard/settings/organization${params ? `?${params}` : ''}`;
|
const redirectUrl = `/dashboard/settings/organization${params ? `?${params}` : ''}`;
|
||||||
router.replace(redirectUrl);
|
router.replace(redirectUrl);
|
||||||
@ -24,7 +20,7 @@ export default function SubscriptionPage() {
|
|||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -1,19 +1,14 @@
|
|||||||
/**
|
|
||||||
* User Management Page
|
|
||||||
*
|
|
||||||
* Manage organization users, roles, and invitations
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
|
import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
|
||||||
import { createInvitation, listInvitations, cancelInvitation } from '@/lib/api/invitations';
|
import { createInvitation, listInvitations, cancelInvitation } from '@/lib/api/invitations';
|
||||||
import { useAuth } from '@/lib/context/auth-context';
|
import { useAuth } from '@/lib/context/auth-context';
|
||||||
import Link from 'next/link';
|
import { Link, useRouter } from '@/i18n/navigation';
|
||||||
import ExportButton from '@/components/ExportButton';
|
import ExportButton from '@/components/ExportButton';
|
||||||
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
|
|
||||||
const PAGE_SIZE = 5;
|
const PAGE_SIZE = 5;
|
||||||
|
|
||||||
@ -26,13 +21,18 @@ function Pagination({
|
|||||||
total: number;
|
total: number;
|
||||||
onPage: (p: number) => void;
|
onPage: (p: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations('dashboard.usersManagement.pagination');
|
||||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
if (totalPages <= 1) return null;
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-3 flex items-center justify-between border-t border-gray-200">
|
<div className="px-6 py-3 flex items-center justify-between border-t border-gray-200">
|
||||||
<p className="text-sm text-gray-500">
|
<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>
|
</p>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
@ -68,6 +68,9 @@ function Pagination({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function UsersManagementPage() {
|
export default function UsersManagementPage() {
|
||||||
|
const t = useTranslations('dashboard.usersManagement');
|
||||||
|
const locale = useLocale();
|
||||||
|
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
@ -106,14 +109,14 @@ export default function UsersManagementPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['invitations'] });
|
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);
|
setShowInviteModal(false);
|
||||||
setInviteForm({ email: '', firstName: '', lastName: '', role: 'USER' });
|
setInviteForm({ email: '', firstName: '', lastName: '', role: 'USER' });
|
||||||
setInvitationsPage(1);
|
setInvitationsPage(1);
|
||||||
setTimeout(() => setSuccess(''), 5000);
|
setTimeout(() => setSuccess(''), 5000);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
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);
|
setTimeout(() => setError(''), 5000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -123,11 +126,11 @@ export default function UsersManagementPage() {
|
|||||||
updateUser(id, { role }),
|
updateUser(id, { role }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
setSuccess('Rôle mis à jour avec succès');
|
setSuccess(t('messages.roleSuccess'));
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
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);
|
setTimeout(() => setError(''), 5000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -138,11 +141,11 @@ export default function UsersManagementPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||||
setSuccess("Statut de l'utilisateur mis à jour avec succès");
|
setSuccess(t('messages.statusSuccess'));
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
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);
|
setTimeout(() => setError(''), 5000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -152,11 +155,11 @@ export default function UsersManagementPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||||
setSuccess('Utilisateur supprimé avec succès');
|
setSuccess(t('messages.deleteSuccess'));
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
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);
|
setTimeout(() => setError(''), 5000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -166,11 +169,11 @@ export default function UsersManagementPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['invitations'] });
|
queryClient.invalidateQueries({ queryKey: ['invitations'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||||
setSuccess('Invitation annulée avec succès');
|
setSuccess(t('messages.cancelInviteSuccess'));
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
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);
|
setTimeout(() => setError(''), 5000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -200,19 +203,20 @@ export default function UsersManagementPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleActive = (userId: string, isActive: boolean) => {
|
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 });
|
toggleActiveMutation.mutate({ id: userId, isActive });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (userId: string) => {
|
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);
|
deleteMutation.mutate(userId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelInvitation = (invId: string, name: string) => {
|
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);
|
cancelInvitationMutation.mutate(invId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -235,7 +239,6 @@ export default function UsersManagementPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* License Warning */}
|
|
||||||
{licenseStatus && !licenseStatus.canInvite && (
|
{licenseStatus && !licenseStatus.canInvite && (
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
@ -245,14 +248,13 @@ export default function UsersManagementPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 flex-1">
|
<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">
|
<p className="mt-1 text-sm text-amber-700">
|
||||||
Votre organisation a utilisé toutes les licences disponibles ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
|
{t('license.limitMessage', { used: licenseStatus.usedLicenses, max: licenseStatus.maxLicenses })}
|
||||||
Mettez à niveau votre abonnement pour inviter plus d'utilisateurs.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-amber-800 hover:text-amber-900 underline">
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -260,7 +262,6 @@ export default function UsersManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* License Usage Info */}
|
|
||||||
{licenseStatus && licenseStatus.canInvite && licenseStatus.availableLicenses <= 2 && licenseStatus.maxLicenses !== -1 && (
|
{licenseStatus && licenseStatus.canInvite && licenseStatus.availableLicenses <= 2 && licenseStatus.maxLicenses !== -1 && (
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
<div className="flex items-center justify-between">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="text-sm text-blue-800">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-blue-600 hover:text-blue-800">
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
title={t('header.title')}
|
||||||
<div>
|
description={t('header.subtitle')}
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Gestion des Utilisateurs</h1>
|
actions={
|
||||||
<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">
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
data={allUsers}
|
data={allUsers}
|
||||||
filename="utilisateurs"
|
filename={t('exportFilename')}
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'firstName', label: 'Prénom' },
|
{ key: 'firstName', label: t('export.firstName') },
|
||||||
{ key: 'lastName', label: 'Nom' },
|
{ key: 'lastName', label: t('export.lastName') },
|
||||||
{ key: 'email', label: 'Email' },
|
{ key: 'email', label: t('export.email') },
|
||||||
{ key: 'role', label: 'Rôle', format: (v) => ({ ADMIN: 'Administrateur', MANAGER: 'Manager', USER: 'Utilisateur', VIEWER: 'Lecteur' }[v] || v) },
|
{ key: 'role', label: t('export.role'), format: (v) => t(`modal.rolesExport.${v}` as any) || v },
|
||||||
{ key: 'isActive', label: 'Statut', format: (v) => v ? 'Actif' : 'Inactif' },
|
{ key: 'isActive', label: t('export.status'), format: (v) => v ? t('users.active') : t('users.inactive') },
|
||||||
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
|
{ key: 'createdAt', label: t('export.createdAt'), format: (v) => v ? new Date(v).toLocaleDateString(dateLocale) : '' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{licenseStatus?.canInvite ? (
|
{licenseStatus?.canInvite ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInviteModal(true)}
|
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>
|
<span className="mr-1.5">+</span>
|
||||||
Inviter un utilisateur
|
<span className="hidden sm:inline">{t('actions.invite')}</span>
|
||||||
|
<span className="sm:hidden">{t('actions.inviteShort')}</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/subscription"
|
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>
|
<span className="mr-1.5">+</span>
|
||||||
Mettre à niveau
|
<span className="hidden sm:inline">{t('actions.upgrade')}</span>
|
||||||
|
<span className="sm:hidden">{t('actions.upgradeShort')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{success && (
|
{success && (
|
||||||
<div className="rounded-md bg-green-50 p-4">
|
<div className="rounded-md bg-green-50 p-4">
|
||||||
@ -330,18 +336,17 @@ export default function UsersManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Users Table */}
|
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-white rounded-lg shadow">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<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 && (
|
{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>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="px-6 py-12 text-center text-gray-500">
|
<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>
|
<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>
|
</div>
|
||||||
) : pagedUsers.length > 0 ? (
|
) : pagedUsers.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
@ -349,12 +354,12 @@ export default function UsersManagementPage() {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<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">{t('users.table.user')}</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">{t('users.table.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">{t('users.table.role')}</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">{t('users.table.status')}</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-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">Actions</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
@ -385,19 +390,19 @@ export default function UsersManagementPage() {
|
|||||||
user.id === currentUser?.id
|
user.id === currentUser?.id
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
|
{currentUser?.role === 'ADMIN' && <option value="ADMIN">{t('modal.roles.ADMIN')}</option>}
|
||||||
<option value="MANAGER">Manager</option>
|
<option value="MANAGER">{t('modal.roles.MANAGER')}</option>
|
||||||
<option value="USER">User</option>
|
<option value="USER">{t('modal.roles.USER')}</option>
|
||||||
<option value="VIEWER">Viewer</option>
|
<option value="VIEWER">{t('modal.roles.VIEWER')}</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<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'}`}>
|
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<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>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<button
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun utilisateur</h3>
|
<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">Commencez par inviter un membre de l'équipe</p>
|
<p className="mt-1 text-sm text-gray-500">{t('users.empty.description')}</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{licenseStatus?.canInvite ? (
|
{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">
|
<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>
|
<span className="mr-2">+</span>
|
||||||
Inviter un utilisateur
|
{t('actions.invite')}
|
||||||
</button>
|
</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">
|
<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>
|
<span className="mr-2">+</span>
|
||||||
Mettre à niveau
|
{t('actions.upgrade')}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -449,25 +454,24 @@ export default function UsersManagementPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pending Invitations */}
|
|
||||||
{allPending.length > 0 && (
|
{allPending.length > 0 && (
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-white rounded-lg shadow">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<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">{t('invitations.table.user')}</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">{t('invitations.table.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">{t('invitations.table.role')}</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">{t('invitations.table.expires')}</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">{t('invitations.table.status')}</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-right text-xs font-medium text-gray-500 uppercase tracking-wider">{t('invitations.table.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
@ -492,11 +496,11 @@ export default function UsersManagementPage() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<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>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<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'}`}>
|
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
Annuler
|
{t('invitations.cancel')}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -521,7 +525,6 @@ export default function UsersManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions Menu Modal */}
|
|
||||||
{openMenuId && menuPosition && (
|
{openMenuId && menuPosition && (
|
||||||
<>
|
<>
|
||||||
<div
|
<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">
|
<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" />
|
<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>
|
</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">
|
<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" />
|
<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>
|
</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>
|
</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">
|
<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" />
|
<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>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Invite Modal */}
|
|
||||||
{showInviteModal && (
|
{showInviteModal && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<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">
|
<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 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>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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">
|
<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">
|
<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" />
|
<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">
|
<form onSubmit={handleInvite} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -606,7 +608,7 @@ export default function UsersManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -617,7 +619,7 @@ export default function UsersManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
@ -627,15 +629,15 @@ export default function UsersManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<select
|
||||||
value={inviteForm.role}
|
value={inviteForm.role}
|
||||||
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
|
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"
|
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="USER">{t('modal.roles.USER')}</option>
|
||||||
<option value="MANAGER">Manager</option>
|
<option value="MANAGER">{t('modal.roles.MANAGER')}</option>
|
||||||
<option value="VIEWER">Lecteur</option>
|
<option value="VIEWER">{t('modal.roles.VIEWER')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
<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}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowInviteModal(false)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user