Compare commits
No commits in common. "9bed6b54a7cecf21def66b614ea2096235c74db9" and "94039598d90bc70c205a74fb2733abe3328cbba7" have entirely different histories.
9bed6b54a7
...
94039598d9
@ -1,12 +1,7 @@
|
||||
import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
import { Public } from '../decorators/public.decorator';
|
||||
import { CsvBookingService } from '../services/csv-booking.service';
|
||||
import {
|
||||
CarrierDocumentsResponseDto,
|
||||
VerifyDocumentAccessDto,
|
||||
DocumentAccessRequirementsDto,
|
||||
} from '../dto/carrier-documents.dto';
|
||||
|
||||
/**
|
||||
* CSV Booking Actions Controller (Public Routes)
|
||||
@ -93,84 +88,4 @@ export class CsvBookingActionsController {
|
||||
reason: reason || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check document access requirements (PUBLIC - token-based)
|
||||
*
|
||||
* GET /api/v1/csv-booking-actions/documents/:token/requirements
|
||||
*/
|
||||
@Public()
|
||||
@Get('documents/:token/requirements')
|
||||
@ApiOperation({
|
||||
summary: 'Check document access requirements (public)',
|
||||
description:
|
||||
'Check if a password is required to access booking documents. Use this before showing the password form.',
|
||||
})
|
||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Access requirements retrieved successfully.',
|
||||
type: DocumentAccessRequirementsDto,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
||||
async getDocumentAccessRequirements(
|
||||
@Param('token') token: string
|
||||
): Promise<DocumentAccessRequirementsDto> {
|
||||
return this.csvBookingService.checkDocumentAccessRequirements(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get booking documents for carrier with password verification (PUBLIC - token-based)
|
||||
*
|
||||
* POST /api/v1/csv-booking-actions/documents/:token
|
||||
*/
|
||||
@Public()
|
||||
@Post('documents/:token')
|
||||
@ApiOperation({
|
||||
summary: 'Get booking documents with password (public)',
|
||||
description:
|
||||
'Public endpoint for carriers to access booking documents after acceptance. Requires password verification. Returns booking summary and documents with signed download URLs.',
|
||||
})
|
||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||||
@ApiBody({ type: VerifyDocumentAccessDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Booking documents retrieved successfully.',
|
||||
type: CarrierDocumentsResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
||||
@ApiResponse({ status: 400, description: 'Booking has not been accepted yet' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid password' })
|
||||
async getBookingDocumentsWithPassword(
|
||||
@Param('token') token: string,
|
||||
@Body() dto: VerifyDocumentAccessDto
|
||||
): Promise<CarrierDocumentsResponseDto> {
|
||||
return this.csvBookingService.getDocumentsForCarrier(token, dto.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get booking documents for carrier (PUBLIC - token-based) - Legacy without password
|
||||
* Kept for backward compatibility with bookings created before password protection
|
||||
*
|
||||
* GET /api/v1/csv-booking-actions/documents/:token
|
||||
*/
|
||||
@Public()
|
||||
@Get('documents/:token')
|
||||
@ApiOperation({
|
||||
summary: 'Get booking documents (public) - Legacy',
|
||||
description:
|
||||
'Public endpoint for carriers to access booking documents. For new bookings, use POST with password instead.',
|
||||
})
|
||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Booking documents retrieved successfully.',
|
||||
type: CarrierDocumentsResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
||||
@ApiResponse({ status: 400, description: 'Booking has not been accepted yet' })
|
||||
@ApiResponse({ status: 401, description: 'Password required for this booking' })
|
||||
async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> {
|
||||
return this.csvBookingService.getDocumentsForCarrier(token);
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,20 +14,13 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Res,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { Response, Request } from 'express';
|
||||
import { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||
import { UserPayload } from '../decorators/current-user.decorator';
|
||||
import { GDPRService } from '../services/gdpr.service';
|
||||
import {
|
||||
UpdateConsentDto,
|
||||
ConsentResponseDto,
|
||||
WithdrawConsentDto,
|
||||
ConsentSuccessDto,
|
||||
} from '../dto/consent.dto';
|
||||
import { GDPRService, ConsentData } from '../services/gdpr.service';
|
||||
|
||||
@ApiTags('GDPR')
|
||||
@Controller('gdpr')
|
||||
@ -84,13 +77,6 @@ export class GDPRController {
|
||||
csv += `User Data,${key},"${value}"\n`;
|
||||
});
|
||||
|
||||
// Cookie consent data
|
||||
if (exportData.cookieConsent) {
|
||||
Object.entries(exportData.cookieConsent).forEach(([key, value]) => {
|
||||
csv += `Cookie Consent,${key},"${value}"\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Set headers
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader(
|
||||
@ -133,26 +119,22 @@ export class GDPRController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Record user consent',
|
||||
description: 'Record consent for cookies (GDPR Article 7)',
|
||||
description: 'Record consent for marketing, analytics, etc. (GDPR Article 7)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Consent recorded',
|
||||
type: ConsentResponseDto,
|
||||
})
|
||||
async recordConsent(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: UpdateConsentDto,
|
||||
@Req() req: Request
|
||||
): Promise<ConsentResponseDto> {
|
||||
// Add IP and user agent from request if not provided
|
||||
const consentData: UpdateConsentDto = {
|
||||
@Body() body: Omit<ConsentData, 'userId'>
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.gdprService.recordConsent({
|
||||
...body,
|
||||
ipAddress: body.ipAddress || req.ip || req.socket.remoteAddress,
|
||||
userAgent: body.userAgent || req.headers['user-agent'],
|
||||
};
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return this.gdprService.recordConsent(user.id, consentData);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -162,18 +144,19 @@ export class GDPRController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Withdraw consent',
|
||||
description: 'Withdraw consent for functional, analytics, or marketing (GDPR Article 7.3)',
|
||||
description: 'Withdraw consent for marketing or analytics (GDPR Article 7.3)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Consent withdrawn',
|
||||
type: ConsentResponseDto,
|
||||
})
|
||||
async withdrawConsent(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: WithdrawConsentDto
|
||||
): Promise<ConsentResponseDto> {
|
||||
return this.gdprService.withdrawConsent(user.id, body.consentType);
|
||||
@Body() body: { consentType: 'marketing' | 'analytics' }
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.gdprService.withdrawConsent(user.id, body.consentType);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -187,9 +170,8 @@ export class GDPRController {
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Consent status retrieved',
|
||||
type: ConsentResponseDto,
|
||||
})
|
||||
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<ConsentResponseDto | null> {
|
||||
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<any> {
|
||||
return this.gdprService.getConsentStatus(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,14 +3,12 @@ import {
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
UseGuards,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
@ -19,7 +17,6 @@ import {
|
||||
ApiBadRequestResponse,
|
||||
ApiInternalServerErrorResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
|
||||
import { RateQuoteMapper } from '../mappers';
|
||||
@ -28,15 +25,8 @@ import { CsvRateSearchService } from '@domain/services/csv-rate-search.service';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||
import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
|
||||
import {
|
||||
AvailableCompaniesDto,
|
||||
FilterOptionsDto,
|
||||
AvailableOriginsDto,
|
||||
AvailableDestinationsDto,
|
||||
RoutePortInfoDto,
|
||||
} from '../dto/csv-rate-upload.dto';
|
||||
import { AvailableCompaniesDto, FilterOptionsDto } from '../dto/csv-rate-upload.dto';
|
||||
import { CsvRateMapper } from '../mappers/csv-rate.mapper';
|
||||
import { PortRepository, PORT_REPOSITORY } from '@domain/ports/out/port.repository';
|
||||
|
||||
@ApiTags('Rates')
|
||||
@Controller('rates')
|
||||
@ -47,8 +37,7 @@ export class RatesController {
|
||||
constructor(
|
||||
private readonly rateSearchService: RateSearchService,
|
||||
private readonly csvRateSearchService: CsvRateSearchService,
|
||||
private readonly csvRateMapper: CsvRateMapper,
|
||||
@Inject(PORT_REPOSITORY) private readonly portRepository: PortRepository
|
||||
private readonly csvRateMapper: CsvRateMapper
|
||||
) {}
|
||||
|
||||
@Post('search')
|
||||
@ -282,168 +271,6 @@ export class RatesController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available origin ports from CSV rates
|
||||
* Returns only ports that have routes defined in CSV files
|
||||
*/
|
||||
@Get('available-routes/origins')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Get available origin ports from CSV rates',
|
||||
description:
|
||||
'Returns list of origin ports that have shipping routes defined in CSV rate files. Use this to populate origin port selection dropdown.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: 'List of available origin ports with details',
|
||||
type: AvailableOriginsDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: 'Unauthorized - missing or invalid token',
|
||||
})
|
||||
async getAvailableOrigins(): Promise<AvailableOriginsDto> {
|
||||
this.logger.log('Fetching available origin ports from CSV rates');
|
||||
|
||||
try {
|
||||
// Get unique origin port codes from CSV rates
|
||||
const originCodes = await this.csvRateSearchService.getAvailableOrigins();
|
||||
|
||||
// Fetch port details from database
|
||||
const ports = await this.portRepository.findByCodes(originCodes);
|
||||
|
||||
// Map to response DTO with port details
|
||||
const origins: RoutePortInfoDto[] = originCodes.map(code => {
|
||||
const port = ports.find(p => p.code === code);
|
||||
if (port) {
|
||||
return {
|
||||
code: port.code,
|
||||
name: port.name,
|
||||
city: port.city,
|
||||
country: port.country,
|
||||
countryName: port.countryName,
|
||||
displayName: port.getDisplayName(),
|
||||
latitude: port.coordinates.latitude,
|
||||
longitude: port.coordinates.longitude,
|
||||
};
|
||||
}
|
||||
// Fallback if port not found in database
|
||||
return {
|
||||
code,
|
||||
name: code,
|
||||
city: '',
|
||||
country: code.substring(0, 2),
|
||||
countryName: '',
|
||||
displayName: code,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by display name
|
||||
origins.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
|
||||
return {
|
||||
origins,
|
||||
total: origins.length,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Failed to fetch available origins: ${error?.message || 'Unknown error'}`,
|
||||
error?.stack
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available destination ports for a given origin
|
||||
* Returns only destinations that have routes from the specified origin in CSV files
|
||||
*/
|
||||
@Get('available-routes/destinations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Get available destination ports for a given origin',
|
||||
description:
|
||||
'Returns list of destination ports that have shipping routes from the specified origin port in CSV rate files. Use this to populate destination port selection dropdown after origin is selected.',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'origin',
|
||||
required: true,
|
||||
description: 'Origin port code (UN/LOCODE format, e.g., NLRTM)',
|
||||
example: 'NLRTM',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: 'List of available destination ports with details',
|
||||
type: AvailableDestinationsDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: 'Unauthorized - missing or invalid token',
|
||||
})
|
||||
@ApiBadRequestResponse({
|
||||
description: 'Origin port code is required',
|
||||
})
|
||||
async getAvailableDestinations(
|
||||
@Query('origin') origin: string
|
||||
): Promise<AvailableDestinationsDto> {
|
||||
this.logger.log(`Fetching available destinations for origin: ${origin}`);
|
||||
|
||||
if (!origin) {
|
||||
throw new Error('Origin port code is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get destination port codes for this origin from CSV rates
|
||||
const destinationCodes = await this.csvRateSearchService.getAvailableDestinations(origin);
|
||||
|
||||
// Fetch port details from database
|
||||
const ports = await this.portRepository.findByCodes(destinationCodes);
|
||||
|
||||
// Map to response DTO with port details
|
||||
const destinations: RoutePortInfoDto[] = destinationCodes.map(code => {
|
||||
const port = ports.find(p => p.code === code);
|
||||
if (port) {
|
||||
return {
|
||||
code: port.code,
|
||||
name: port.name,
|
||||
city: port.city,
|
||||
country: port.country,
|
||||
countryName: port.countryName,
|
||||
displayName: port.getDisplayName(),
|
||||
latitude: port.coordinates.latitude,
|
||||
longitude: port.coordinates.longitude,
|
||||
};
|
||||
}
|
||||
// Fallback if port not found in database
|
||||
return {
|
||||
code,
|
||||
name: code,
|
||||
city: '',
|
||||
country: code.substring(0, 2),
|
||||
countryName: '',
|
||||
displayName: code,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by display name
|
||||
destinations.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
|
||||
return {
|
||||
origin: origin.toUpperCase(),
|
||||
destinations,
|
||||
total: destinations.length,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Failed to fetch available destinations: ${error?.message || 'Unknown error'}`,
|
||||
error?.stack
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available companies
|
||||
*/
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for verifying document access password
|
||||
*/
|
||||
export class VerifyDocumentAccessDto {
|
||||
@ApiProperty({ description: 'Password for document access (booking number code)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response DTO for checking document access requirements
|
||||
*/
|
||||
export class DocumentAccessRequirementsDto {
|
||||
@ApiProperty({ description: 'Whether password is required to access documents' })
|
||||
requiresPassword: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Booking number (if available)' })
|
||||
bookingNumber?: string;
|
||||
|
||||
@ApiProperty({ description: 'Current booking status' })
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking Summary DTO for Carrier Documents Page
|
||||
*/
|
||||
export class BookingSummaryDto {
|
||||
@ApiProperty({ description: 'Booking unique ID' })
|
||||
id: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Human-readable booking number' })
|
||||
bookingNumber?: string;
|
||||
|
||||
@ApiProperty({ description: 'Carrier/Company name' })
|
||||
carrierName: string;
|
||||
|
||||
@ApiProperty({ description: 'Origin port code' })
|
||||
origin: string;
|
||||
|
||||
@ApiProperty({ description: 'Destination port code' })
|
||||
destination: string;
|
||||
|
||||
@ApiProperty({ description: 'Route description (origin -> destination)' })
|
||||
routeDescription: string;
|
||||
|
||||
@ApiProperty({ description: 'Volume in CBM' })
|
||||
volumeCBM: number;
|
||||
|
||||
@ApiProperty({ description: 'Weight in KG' })
|
||||
weightKG: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of pallets' })
|
||||
palletCount: number;
|
||||
|
||||
@ApiProperty({ description: 'Price in the primary currency' })
|
||||
price: number;
|
||||
|
||||
@ApiProperty({ description: 'Currency (USD or EUR)' })
|
||||
currency: string;
|
||||
|
||||
@ApiProperty({ description: 'Transit time in days' })
|
||||
transitDays: number;
|
||||
|
||||
@ApiProperty({ description: 'Container type' })
|
||||
containerType: string;
|
||||
|
||||
@ApiProperty({ description: 'When the booking was accepted' })
|
||||
acceptedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Document with signed download URL for carrier access
|
||||
*/
|
||||
export class DocumentWithUrlDto {
|
||||
@ApiProperty({ description: 'Document unique ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Document type',
|
||||
enum: ['BILL_OF_LADING', 'PACKING_LIST', 'COMMERCIAL_INVOICE', 'CERTIFICATE_OF_ORIGIN', 'OTHER'],
|
||||
})
|
||||
type: string;
|
||||
|
||||
@ApiProperty({ description: 'Original file name' })
|
||||
fileName: string;
|
||||
|
||||
@ApiProperty({ description: 'File MIME type' })
|
||||
mimeType: string;
|
||||
|
||||
@ApiProperty({ description: 'File size in bytes' })
|
||||
size: number;
|
||||
|
||||
@ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' })
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrier Documents Response DTO
|
||||
*
|
||||
* Response for carrier document access page
|
||||
*/
|
||||
export class CarrierDocumentsResponseDto {
|
||||
@ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto })
|
||||
booking: BookingSummaryDto;
|
||||
|
||||
@ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] })
|
||||
documents: DocumentWithUrlDto[];
|
||||
}
|
||||
@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Cookie Consent DTOs
|
||||
* GDPR compliant consent management
|
||||
*/
|
||||
|
||||
import { IsBoolean, IsOptional, IsString, IsEnum, IsDateString, IsIP } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* Request DTO for recording/updating cookie consent
|
||||
*/
|
||||
export class UpdateConsentDto {
|
||||
@ApiProperty({
|
||||
example: true,
|
||||
description: 'Essential cookies consent (always true, required for functionality)',
|
||||
default: true,
|
||||
})
|
||||
@IsBoolean()
|
||||
essential: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: false,
|
||||
description: 'Functional cookies consent (preferences, language, etc.)',
|
||||
default: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
functional: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: false,
|
||||
description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)',
|
||||
default: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
analytics: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: false,
|
||||
description: 'Marketing cookies consent (ads, tracking, remarketing)',
|
||||
default: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
marketing: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '192.168.1.1',
|
||||
description: 'IP address at time of consent (for GDPR audit trail)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ipAddress?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
description: 'User agent at time of consent',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response DTO for consent status
|
||||
*/
|
||||
export class ConsentResponseDto {
|
||||
@ApiProperty({
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
description: 'User ID',
|
||||
})
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: true,
|
||||
description: 'Essential cookies consent (always true)',
|
||||
})
|
||||
essential: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: false,
|
||||
description: 'Functional cookies consent',
|
||||
})
|
||||
functional: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: false,
|
||||
description: 'Analytics cookies consent',
|
||||
})
|
||||
analytics: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: false,
|
||||
description: 'Marketing cookies consent',
|
||||
})
|
||||
marketing: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2025-01-27T10:30:00.000Z',
|
||||
description: 'Date when consent was recorded',
|
||||
})
|
||||
consentDate: Date;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2025-01-27T10:30:00.000Z',
|
||||
description: 'Last update timestamp',
|
||||
})
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request DTO for withdrawing specific consent
|
||||
*/
|
||||
export class WithdrawConsentDto {
|
||||
@ApiProperty({
|
||||
example: 'marketing',
|
||||
description: 'Type of consent to withdraw',
|
||||
enum: ['functional', 'analytics', 'marketing'],
|
||||
})
|
||||
@IsEnum(['functional', 'analytics', 'marketing'], {
|
||||
message: 'Consent type must be functional, analytics, or marketing',
|
||||
})
|
||||
consentType: 'functional' | 'analytics' | 'marketing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Success response DTO
|
||||
*/
|
||||
export class ConsentSuccessDto {
|
||||
@ApiProperty({
|
||||
example: true,
|
||||
description: 'Operation success status',
|
||||
})
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Consent preferences saved successfully',
|
||||
description: 'Response message',
|
||||
})
|
||||
message: string;
|
||||
}
|
||||
@ -201,12 +201,6 @@ export class CsvBookingResponseDto {
|
||||
})
|
||||
id: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Booking number (e.g. XPD-2026-W75VPT)',
|
||||
example: 'XPD-2026-W75VPT',
|
||||
})
|
||||
bookingNumber?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'User ID who created the booking',
|
||||
example: '987fcdeb-51a2-43e8-9c6d-8b9a1c2d3e4f',
|
||||
|
||||
@ -209,101 +209,3 @@ export class FilterOptionsDto {
|
||||
})
|
||||
currencies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Port Info for Route Response DTO
|
||||
* Contains port details with coordinates for map display
|
||||
*/
|
||||
export class RoutePortInfoDto {
|
||||
@ApiProperty({
|
||||
description: 'UN/LOCODE port code',
|
||||
example: 'NLRTM',
|
||||
})
|
||||
code: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Port name',
|
||||
example: 'Rotterdam',
|
||||
})
|
||||
name: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'City name',
|
||||
example: 'Rotterdam',
|
||||
})
|
||||
city: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Country code (ISO 3166-1 alpha-2)',
|
||||
example: 'NL',
|
||||
})
|
||||
country: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Country full name',
|
||||
example: 'Netherlands',
|
||||
})
|
||||
countryName: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Display name for UI',
|
||||
example: 'Rotterdam, Netherlands (NLRTM)',
|
||||
})
|
||||
displayName: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Latitude coordinate',
|
||||
example: 51.9244,
|
||||
required: false,
|
||||
})
|
||||
latitude?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Longitude coordinate',
|
||||
example: 4.4777,
|
||||
required: false,
|
||||
})
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available Origins Response DTO
|
||||
* Returns list of origin ports that have routes in CSV rates
|
||||
*/
|
||||
export class AvailableOriginsDto {
|
||||
@ApiProperty({
|
||||
description: 'List of origin ports with available routes in CSV rates',
|
||||
type: [RoutePortInfoDto],
|
||||
})
|
||||
origins: RoutePortInfoDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Total number of available origin ports',
|
||||
example: 15,
|
||||
})
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available Destinations Response DTO
|
||||
* Returns list of destination ports available for a given origin
|
||||
*/
|
||||
export class AvailableDestinationsDto {
|
||||
@ApiProperty({
|
||||
description: 'Origin port code that was used to filter destinations',
|
||||
example: 'NLRTM',
|
||||
})
|
||||
origin: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'List of destination ports available from the given origin',
|
||||
type: [RoutePortInfoDto],
|
||||
})
|
||||
destinations: RoutePortInfoDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Total number of available destinations for this origin',
|
||||
example: 8,
|
||||
})
|
||||
total: number;
|
||||
}
|
||||
|
||||
@ -12,7 +12,6 @@ import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities
|
||||
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
|
||||
import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity';
|
||||
import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity';
|
||||
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -21,7 +20,6 @@ import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm
|
||||
BookingOrmEntity,
|
||||
AuditLogOrmEntity,
|
||||
NotificationOrmEntity,
|
||||
CookieConsentOrmEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [GDPRController],
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Inject,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as argon2 from 'argon2';
|
||||
import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity';
|
||||
import { PortCode } from '@domain/value-objects/port-code.vo';
|
||||
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
||||
@ -29,7 +21,6 @@ import {
|
||||
CsvBookingListResponseDto,
|
||||
CsvBookingStatsDto,
|
||||
} from '../dto/csv-booking.dto';
|
||||
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
|
||||
|
||||
/**
|
||||
* CSV Booking Document (simple class for domain)
|
||||
@ -65,27 +56,6 @@ export class CsvBookingService {
|
||||
private readonly storageAdapter: StoragePort
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate a unique booking number
|
||||
* Format: XPD-YYYY-XXXXXX (e.g., XPD-2025-A3B7K9)
|
||||
*/
|
||||
private generateBookingNumber(): string {
|
||||
const year = new Date().getFullYear();
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity
|
||||
let code = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return `XPD-${year}-${code}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the password from booking number (last 6 characters)
|
||||
*/
|
||||
private extractPasswordFromBookingNumber(bookingNumber: string): string {
|
||||
return bookingNumber.split('-').pop() || bookingNumber.slice(-6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new CSV booking request
|
||||
*/
|
||||
@ -102,14 +72,9 @@ export class CsvBookingService {
|
||||
throw new BadRequestException('At least one document is required');
|
||||
}
|
||||
|
||||
// Generate unique confirmation token and booking number
|
||||
// Generate unique confirmation token
|
||||
const confirmationToken = uuidv4();
|
||||
const bookingId = uuidv4();
|
||||
const bookingNumber = this.generateBookingNumber();
|
||||
const documentPassword = this.extractPasswordFromBookingNumber(bookingNumber);
|
||||
|
||||
// Hash the password for storage
|
||||
const passwordHash = await argon2.hash(documentPassword);
|
||||
|
||||
// Upload documents to S3
|
||||
const documents = await this.uploadDocuments(files, bookingId);
|
||||
@ -141,26 +106,13 @@ export class CsvBookingService {
|
||||
|
||||
// Save to database
|
||||
const savedBooking = await this.csvBookingRepository.create(booking);
|
||||
|
||||
// Update ORM entity with booking number and password hash
|
||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
if (ormBooking) {
|
||||
ormBooking.bookingNumber = bookingNumber;
|
||||
ormBooking.passwordHash = passwordHash;
|
||||
await this.csvBookingRepository['repository'].save(ormBooking);
|
||||
}
|
||||
|
||||
this.logger.log(`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}`);
|
||||
this.logger.log(`CSV booking created with ID: ${bookingId}`);
|
||||
|
||||
// Send email to carrier and WAIT for confirmation
|
||||
// The button waits for the email to be sent before responding
|
||||
try {
|
||||
await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, {
|
||||
bookingId,
|
||||
bookingNumber,
|
||||
documentPassword,
|
||||
origin: dto.origin,
|
||||
destination: dto.destination,
|
||||
volumeCBM: dto.volumeCBM,
|
||||
@ -176,7 +128,6 @@ export class CsvBookingService {
|
||||
fileName: doc.fileName,
|
||||
})),
|
||||
confirmationToken,
|
||||
notes: dto.notes,
|
||||
});
|
||||
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
|
||||
} catch (error: any) {
|
||||
@ -250,130 +201,6 @@ export class CsvBookingService {
|
||||
return this.toResponseDto(booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify password and get booking documents for carrier (public endpoint)
|
||||
* Only accessible for ACCEPTED bookings with correct password
|
||||
*/
|
||||
async getDocumentsForCarrier(
|
||||
token: string,
|
||||
password?: string
|
||||
): Promise<CarrierDocumentsResponseDto> {
|
||||
this.logger.log(`Getting documents for carrier with token: ${token}`);
|
||||
|
||||
// Get ORM entity to access passwordHash
|
||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||
where: { confirmationToken: token },
|
||||
});
|
||||
|
||||
if (!ormBooking) {
|
||||
throw new NotFoundException('Réservation introuvable');
|
||||
}
|
||||
|
||||
// Only allow access for ACCEPTED bookings
|
||||
if (ormBooking.status !== 'ACCEPTED') {
|
||||
throw new BadRequestException("Cette réservation n'a pas encore été acceptée");
|
||||
}
|
||||
|
||||
// Check if password protection is enabled for this booking
|
||||
if (ormBooking.passwordHash) {
|
||||
if (!password) {
|
||||
throw new UnauthorizedException('Mot de passe requis pour accéder aux documents');
|
||||
}
|
||||
|
||||
const isPasswordValid = await argon2.verify(ormBooking.passwordHash, password);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Mot de passe incorrect');
|
||||
}
|
||||
}
|
||||
|
||||
// Get domain booking for business logic
|
||||
const booking = await this.csvBookingRepository.findByToken(token);
|
||||
if (!booking) {
|
||||
throw new NotFoundException('Réservation introuvable');
|
||||
}
|
||||
|
||||
// Generate signed URLs for all documents
|
||||
const documentsWithUrls = await Promise.all(
|
||||
booking.documents.map(async doc => {
|
||||
const signedUrl = await this.generateSignedUrlForDocument(doc.filePath);
|
||||
return {
|
||||
id: doc.id,
|
||||
type: doc.type,
|
||||
fileName: doc.fileName,
|
||||
mimeType: doc.mimeType,
|
||||
size: doc.size,
|
||||
downloadUrl: signedUrl,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
|
||||
|
||||
return {
|
||||
booking: {
|
||||
id: booking.id,
|
||||
bookingNumber: ormBooking.bookingNumber || undefined,
|
||||
carrierName: booking.carrierName,
|
||||
origin: booking.origin.getValue(),
|
||||
destination: booking.destination.getValue(),
|
||||
routeDescription: booking.getRouteDescription(),
|
||||
volumeCBM: booking.volumeCBM,
|
||||
weightKG: booking.weightKG,
|
||||
palletCount: booking.palletCount,
|
||||
price: booking.getPriceInCurrency(primaryCurrency),
|
||||
currency: primaryCurrency,
|
||||
transitDays: booking.transitDays,
|
||||
containerType: booking.containerType,
|
||||
acceptedAt: booking.respondedAt!,
|
||||
},
|
||||
documents: documentsWithUrls,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a booking requires password for document access
|
||||
*/
|
||||
async checkDocumentAccessRequirements(
|
||||
token: string
|
||||
): Promise<{ requiresPassword: boolean; bookingNumber?: string; status: string }> {
|
||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||
where: { confirmationToken: token },
|
||||
});
|
||||
|
||||
if (!ormBooking) {
|
||||
throw new NotFoundException('Réservation introuvable');
|
||||
}
|
||||
|
||||
return {
|
||||
requiresPassword: !!ormBooking.passwordHash,
|
||||
bookingNumber: ormBooking.bookingNumber || undefined,
|
||||
status: ormBooking.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate signed URL for a document file path
|
||||
*/
|
||||
private async generateSignedUrlForDocument(filePath: string): Promise<string> {
|
||||
const bucket = 'xpeditis-documents';
|
||||
|
||||
// Extract key from the file path
|
||||
let key = filePath;
|
||||
if (filePath.includes('xpeditis-documents/')) {
|
||||
key = filePath.split('xpeditis-documents/')[1];
|
||||
} else if (filePath.startsWith('http')) {
|
||||
const url = new URL(filePath);
|
||||
key = url.pathname.replace(/^\//, '');
|
||||
if (key.startsWith('xpeditis-documents/')) {
|
||||
key = key.replace('xpeditis-documents/', '');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate signed URL with 1 hour expiration
|
||||
const signedUrl = await this.storageAdapter.getSignedUrl({ bucket, key }, 3600);
|
||||
return signedUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a booking request
|
||||
*/
|
||||
@ -386,11 +213,6 @@ export class CsvBookingService {
|
||||
throw new NotFoundException('Booking not found');
|
||||
}
|
||||
|
||||
// Get ORM entity for bookingNumber
|
||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||
where: { confirmationToken: token },
|
||||
});
|
||||
|
||||
// Accept the booking (domain logic validates status)
|
||||
booking.accept();
|
||||
|
||||
@ -398,31 +220,6 @@ export class CsvBookingService {
|
||||
const updatedBooking = await this.csvBookingRepository.update(booking);
|
||||
this.logger.log(`Booking ${booking.id} accepted`);
|
||||
|
||||
// Extract password from booking number for the email
|
||||
const bookingNumber = ormBooking?.bookingNumber;
|
||||
const documentPassword = bookingNumber
|
||||
? this.extractPasswordFromBookingNumber(bookingNumber)
|
||||
: undefined;
|
||||
|
||||
// Send document access email to carrier
|
||||
try {
|
||||
await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, {
|
||||
carrierName: booking.carrierName,
|
||||
bookingId: booking.id,
|
||||
bookingNumber: bookingNumber || undefined,
|
||||
documentPassword: documentPassword,
|
||||
origin: booking.origin.getValue(),
|
||||
destination: booking.destination.getValue(),
|
||||
volumeCBM: booking.volumeCBM,
|
||||
weightKG: booking.weightKG,
|
||||
documentCount: booking.documents.length,
|
||||
confirmationToken: booking.confirmationToken,
|
||||
});
|
||||
this.logger.log(`Document access email sent to carrier: ${booking.carrierEmail}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to send document access email: ${error?.message}`, error?.stack);
|
||||
}
|
||||
|
||||
// Create notification for user
|
||||
try {
|
||||
const notification = Notification.create({
|
||||
@ -678,9 +475,9 @@ export class CsvBookingService {
|
||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||
}
|
||||
|
||||
// Allow adding documents to PENDING or ACCEPTED bookings
|
||||
if (booking.status !== CsvBookingStatus.PENDING && booking.status !== CsvBookingStatus.ACCEPTED) {
|
||||
throw new BadRequestException('Cannot add documents to a booking that is rejected or cancelled');
|
||||
// Verify booking is still pending
|
||||
if (booking.status !== CsvBookingStatus.PENDING) {
|
||||
throw new BadRequestException('Cannot add documents to a booking that is not pending');
|
||||
}
|
||||
|
||||
// Upload new documents
|
||||
@ -709,24 +506,6 @@ export class CsvBookingService {
|
||||
|
||||
this.logger.log(`Added ${newDocuments.length} documents to booking ${bookingId}`);
|
||||
|
||||
// If booking is ACCEPTED, notify carrier about new documents
|
||||
if (booking.status === CsvBookingStatus.ACCEPTED) {
|
||||
try {
|
||||
await this.emailAdapter.sendNewDocumentsNotification(booking.carrierEmail, {
|
||||
carrierName: booking.carrierName,
|
||||
bookingId: booking.id,
|
||||
origin: booking.origin.getValue(),
|
||||
destination: booking.destination.getValue(),
|
||||
newDocumentsCount: newDocuments.length,
|
||||
totalDocumentsCount: updatedDocuments.length,
|
||||
confirmationToken: booking.confirmationToken,
|
||||
});
|
||||
this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to send new documents notification: ${error?.message}`, error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Documents added successfully',
|
||||
@ -922,7 +701,6 @@ export class CsvBookingService {
|
||||
|
||||
return {
|
||||
id: booking.id,
|
||||
bookingNumber: booking.bookingNumber,
|
||||
userId: booking.userId,
|
||||
organizationId: booking.organizationId,
|
||||
carrierName: booking.carrierName,
|
||||
|
||||
@ -1,35 +1,37 @@
|
||||
/**
|
||||
* GDPR Compliance Service
|
||||
* GDPR Compliance Service - Simplified Version
|
||||
*
|
||||
* Handles data export, deletion, and consent management
|
||||
* with full database persistence
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
||||
import { UpdateConsentDto, ConsentResponseDto } from '../dto/consent.dto';
|
||||
|
||||
export interface GDPRDataExport {
|
||||
exportDate: string;
|
||||
userId: string;
|
||||
userData: any;
|
||||
cookieConsent: any;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ConsentData {
|
||||
userId: string;
|
||||
marketing: boolean;
|
||||
analytics: boolean;
|
||||
functional: boolean;
|
||||
consentDate: Date;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GDPRService {
|
||||
private readonly logger = new Logger(GDPRService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(UserOrmEntity)
|
||||
private readonly userRepository: Repository<UserOrmEntity>,
|
||||
@InjectRepository(CookieConsentOrmEntity)
|
||||
private readonly consentRepository: Repository<CookieConsentOrmEntity>
|
||||
private readonly userRepository: Repository<UserOrmEntity>
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -44,9 +46,6 @@ export class GDPRService {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Fetch consent data
|
||||
const consent = await this.consentRepository.findOne({ where: { userId } });
|
||||
|
||||
// Sanitize user data (remove password hash)
|
||||
const sanitizedUser = {
|
||||
id: user.id,
|
||||
@ -64,15 +63,6 @@ export class GDPRService {
|
||||
exportDate: new Date().toISOString(),
|
||||
userId,
|
||||
userData: sanitizedUser,
|
||||
cookieConsent: consent
|
||||
? {
|
||||
essential: consent.essential,
|
||||
functional: consent.functional,
|
||||
analytics: consent.analytics,
|
||||
marketing: consent.marketing,
|
||||
consentDate: consent.consentDate,
|
||||
}
|
||||
: null,
|
||||
message:
|
||||
'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
|
||||
};
|
||||
@ -98,9 +88,6 @@ export class GDPRService {
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete consent data first (will cascade with user deletion)
|
||||
await this.consentRepository.delete({ userId });
|
||||
|
||||
// IMPORTANT: In production, implement full data anonymization
|
||||
// For now, we just mark the account for deletion
|
||||
// Real implementation should:
|
||||
@ -118,139 +105,55 @@ export class GDPRService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record or update consent (GDPR Article 7 - Conditions for consent)
|
||||
* Record consent (GDPR Article 7 - Conditions for consent)
|
||||
*/
|
||||
async recordConsent(
|
||||
userId: string,
|
||||
consentData: UpdateConsentDto
|
||||
): Promise<ConsentResponseDto> {
|
||||
this.logger.log(`Recording consent for user ${userId}`);
|
||||
async recordConsent(consentData: ConsentData): Promise<void> {
|
||||
this.logger.log(`Recording consent for user ${consentData.userId}`);
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: consentData.userId },
|
||||
});
|
||||
|
||||
// Verify user exists
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Check if consent already exists
|
||||
let consent = await this.consentRepository.findOne({ where: { userId } });
|
||||
|
||||
if (consent) {
|
||||
// Update existing consent
|
||||
consent.essential = true; // Always true
|
||||
consent.functional = consentData.functional;
|
||||
consent.analytics = consentData.analytics;
|
||||
consent.marketing = consentData.marketing;
|
||||
consent.ipAddress = consentData.ipAddress || consent.ipAddress;
|
||||
consent.userAgent = consentData.userAgent || consent.userAgent;
|
||||
consent.consentDate = new Date();
|
||||
|
||||
await this.consentRepository.save(consent);
|
||||
this.logger.log(`Consent updated for user ${userId}`);
|
||||
} else {
|
||||
// Create new consent record
|
||||
consent = this.consentRepository.create({
|
||||
id: uuidv4(),
|
||||
userId,
|
||||
essential: true, // Always true
|
||||
functional: consentData.functional,
|
||||
analytics: consentData.analytics,
|
||||
marketing: consentData.marketing,
|
||||
ipAddress: consentData.ipAddress,
|
||||
userAgent: consentData.userAgent,
|
||||
consentDate: new Date(),
|
||||
});
|
||||
|
||||
await this.consentRepository.save(consent);
|
||||
this.logger.log(`New consent created for user ${userId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
essential: consent.essential,
|
||||
functional: consent.functional,
|
||||
analytics: consent.analytics,
|
||||
marketing: consent.marketing,
|
||||
consentDate: consent.consentDate,
|
||||
updatedAt: consent.updatedAt,
|
||||
};
|
||||
// In production, store in separate consent table
|
||||
// For now, just log the consent
|
||||
this.logger.log(
|
||||
`Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw specific consent (GDPR Article 7.3 - Withdrawal of consent)
|
||||
* Withdraw consent (GDPR Article 7.3 - Withdrawal of consent)
|
||||
*/
|
||||
async withdrawConsent(
|
||||
userId: string,
|
||||
consentType: 'functional' | 'analytics' | 'marketing'
|
||||
): Promise<ConsentResponseDto> {
|
||||
async withdrawConsent(userId: string, consentType: 'marketing' | 'analytics'): Promise<void> {
|
||||
this.logger.log(`Withdrawing ${consentType} consent for user ${userId}`);
|
||||
|
||||
// Verify user exists
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Find consent record
|
||||
let consent = await this.consentRepository.findOne({ where: { userId } });
|
||||
|
||||
if (!consent) {
|
||||
// Create default consent with withdrawn type
|
||||
consent = this.consentRepository.create({
|
||||
id: uuidv4(),
|
||||
userId,
|
||||
essential: true,
|
||||
functional: consentType === 'functional' ? false : false,
|
||||
analytics: consentType === 'analytics' ? false : false,
|
||||
marketing: consentType === 'marketing' ? false : false,
|
||||
consentDate: new Date(),
|
||||
});
|
||||
} else {
|
||||
// Update specific consent type
|
||||
consent[consentType] = false;
|
||||
consent.consentDate = new Date();
|
||||
}
|
||||
|
||||
await this.consentRepository.save(consent);
|
||||
this.logger.log(`${consentType} consent withdrawn for user ${userId}`);
|
||||
|
||||
return {
|
||||
userId,
|
||||
essential: consent.essential,
|
||||
functional: consent.functional,
|
||||
analytics: consent.analytics,
|
||||
marketing: consent.marketing,
|
||||
consentDate: consent.consentDate,
|
||||
updatedAt: consent.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current consent status
|
||||
*/
|
||||
async getConsentStatus(userId: string): Promise<ConsentResponseDto | null> {
|
||||
// Verify user exists
|
||||
async getConsentStatus(userId: string): Promise<any> {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Find consent record
|
||||
const consent = await this.consentRepository.findOne({ where: { userId } });
|
||||
|
||||
if (!consent) {
|
||||
// No consent recorded yet - return null to indicate user should provide consent
|
||||
return null;
|
||||
}
|
||||
|
||||
// Default consent status
|
||||
return {
|
||||
userId,
|
||||
essential: consent.essential,
|
||||
functional: consent.functional,
|
||||
analytics: consent.analytics,
|
||||
marketing: consent.marketing,
|
||||
consentDate: consent.consentDate,
|
||||
updatedAt: consent.updatedAt,
|
||||
marketing: false,
|
||||
analytics: false,
|
||||
functional: true,
|
||||
message: 'Consent management fully implemented in production version',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,8 +79,7 @@ export class CsvBooking {
|
||||
public readonly requestedAt: Date,
|
||||
public respondedAt?: Date,
|
||||
public notes?: string,
|
||||
public rejectionReason?: string,
|
||||
public readonly bookingNumber?: string
|
||||
public rejectionReason?: string
|
||||
) {
|
||||
this.validate();
|
||||
}
|
||||
@ -362,8 +361,7 @@ export class CsvBooking {
|
||||
requestedAt: Date,
|
||||
respondedAt?: Date,
|
||||
notes?: string,
|
||||
rejectionReason?: string,
|
||||
bookingNumber?: string
|
||||
rejectionReason?: string
|
||||
): CsvBooking {
|
||||
// Create instance without calling constructor validation
|
||||
const booking = Object.create(CsvBooking.prototype);
|
||||
@ -391,7 +389,6 @@ export class CsvBooking {
|
||||
booking.respondedAt = respondedAt;
|
||||
booking.notes = notes;
|
||||
booking.rejectionReason = rejectionReason;
|
||||
booking.bookingNumber = bookingNumber;
|
||||
|
||||
return booking;
|
||||
}
|
||||
|
||||
@ -85,8 +85,6 @@ export interface EmailPort {
|
||||
carrierEmail: string,
|
||||
bookingDetails: {
|
||||
bookingId: string;
|
||||
bookingNumber?: string;
|
||||
documentPassword?: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
volumeCBM: number;
|
||||
@ -102,7 +100,6 @@ export interface EmailPort {
|
||||
fileName: string;
|
||||
}>;
|
||||
confirmationToken: string;
|
||||
notes?: string;
|
||||
}
|
||||
): Promise<void>;
|
||||
|
||||
@ -123,39 +120,4 @@ export interface EmailPort {
|
||||
carrierName: string,
|
||||
temporaryPassword: string
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Send document access email to carrier after booking acceptance
|
||||
*/
|
||||
sendDocumentAccessEmail(
|
||||
carrierEmail: string,
|
||||
data: {
|
||||
carrierName: string;
|
||||
bookingId: string;
|
||||
bookingNumber?: string;
|
||||
documentPassword?: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
volumeCBM: number;
|
||||
weightKG: number;
|
||||
documentCount: number;
|
||||
confirmationToken: string;
|
||||
}
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Send notification to carrier when new documents are added
|
||||
*/
|
||||
sendNewDocumentsNotification(
|
||||
carrierEmail: string,
|
||||
data: {
|
||||
carrierName: string;
|
||||
bookingId: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
newDocumentsCount: number;
|
||||
totalDocumentsCount: number;
|
||||
confirmationToken: string;
|
||||
}
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@ -239,60 +239,6 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
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[]> {
|
||||
const allRates = await this.loadAllRates();
|
||||
const origins = new Set(allRates.map(rate => rate.origin.getValue()));
|
||||
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[]> {
|
||||
const allRates = await this.loadAllRates();
|
||||
const originCode = PortCode.create(origin);
|
||||
|
||||
const destinations = new Set(
|
||||
allRates
|
||||
.filter(rate => rate.origin.equals(originCode))
|
||||
.map(rate => rate.destination.getValue())
|
||||
);
|
||||
|
||||
return Array.from(destinations).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available routes (origin-destination pairs) from CSV rates
|
||||
* Returns a map of origin codes to their available destination codes
|
||||
*/
|
||||
async getAvailableRoutes(): Promise<Map<string, string[]>> {
|
||||
const allRates = await this.loadAllRates();
|
||||
const routeMap = new Map<string, Set<string>>();
|
||||
|
||||
allRates.forEach(rate => {
|
||||
const origin = rate.origin.getValue();
|
||||
const destination = rate.destination.getValue();
|
||||
|
||||
if (!routeMap.has(origin)) {
|
||||
routeMap.set(origin, new Set());
|
||||
}
|
||||
routeMap.get(origin)!.add(destination);
|
||||
});
|
||||
|
||||
// Convert Sets to sorted arrays
|
||||
const result = new Map<string, string[]>();
|
||||
routeMap.forEach((destinations, origin) => {
|
||||
result.set(origin, Array.from(destinations).sort());
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all rates from all CSV files
|
||||
*/
|
||||
|
||||
@ -239,8 +239,6 @@ export class EmailAdapter implements EmailPort {
|
||||
carrierEmail: string,
|
||||
bookingData: {
|
||||
bookingId: string;
|
||||
bookingNumber?: string;
|
||||
documentPassword?: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
volumeCBM: number;
|
||||
@ -256,7 +254,6 @@ export class EmailAdapter implements EmailPort {
|
||||
fileName: string;
|
||||
}>;
|
||||
confirmationToken: string;
|
||||
notes?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
// Use APP_URL (frontend) for accept/reject links
|
||||
@ -273,7 +270,7 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
await this.send({
|
||||
to: carrierEmail,
|
||||
subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin} → ${bookingData.destination}`,
|
||||
subject: `Nouvelle demande de réservation - ${bookingData.origin} → ${bookingData.destination}`,
|
||||
html,
|
||||
});
|
||||
|
||||
@ -430,194 +427,4 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
this.logger.log(`Carrier password reset email sent to ${email}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send document access email to carrier after booking acceptance
|
||||
*/
|
||||
async sendDocumentAccessEmail(
|
||||
carrierEmail: string,
|
||||
data: {
|
||||
carrierName: string;
|
||||
bookingId: string;
|
||||
bookingNumber?: string;
|
||||
documentPassword?: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
volumeCBM: number;
|
||||
weightKG: number;
|
||||
documentCount: number;
|
||||
confirmationToken: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000');
|
||||
const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`;
|
||||
|
||||
// Password section HTML - only show if password is set
|
||||
const passwordSection = data.documentPassword
|
||||
? `
|
||||
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 20px; margin: 20px 0;">
|
||||
<h3 style="margin: 0 0 10px 0; color: #92400e; font-size: 16px;">🔐 Mot de passe d'accès aux documents</h3>
|
||||
<p style="margin: 0; color: #78350f;">Pour accéder aux documents, vous aurez besoin du mot de passe suivant :</p>
|
||||
<div style="background: white; border-radius: 6px; padding: 15px; margin-top: 15px; text-align: center;">
|
||||
<code style="font-size: 24px; font-weight: bold; color: #1e293b; letter-spacing: 2px;">${data.documentPassword}</code>
|
||||
</div>
|
||||
<p style="margin: 15px 0 0 0; color: #78350f; font-size: 13px;">⚠️ Conservez ce mot de passe, il vous sera demandé à chaque accès.</p>
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f5f5f5; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
||||
.header { background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%); color: white; padding: 30px 20px; text-align: center; }
|
||||
.header h1 { margin: 0; font-size: 24px; }
|
||||
.content { padding: 30px; }
|
||||
.route { font-size: 20px; font-weight: 600; color: #1e293b; text-align: center; margin: 20px 0; }
|
||||
.route-arrow { color: #0284c7; margin: 0 10px; }
|
||||
.summary { background: #f8fafc; border-radius: 8px; padding: 20px; margin: 20px 0; }
|
||||
.summary-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #e2e8f0; }
|
||||
.summary-row:last-child { border-bottom: none; }
|
||||
.documents-badge { display: inline-block; background: #dbeafe; color: #1d4ed8; padding: 8px 16px; border-radius: 20px; font-size: 14px; font-weight: 500; margin: 20px 0; }
|
||||
.cta-button { display: block; text-align: center; background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%); color: white !important; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 25px 0; }
|
||||
.footer { background: #f8fafc; text-align: center; padding: 20px; color: #64748b; font-size: 12px; border-top: 1px solid #e2e8f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Documents disponibles</h1>
|
||||
<p style="margin: 10px 0 0 0; opacity: 0.9;">Votre reservation a ete acceptee</p>
|
||||
${data.bookingNumber ? `<p style="margin: 5px 0 0 0; opacity: 0.9; font-size: 14px;">N° ${data.bookingNumber}</p>` : ''}
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Bonjour <strong>${data.carrierName}</strong>,</p>
|
||||
<p>Merci d'avoir accepte la demande de reservation. Les documents associes sont maintenant disponibles au telechargement.</p>
|
||||
|
||||
<div class="route">
|
||||
${data.origin} <span class="route-arrow">→</span> ${data.destination}
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-row">
|
||||
<span style="color: #64748b;">Volume</span>
|
||||
<span style="font-weight: 500;">${data.volumeCBM} CBM</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span style="color: #64748b;">Poids</span>
|
||||
<span style="font-weight: 500;">${data.weightKG} kg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<span class="documents-badge">${data.documentCount} document${data.documentCount > 1 ? 's' : ''} disponible${data.documentCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
${passwordSection}
|
||||
|
||||
<a href="${documentsUrl}" class="cta-button">Acceder aux documents</a>
|
||||
|
||||
<p style="color: #64748b; font-size: 14px; text-align: center;">Ce lien est permanent. Vous pouvez y acceder a tout moment.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Reference: ${data.bookingNumber || data.bookingId.substring(0, 8).toUpperCase()}</p>
|
||||
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
await this.send({
|
||||
to: carrierEmail,
|
||||
subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin} → ${data.destination}`,
|
||||
html,
|
||||
});
|
||||
|
||||
this.logger.log(`Document access email sent to ${carrierEmail} for booking ${data.bookingId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification to carrier when new documents are added
|
||||
*/
|
||||
async sendNewDocumentsNotification(
|
||||
carrierEmail: string,
|
||||
data: {
|
||||
carrierName: string;
|
||||
bookingId: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
newDocumentsCount: number;
|
||||
totalDocumentsCount: number;
|
||||
confirmationToken: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000');
|
||||
const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`;
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f5f5f5; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
||||
.header { background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); color: white; padding: 30px 20px; text-align: center; }
|
||||
.header h1 { margin: 0; font-size: 24px; }
|
||||
.content { padding: 30px; }
|
||||
.route { font-size: 20px; font-weight: 600; color: #1e293b; text-align: center; margin: 20px 0; }
|
||||
.route-arrow { color: #f59e0b; margin: 0 10px; }
|
||||
.highlight { background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 15px; margin: 20px 0; text-align: center; }
|
||||
.cta-button { display: block; text-align: center; background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); color: white !important; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 25px 0; }
|
||||
.footer { background: #f8fafc; text-align: center; padding: 20px; color: #64748b; font-size: 12px; border-top: 1px solid #e2e8f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Nouveaux documents ajoutes</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Bonjour <strong>${data.carrierName}</strong>,</p>
|
||||
<p>De nouveaux documents ont ete ajoutes a votre reservation.</p>
|
||||
|
||||
<div class="route">
|
||||
${data.origin} <span class="route-arrow">→</span> ${data.destination}
|
||||
</div>
|
||||
|
||||
<div class="highlight">
|
||||
<p style="margin: 0; font-size: 18px; font-weight: 600; color: #92400e;">
|
||||
+${data.newDocumentsCount} nouveau${data.newDocumentsCount > 1 ? 'x' : ''} document${data.newDocumentsCount > 1 ? 's' : ''}
|
||||
</p>
|
||||
<p style="margin: 5px 0 0 0; color: #a16207;">
|
||||
Total: ${data.totalDocumentsCount} document${data.totalDocumentsCount > 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="${documentsUrl}" class="cta-button">Voir les documents</a>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Reference: ${data.bookingId.substring(0, 8).toUpperCase()}</p>
|
||||
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
await this.send({
|
||||
to: carrierEmail,
|
||||
subject: `Nouveaux documents - Reservation ${data.origin} → ${data.destination}`,
|
||||
html,
|
||||
});
|
||||
|
||||
this.logger.log(`New documents notification sent to ${carrierEmail} for booking ${data.bookingId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -261,8 +261,6 @@ export class EmailTemplates {
|
||||
*/
|
||||
async renderCsvBookingRequest(data: {
|
||||
bookingId: string;
|
||||
bookingNumber?: string;
|
||||
documentPassword?: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
volumeCBM: number;
|
||||
@ -277,7 +275,6 @@ export class EmailTemplates {
|
||||
type: string;
|
||||
fileName: string;
|
||||
}>;
|
||||
notes?: string;
|
||||
acceptUrl: string;
|
||||
rejectUrl: string;
|
||||
}): Promise<string> {
|
||||
@ -484,21 +481,6 @@ export class EmailTemplates {
|
||||
Vous avez reçu une nouvelle demande de réservation via Xpeditis. Veuillez examiner les détails ci-dessous et confirmer ou refuser cette demande.
|
||||
</p>
|
||||
|
||||
{{#if bookingNumber}}
|
||||
<!-- Booking Reference Box -->
|
||||
<div style="background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); border: 2px solid #0284c7; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #0369a1;">Numéro de devis</p>
|
||||
<p style="margin: 0; font-size: 28px; font-weight: bold; color: #0c4a6e; letter-spacing: 2px;">{{bookingNumber}}</p>
|
||||
{{#if documentPassword}}
|
||||
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #bae6fd;">
|
||||
<p style="margin: 0 0 5px 0; font-size: 12px; color: #0369a1;">🔐 Mot de passe pour accéder aux documents</p>
|
||||
<p style="margin: 0; font-size: 20px; font-weight: bold; color: #0c4a6e; background: white; display: inline-block; padding: 8px 16px; border-radius: 4px; letter-spacing: 3px;">{{documentPassword}}</p>
|
||||
<p style="margin: 10px 0 0 0; font-size: 11px; color: #64748b;">Conservez ce mot de passe, il vous sera demandé pour télécharger les documents</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<!-- Booking Details -->
|
||||
<div class="section-title">📋 Détails du transport</div>
|
||||
<table class="details-table">
|
||||
@ -558,14 +540,6 @@ export class EmailTemplates {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{{#if notes}}
|
||||
<!-- Notes -->
|
||||
<div style="background-color: #f0f9ff; border-left: 4px solid #0284c7; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0 0 5px 0; font-weight: 700; color: #045a8d; font-size: 14px;">📝 Notes du client</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #333; line-height: 1.6;">{{notes}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<p>Veuillez confirmer votre décision :</p>
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Cookie Consent ORM Entity (Infrastructure Layer)
|
||||
*
|
||||
* TypeORM entity for cookie consent persistence
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { UserOrmEntity } from './user.orm-entity';
|
||||
|
||||
@Entity('cookie_consents')
|
||||
@Index('idx_cookie_consents_user', ['userId'])
|
||||
export class CookieConsentOrmEntity {
|
||||
@PrimaryColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid', unique: true })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: UserOrmEntity;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
essential: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
functional: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
analytics: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
marketing: boolean;
|
||||
|
||||
@Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true })
|
||||
ipAddress: string | null;
|
||||
|
||||
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
||||
userAgent: string | null;
|
||||
|
||||
@Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' })
|
||||
consentDate: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@ -96,13 +96,6 @@ export class CsvBookingOrmEntity {
|
||||
@Index()
|
||||
confirmationToken: string;
|
||||
|
||||
@Column({ name: 'booking_number', type: 'varchar', length: 20, nullable: true })
|
||||
@Index()
|
||||
bookingNumber: string | null;
|
||||
|
||||
@Column({ name: 'password_hash', type: 'text', nullable: true })
|
||||
passwordHash: string | null;
|
||||
|
||||
@Column({ name: 'requested_at', type: 'timestamp with time zone' })
|
||||
@Index()
|
||||
requestedAt: Date;
|
||||
|
||||
@ -41,8 +41,7 @@ export class CsvBookingMapper {
|
||||
ormEntity.requestedAt,
|
||||
ormEntity.respondedAt,
|
||||
ormEntity.notes,
|
||||
ormEntity.rejectionReason,
|
||||
ormEntity.bookingNumber ?? undefined
|
||||
ormEntity.rejectionReason
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Migration: Create Cookie Consents Table
|
||||
* GDPR compliant cookie preference storage
|
||||
*/
|
||||
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateCookieConsent1738100000000 implements MigrationInterface {
|
||||
name = 'CreateCookieConsent1738100000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create cookie_consents table
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "cookie_consents" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"user_id" UUID NOT NULL,
|
||||
"essential" BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
"functional" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"analytics" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"marketing" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"ip_address" VARCHAR(45) NULL,
|
||||
"user_agent" TEXT NULL,
|
||||
"consent_date" TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
"updated_at" TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT "pk_cookie_consents" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "uq_cookie_consents_user" UNIQUE ("user_id"),
|
||||
CONSTRAINT "fk_cookie_consents_user" FOREIGN KEY ("user_id")
|
||||
REFERENCES "users"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Create index for fast user lookups
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_cookie_consents_user" ON "cookie_consents" ("user_id")
|
||||
`);
|
||||
|
||||
// Add comments
|
||||
await queryRunner.query(`
|
||||
COMMENT ON TABLE "cookie_consents" IS 'GDPR compliant cookie consent preferences per user'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
COMMENT ON COLUMN "cookie_consents"."essential" IS 'Essential cookies - always true, required for functionality'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
COMMENT ON COLUMN "cookie_consents"."functional" IS 'Functional cookies - preferences, language, etc.'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
COMMENT ON COLUMN "cookie_consents"."analytics" IS 'Analytics cookies - Google Analytics, Sentry, etc.'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
COMMENT ON COLUMN "cookie_consents"."marketing" IS 'Marketing cookies - ads, tracking, remarketing'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
COMMENT ON COLUMN "cookie_consents"."ip_address" IS 'IP address at time of consent for GDPR audit trail'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "cookie_consents"`);
|
||||
}
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Migration: Add Password Protection to CSV Bookings
|
||||
*
|
||||
* Adds password protection for carrier document access
|
||||
* Including: booking_number (readable ID) and password_hash
|
||||
*/
|
||||
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPasswordToCsvBookings1738200000000 implements MigrationInterface {
|
||||
name = 'AddPasswordToCsvBookings1738200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Add password-related columns to csv_bookings
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "csv_bookings"
|
||||
ADD COLUMN "booking_number" VARCHAR(20) NULL,
|
||||
ADD COLUMN "password_hash" TEXT NULL
|
||||
`);
|
||||
|
||||
// Create unique index for booking_number
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX "idx_csv_bookings_booking_number"
|
||||
ON "csv_bookings" ("booking_number")
|
||||
WHERE "booking_number" IS NOT NULL
|
||||
`);
|
||||
|
||||
// Add comments
|
||||
await queryRunner.query(`
|
||||
COMMENT ON COLUMN "csv_bookings"."booking_number" IS 'Human-readable booking number (format: XPD-YYYY-XXXXXX)'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
COMMENT ON COLUMN "csv_bookings"."password_hash" IS 'Argon2 hashed password for carrier document access'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Remove index first
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "idx_csv_bookings_booking_number"`);
|
||||
|
||||
// Remove columns
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "csv_bookings"
|
||||
DROP COLUMN IF EXISTS "booking_number",
|
||||
DROP COLUMN IF EXISTS "password_hash"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -1,568 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
Loader2,
|
||||
XCircle,
|
||||
Package,
|
||||
Ship,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
type: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
interface BookingSummary {
|
||||
id: string;
|
||||
bookingNumber?: string;
|
||||
carrierName: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
routeDescription: string;
|
||||
volumeCBM: number;
|
||||
weightKG: number;
|
||||
palletCount: number;
|
||||
price: number;
|
||||
currency: string;
|
||||
transitDays: number;
|
||||
containerType: string;
|
||||
acceptedAt: string;
|
||||
}
|
||||
|
||||
interface CarrierDocumentsData {
|
||||
booking: BookingSummary;
|
||||
documents: Document[];
|
||||
}
|
||||
|
||||
interface AccessRequirements {
|
||||
requiresPassword: boolean;
|
||||
bookingNumber?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const documentTypeLabels: Record<string, string> = {
|
||||
BILL_OF_LADING: 'Connaissement',
|
||||
PACKING_LIST: 'Liste de colisage',
|
||||
COMMERCIAL_INVOICE: 'Facture commerciale',
|
||||
CERTIFICATE_OF_ORIGIN: "Certificat d'origine",
|
||||
OTHER: 'Autre document',
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const getFileIcon = (mimeType: string) => {
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('image')) return '🖼️';
|
||||
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return '📊';
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) return '📝';
|
||||
return '📎';
|
||||
};
|
||||
|
||||
export default function CarrierDocumentsPage() {
|
||||
const params = useParams();
|
||||
const token = params.token as string;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<CarrierDocumentsData | null>(null);
|
||||
const [downloading, setDownloading] = useState<string | null>(null);
|
||||
|
||||
// Password protection state
|
||||
const [requirements, setRequirements] = useState<AccessRequirements | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
|
||||
const hasCalledApi = useRef(false);
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
// Check access requirements first
|
||||
const checkRequirements = async () => {
|
||||
if (!token) {
|
||||
setError('Lien invalide');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${apiUrl}/api/v1/csv-booking-actions/documents/${token}/requirements`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
errorData = { message: `Erreur HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const errorMessage = errorData.message || 'Erreur lors du chargement';
|
||||
|
||||
if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
|
||||
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const reqData: AccessRequirements = await response.json();
|
||||
setRequirements(reqData);
|
||||
|
||||
// If booking is not accepted yet
|
||||
if (reqData.status !== 'ACCEPTED') {
|
||||
setError(
|
||||
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no password required, fetch documents directly
|
||||
if (!reqData.requiresPassword) {
|
||||
await fetchDocumentsWithoutPassword();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking requirements:', err);
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch documents without password (legacy bookings)
|
||||
const fetchDocumentsWithoutPassword = async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
errorData = { message: `Erreur HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const errorMessage = errorData.message || 'Erreur lors du chargement des documents';
|
||||
|
||||
if (
|
||||
errorMessage.includes('pas encore été acceptée') ||
|
||||
errorMessage.includes('not accepted')
|
||||
) {
|
||||
throw new Error(
|
||||
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
|
||||
);
|
||||
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
|
||||
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
|
||||
} else if (errorMessage.includes('Mot de passe requis') || errorMessage.includes('required')) {
|
||||
// Password is now required, show the form
|
||||
setRequirements({ requiresPassword: true, status: 'ACCEPTED' });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
setData(responseData);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Error fetching documents:', err);
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch documents with password
|
||||
const fetchDocumentsWithPassword = async (pwd: string) => {
|
||||
setVerifying(true);
|
||||
setPasswordError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ password: pwd }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
errorData = { message: `Erreur HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const errorMessage = errorData.message || 'Erreur lors de la vérification';
|
||||
|
||||
if (
|
||||
response.status === 401 ||
|
||||
errorMessage.includes('incorrect') ||
|
||||
errorMessage.includes('invalid')
|
||||
) {
|
||||
setPasswordError('Mot de passe incorrect. Vérifiez votre email pour retrouver le mot de passe.');
|
||||
setVerifying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
setData(responseData);
|
||||
setVerifying(false);
|
||||
} catch (err) {
|
||||
console.error('Error verifying password:', err);
|
||||
setPasswordError(err instanceof Error ? err.message : 'Erreur lors de la vérification');
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCalledApi.current) return;
|
||||
hasCalledApi.current = true;
|
||||
checkRequirements();
|
||||
}, [token]);
|
||||
|
||||
const handlePasswordSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password.trim()) {
|
||||
setPasswordError('Veuillez entrer le mot de passe');
|
||||
return;
|
||||
}
|
||||
fetchDocumentsWithPassword(password.trim());
|
||||
};
|
||||
|
||||
const handleDownload = async (doc: Document) => {
|
||||
setDownloading(doc.id);
|
||||
|
||||
try {
|
||||
// The downloadUrl is already a signed URL, open it directly
|
||||
window.open(doc.downloadUrl, '_blank');
|
||||
} catch (err) {
|
||||
console.error('Error downloading document:', err);
|
||||
alert('Erreur lors du téléchargement. Veuillez réessayer.');
|
||||
} finally {
|
||||
// Small delay to show loading state
|
||||
setTimeout(() => setDownloading(null), 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setData(null);
|
||||
setRequirements(null);
|
||||
setPassword('');
|
||||
setPasswordError(null);
|
||||
hasCalledApi.current = false;
|
||||
checkRequirements();
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-brand-gray">
|
||||
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
|
||||
<Loader2 className="w-16 h-16 text-brand-turquoise mx-auto mb-4 animate-spin" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Chargement...</h1>
|
||||
<p className="text-gray-600">Veuillez patienter</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-brand-gray">
|
||||
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
|
||||
<p className="text-gray-600 mb-6">{error}</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 font-medium transition-colors"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Password form state
|
||||
if (requirements?.requiresPassword && !data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-brand-gray p-4">
|
||||
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full">
|
||||
<div className="text-center mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mb-4">
|
||||
<Lock className="w-8 h-8 text-brand-turquoise" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Accès sécurisé</h1>
|
||||
<p className="text-gray-600">
|
||||
Cette page est protégée. Entrez le mot de passe reçu par email pour accéder aux
|
||||
documents.
|
||||
</p>
|
||||
{requirements.bookingNumber && (
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Réservation: <span className="font-mono font-bold">{requirements.bookingNumber}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Mot de passe
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value.toUpperCase())}
|
||||
placeholder="Ex: A3B7K9"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-brand-turquoise text-center text-xl tracking-widest font-mono uppercase"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{passwordError && (
|
||||
<p className="mt-2 text-sm text-red-600 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{passwordError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={verifying}
|
||||
className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{verifying ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Vérification...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-5 h-5" />
|
||||
Accéder aux documents
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Où trouver le mot de passe ?</strong>
|
||||
<br />
|
||||
Le mot de passe vous a été envoyé dans l'email de confirmation de la réservation. Il
|
||||
correspond aux 6 derniers caractères du numéro de devis.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const { booking, documents } = data;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-brand-gray">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Image
|
||||
src="/assets/logos/logo-black.svg"
|
||||
alt="Xpeditis"
|
||||
width={40}
|
||||
height={48}
|
||||
className="h-auto"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="text-sm text-brand-turquoise hover:text-brand-navy font-medium"
|
||||
>
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* Booking Summary Card */}
|
||||
<div className="bg-white rounded-xl shadow-md overflow-hidden mb-6">
|
||||
<div className="bg-gradient-to-r from-brand-navy to-brand-navy/80 px-6 py-4">
|
||||
<div className="flex items-center justify-center gap-4 text-white">
|
||||
<span className="text-2xl font-bold">{booking.origin}</span>
|
||||
<ArrowRight className="w-6 h-6" />
|
||||
<span className="text-2xl font-bold">{booking.destination}</span>
|
||||
</div>
|
||||
{booking.bookingNumber && (
|
||||
<p className="text-center text-white/70 text-sm mt-1">
|
||||
N° {booking.bookingNumber}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
||||
<p className="text-xs text-gray-500">Volume</p>
|
||||
<p className="font-semibold text-gray-900">{booking.volumeCBM} CBM</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
||||
<p className="text-xs text-gray-500">Poids</p>
|
||||
<p className="font-semibold text-gray-900">{booking.weightKG} kg</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
||||
<p className="text-xs text-gray-500">Transit</p>
|
||||
<p className="font-semibold text-gray-900">{booking.transitDays} jours</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
||||
<p className="text-xs text-gray-500">Type</p>
|
||||
<p className="font-semibold text-gray-900">{booking.containerType}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">
|
||||
Transporteur:{' '}
|
||||
<span className="text-gray-900 font-medium">{booking.carrierName}</span>
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
Ref:{' '}
|
||||
<span className="font-mono text-gray-900">
|
||||
{booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documents Section */}
|
||||
<div className="bg-white rounded-xl shadow-md overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-brand-turquoise" />
|
||||
Documents ({documents.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{documents.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600">Aucun document disponible pour le moment.</p>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Les documents apparaîtront ici une fois ajoutés.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{documents.map(doc => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-2xl">{getFileIcon(doc.mimeType)}</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{doc.fileName}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs px-2 py-0.5 bg-brand-turquoise/10 text-brand-navy rounded-full">
|
||||
{documentTypeLabels[doc.type] || doc.type}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleDownload(doc)}
|
||||
disabled={downloading === doc.id}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{downloading === doc.id ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Télécharger</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<p className="mt-6 text-center text-sm text-gray-500">
|
||||
Cette page affiche toujours les documents les plus récents de la réservation.
|
||||
</p>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-auto py-6 text-center text-sm text-brand-navy/50">
|
||||
<p>© {new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,8 +2,6 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getAllBookings, getAllUsers } from '@/lib/api/admin';
|
||||
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
@ -228,31 +226,30 @@ export default function AdminDocumentsPage() {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, filterUserId, filterQuoteNumber]);
|
||||
|
||||
const getDocumentIcon = (type: string): ReactNode => {
|
||||
const getDocumentIcon = (type: string) => {
|
||||
const typeLower = type.toLowerCase();
|
||||
const cls = "h-6 w-6";
|
||||
const iconMap: Record<string, ReactNode> = {
|
||||
'application/pdf': <FileText className={`${cls} text-red-500`} />,
|
||||
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
|
||||
'image/png': <ImageIcon className={`${cls} text-green-500`} />,
|
||||
'image/jpg': <ImageIcon className={`${cls} text-green-500`} />,
|
||||
pdf: <FileText className={`${cls} text-red-500`} />,
|
||||
jpeg: <ImageIcon className={`${cls} text-green-500`} />,
|
||||
jpg: <ImageIcon className={`${cls} text-green-500`} />,
|
||||
png: <ImageIcon className={`${cls} text-green-500`} />,
|
||||
gif: <ImageIcon className={`${cls} text-green-500`} />,
|
||||
image: <ImageIcon className={`${cls} text-green-500`} />,
|
||||
word: <FileEdit className={`${cls} text-blue-500`} />,
|
||||
doc: <FileEdit className={`${cls} text-blue-500`} />,
|
||||
docx: <FileEdit className={`${cls} text-blue-500`} />,
|
||||
excel: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
||||
xls: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
||||
xlsx: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
||||
csv: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
||||
text: <FileText className={`${cls} text-gray-500`} />,
|
||||
txt: <FileText className={`${cls} text-gray-500`} />,
|
||||
const icons: Record<string, string> = {
|
||||
'application/pdf': '📄',
|
||||
'image/jpeg': '🖼️',
|
||||
'image/png': '🖼️',
|
||||
'image/jpg': '🖼️',
|
||||
pdf: '📄',
|
||||
jpeg: '🖼️',
|
||||
jpg: '🖼️',
|
||||
png: '🖼️',
|
||||
gif: '🖼️',
|
||||
image: '🖼️',
|
||||
word: '📝',
|
||||
doc: '📝',
|
||||
docx: '📝',
|
||||
excel: '📊',
|
||||
xls: '📊',
|
||||
xlsx: '📊',
|
||||
csv: '📊',
|
||||
text: '📄',
|
||||
txt: '📄',
|
||||
};
|
||||
return iconMap[typeLower] || <Paperclip className={`${cls} text-gray-400`} />;
|
||||
return icons[typeLower] || '📎';
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
@ -448,7 +445,7 @@ export default function AdminDocumentsPage() {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
|
||||
<span className="text-2xl mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
|
||||
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ClipboardList, Mail, AlertTriangle } from 'lucide-react';
|
||||
import type { CsvRateSearchResult } from '@/types/rates';
|
||||
import { createCsvBooking } from '@/lib/api/bookings';
|
||||
|
||||
@ -50,7 +49,6 @@ function NewBookingPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<BookingForm>({
|
||||
@ -189,7 +187,7 @@ function NewBookingPageContent() {
|
||||
|
||||
const canProceedToStep2 = formData.carrierName && formData.origin && formData.destination;
|
||||
const canProceedToStep3 = formData.documents.length >= 1;
|
||||
const canSubmit = canProceedToStep3 && termsAccepted;
|
||||
const canSubmit = canProceedToStep3;
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
@ -219,10 +217,10 @@ function NewBookingPageContent() {
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-8 bg-white rounded-lg shadow-md p-6">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
{[1, 2, 3].map(step => (
|
||||
<div key={step} className={`flex items-center ${step < 3 ? 'flex-1' : ''}`}>
|
||||
<div key={step} className="flex items-center flex-1">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
|
||||
@ -261,7 +259,7 @@ function NewBookingPageContent() {
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-2 border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<AlertTriangle className="h-6 w-6 mr-3 text-red-500 flex-shrink-0" />
|
||||
<span className="text-2xl mr-3">⚠️</span>
|
||||
<div>
|
||||
<h4 className="font-semibold text-red-900 mb-1">Erreur</h4>
|
||||
<p className="text-red-700 whitespace-pre-line">{error}</p>
|
||||
@ -386,7 +384,7 @@ function NewBookingPageContent() {
|
||||
|
||||
<div className="mb-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<ClipboardList className="h-6 w-6 mr-3 text-yellow-700 flex-shrink-0" />
|
||||
<span className="text-2xl mr-3">📋</span>
|
||||
<div>
|
||||
<h4 className="font-semibold text-yellow-900 mb-1">Information importante</h4>
|
||||
<p className="text-sm text-yellow-800">
|
||||
@ -574,7 +572,7 @@ function NewBookingPageContent() {
|
||||
{/* What happens next */}
|
||||
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
<Mail className="h-5 w-5 mr-2 inline-block" /> Que se passe-t-il ensuite ?
|
||||
📧 Que se passe-t-il ensuite ?
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-700">
|
||||
<li className="flex items-start">
|
||||
@ -609,9 +607,8 @@ function NewBookingPageContent() {
|
||||
<label className="flex items-start cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={termsAccepted}
|
||||
onChange={(e) => setTermsAccepted(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
required
|
||||
/>
|
||||
<span className="ml-3 text-sm text-gray-700">
|
||||
Je confirme que les informations fournies sont exactes et que j'accepte les{' '}
|
||||
|
||||
@ -10,8 +10,6 @@ import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { listBookings, listCsvBookings } from '@/lib/api';
|
||||
import Link from 'next/link';
|
||||
import { Plus } from 'lucide-react';
|
||||
import ExportButton from '@/components/ExportButton';
|
||||
|
||||
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
||||
|
||||
@ -148,38 +146,14 @@ export default function BookingsListPage() {
|
||||
<h1 className="text-2xl font-bold text-gray-900">Réservations</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Gérez et suivez vos envois</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<ExportButton
|
||||
data={filteredBookings}
|
||||
filename="reservations"
|
||||
columns={[
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'palletCount', label: 'Palettes', format: (v) => `${v || 0}` },
|
||||
{ key: 'weightKG', label: 'Poids (kg)', format: (v) => `${v || 0}` },
|
||||
{ key: 'volumeCBM', label: 'Volume (CBM)', format: (v) => `${v || 0}` },
|
||||
{ key: 'origin', label: 'Origine' },
|
||||
{ key: 'destination', label: 'Destination' },
|
||||
{ key: 'carrierName', label: 'Transporteur' },
|
||||
{ key: 'status', label: 'Statut', format: (v) => {
|
||||
const labels: Record<string, string> = {
|
||||
PENDING: 'En attente',
|
||||
ACCEPTED: 'Accepté',
|
||||
REJECTED: 'Refusé',
|
||||
};
|
||||
return labels[v] || v;
|
||||
}},
|
||||
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
href="/dashboard/search-advanced"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<span className="mr-2">➕</span>
|
||||
Nouvelle Réservation
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
@ -288,11 +262,8 @@ export default function BookingsListPage() {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
N° Devis
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
N° Booking
|
||||
N° Devis
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -355,14 +326,11 @@ export default function BookingsListPage() {
|
||||
})
|
||||
: 'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-left text-sm font-medium">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{booking.type === 'csv'
|
||||
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
|
||||
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy">
|
||||
{booking.bookingNumber || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -482,7 +450,7 @@ export default function BookingsListPage() {
|
||||
href="/dashboard/search-advanced"
|
||||
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" />
|
||||
<span className="mr-2">➕</span>
|
||||
Nouvelle Réservation
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -2,9 +2,6 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
||||
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import ExportButton from '@/components/ExportButton';
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
@ -167,31 +164,30 @@ export default function UserDocumentsPage() {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, filterStatus, filterQuoteNumber]);
|
||||
|
||||
const getDocumentIcon = (type: string): ReactNode => {
|
||||
const getDocumentIcon = (type: string) => {
|
||||
const typeLower = type.toLowerCase();
|
||||
const cls = "h-6 w-6";
|
||||
const iconMap: Record<string, ReactNode> = {
|
||||
'application/pdf': <FileText className={`${cls} text-red-500`} />,
|
||||
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
|
||||
'image/png': <ImageIcon className={`${cls} text-green-500`} />,
|
||||
'image/jpg': <ImageIcon className={`${cls} text-green-500`} />,
|
||||
pdf: <FileText className={`${cls} text-red-500`} />,
|
||||
jpeg: <ImageIcon className={`${cls} text-green-500`} />,
|
||||
jpg: <ImageIcon className={`${cls} text-green-500`} />,
|
||||
png: <ImageIcon className={`${cls} text-green-500`} />,
|
||||
gif: <ImageIcon className={`${cls} text-green-500`} />,
|
||||
image: <ImageIcon className={`${cls} text-green-500`} />,
|
||||
word: <FileEdit className={`${cls} text-blue-500`} />,
|
||||
doc: <FileEdit className={`${cls} text-blue-500`} />,
|
||||
docx: <FileEdit className={`${cls} text-blue-500`} />,
|
||||
excel: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
||||
xls: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
||||
xlsx: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
||||
csv: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
||||
text: <FileText className={`${cls} text-gray-500`} />,
|
||||
txt: <FileText className={`${cls} text-gray-500`} />,
|
||||
const icons: Record<string, string> = {
|
||||
'application/pdf': '📄',
|
||||
'image/jpeg': '🖼️',
|
||||
'image/png': '🖼️',
|
||||
'image/jpg': '🖼️',
|
||||
pdf: '📄',
|
||||
jpeg: '🖼️',
|
||||
jpg: '🖼️',
|
||||
png: '🖼️',
|
||||
gif: '🖼️',
|
||||
image: '🖼️',
|
||||
word: '📝',
|
||||
doc: '📝',
|
||||
docx: '📝',
|
||||
excel: '📊',
|
||||
xls: '📊',
|
||||
xlsx: '📊',
|
||||
csv: '📊',
|
||||
text: '📄',
|
||||
txt: '📄',
|
||||
};
|
||||
return iconMap[typeLower] || <Paperclip className={`${cls} text-gray-400`} />;
|
||||
return icons[typeLower] || '📎';
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
@ -259,10 +255,8 @@ export default function UserDocumentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Get bookings available for adding documents (PENDING or ACCEPTED)
|
||||
const bookingsAvailableForDocuments = bookings.filter(
|
||||
b => b.status === 'PENDING' || b.status === 'ACCEPTED'
|
||||
);
|
||||
// Get unique bookings for add document modal
|
||||
const bookingsWithPendingStatus = bookings.filter(b => b.status === 'PENDING');
|
||||
|
||||
const handleAddDocumentClick = () => {
|
||||
setShowAddModal(true);
|
||||
@ -413,31 +407,9 @@ export default function UserDocumentsPage() {
|
||||
Gérez tous les documents de vos réservations
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<ExportButton
|
||||
data={filteredDocuments}
|
||||
filename="documents"
|
||||
columns={[
|
||||
{ key: 'fileName', label: 'Nom du fichier' },
|
||||
{ key: 'fileType', label: 'Type' },
|
||||
{ key: 'quoteNumber', label: 'N° de Devis' },
|
||||
{ key: 'route', label: 'Route' },
|
||||
{ key: 'carrierName', label: 'Transporteur' },
|
||||
{ key: 'status', label: 'Statut', format: (v) => {
|
||||
const labels: Record<string, string> = {
|
||||
PENDING: 'En attente',
|
||||
ACCEPTED: 'Accepté',
|
||||
REJECTED: 'Refusé',
|
||||
CANCELLED: 'Annulé',
|
||||
};
|
||||
return labels[v] || v;
|
||||
}},
|
||||
{ key: 'uploadedAt', label: 'Date d\'ajout', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddDocumentClick}
|
||||
disabled={bookingsAvailableForDocuments.length === 0}
|
||||
disabled={bookingsWithPendingStatus.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"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -451,7 +423,6 @@ export default function UserDocumentsPage() {
|
||||
Ajouter un document
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
@ -562,7 +533,7 @@ export default function UserDocumentsPage() {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">
|
||||
<span className="text-2xl mr-2">
|
||||
{getDocumentIcon(doc.fileType || doc.type)}
|
||||
</span>
|
||||
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
|
||||
@ -815,7 +786,7 @@ export default function UserDocumentsPage() {
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sélectionner une réservation
|
||||
Sélectionner une réservation (en attente)
|
||||
</label>
|
||||
<select
|
||||
value={selectedBookingId || ''}
|
||||
@ -823,9 +794,9 @@ export default function UserDocumentsPage() {
|
||||
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>
|
||||
{bookingsAvailableForDocuments.map(booking => (
|
||||
{bookingsWithPendingStatus.map(booking => (
|
||||
<option key={booking.id} value={booking.id}>
|
||||
{getQuoteNumber(booking)} - {booking.origin} → {booking.destination} ({booking.status === 'PENDING' ? 'En attente' : 'Accepté'})
|
||||
{getQuoteNumber(booking)} - {booking.origin} → {booking.destination}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@ -13,32 +13,25 @@ import { useState } from 'react';
|
||||
import NotificationDropdown from '@/components/NotificationDropdown';
|
||||
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
BarChart3,
|
||||
Package,
|
||||
FileText,
|
||||
Search,
|
||||
BookOpen,
|
||||
Building2,
|
||||
Users,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, logout } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
// { name: 'Search Rates', href: '/dashboard/search-advanced', icon: '🔎' },
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Tableau de bord', href: '/dashboard', icon: BarChart3 },
|
||||
{ name: 'Réservations', href: '/dashboard/bookings', icon: Package },
|
||||
{ name: 'Documents', href: '/dashboard/documents', icon: FileText },
|
||||
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search },
|
||||
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen },
|
||||
{ name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 },
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: '📊' },
|
||||
{ name: 'Bookings', href: '/dashboard/bookings', icon: '📦' },
|
||||
{ name: 'Documents', href: '/dashboard/documents', icon: '📄' },
|
||||
{ name: 'Track & Trace', href: '/dashboard/track-trace', icon: '🔍' },
|
||||
{ name: 'Wiki', href: '/dashboard/wiki', icon: '📚' },
|
||||
{ name: 'My Profile', href: '/dashboard/profile', icon: '👤' },
|
||||
{ name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' },
|
||||
// ADMIN and MANAGER only navigation items
|
||||
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
|
||||
{ name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users },
|
||||
{ name: 'Users', href: '/dashboard/settings/users', icon: '👥' },
|
||||
] : []),
|
||||
];
|
||||
|
||||
@ -105,7 +98,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="mr-3 h-5 w-5" />
|
||||
<span className="mr-3 text-xl">{item.icon}</span>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
@ -136,8 +129,15 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
onClick={logout}
|
||||
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Déconnexion
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -162,17 +162,17 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
</button>
|
||||
<div className="flex-1 lg:flex-none">
|
||||
<h1 className="text-xl font-semibold text-gray-900">
|
||||
{navigation.find(item => isActive(item.href))?.name || 'Tableau de bord'}
|
||||
{navigation.find(item => isActive(item.href))?.name || 'Dashboard'}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Notifications */}
|
||||
<NotificationDropdown />
|
||||
|
||||
{/* User Initials */}
|
||||
<Link href="/dashboard/profile" className="w-9 h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors">
|
||||
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
||||
</Link>
|
||||
{/* User Role Badge */}
|
||||
<span className="px-3 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full">
|
||||
{user?.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -16,8 +16,7 @@ import {
|
||||
deleteNotification,
|
||||
} from '@/lib/api';
|
||||
import type { NotificationResponse } from '@/types/api';
|
||||
import { Trash2, CheckCheck, Filter, Bell, ChevronLeft, ChevronRight, Package, RefreshCw, XCircle, CheckCircle, Mail, Clock, FileText, Megaphone, User, Building2 } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Trash2, CheckCheck, Filter, Bell, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
|
||||
@ -78,7 +77,7 @@ export default function NotificationsPage() {
|
||||
|
||||
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')) {
|
||||
if (confirm('Are you sure you want to delete this notification?')) {
|
||||
deleteNotificationMutation.mutate(notificationId);
|
||||
}
|
||||
};
|
||||
@ -93,23 +92,22 @@ export default function NotificationsPage() {
|
||||
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-100';
|
||||
};
|
||||
|
||||
const getNotificationIcon = (type: string): ReactNode => {
|
||||
const iconClass = "h-8 w-8";
|
||||
const icons: Record<string, ReactNode> = {
|
||||
booking_created: <Package className={`${iconClass} text-blue-600`} />,
|
||||
booking_updated: <RefreshCw className={`${iconClass} text-orange-500`} />,
|
||||
booking_cancelled: <XCircle className={`${iconClass} text-red-500`} />,
|
||||
booking_confirmed: <CheckCircle className={`${iconClass} text-green-500`} />,
|
||||
csv_booking_accepted: <CheckCircle className={`${iconClass} text-green-500`} />,
|
||||
csv_booking_rejected: <XCircle className={`${iconClass} text-red-500`} />,
|
||||
csv_booking_request_sent: <Mail className={`${iconClass} text-blue-500`} />,
|
||||
rate_quote_expiring: <Clock className={`${iconClass} text-yellow-500`} />,
|
||||
document_uploaded: <FileText className={`${iconClass} text-gray-600`} />,
|
||||
system_announcement: <Megaphone className={`${iconClass} text-purple-500`} />,
|
||||
user_invited: <User className={`${iconClass} text-teal-500`} />,
|
||||
organization_update: <Building2 className={`${iconClass} text-indigo-500`} />,
|
||||
const getNotificationIcon = (type: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
booking_created: '📦',
|
||||
booking_updated: '🔄',
|
||||
booking_cancelled: '❌',
|
||||
booking_confirmed: '✅',
|
||||
csv_booking_accepted: '✅',
|
||||
csv_booking_rejected: '❌',
|
||||
csv_booking_request_sent: '📧',
|
||||
rate_quote_expiring: '⏰',
|
||||
document_uploaded: '📄',
|
||||
system_announcement: '📢',
|
||||
user_invited: '👤',
|
||||
organization_update: '🏢',
|
||||
};
|
||||
return icons[type.toLowerCase()] || <Bell className={`${iconClass} text-gray-500`} />;
|
||||
return icons[type.toLowerCase()] || '🔔';
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
@ -120,11 +118,11 @@ export default function NotificationsPage() {
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'A l\'instant';
|
||||
if (diffMins < 60) return `Il y a ${diffMins}min`;
|
||||
if (diffHours < 24) return `Il y a ${diffHours}h`;
|
||||
if (diffDays < 7) return `Il y a ${diffDays}j`;
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
@ -146,8 +144,8 @@ export default function NotificationsPage() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Notifications</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{total} notification{total !== 1 ? 's' : ''} au total
|
||||
{unreadCount > 0 && ` • ${unreadCount} non lue${unreadCount > 1 ? 's' : ''}`}
|
||||
{total} notification{total !== 1 ? 's' : ''} total
|
||||
{unreadCount > 0 && ` • ${unreadCount} unread`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -158,7 +156,7 @@ export default function NotificationsPage() {
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<CheckCheck className="w-5 h-5" />
|
||||
<span>Tout marquer comme lu</span>
|
||||
<span>Mark all as read</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -171,7 +169,7 @@ export default function NotificationsPage() {
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Filter className="w-5 h-5 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">Filtrer :</span>
|
||||
<span className="text-sm font-medium text-gray-700">Filter:</span>
|
||||
<div className="flex space-x-2">
|
||||
{(['all', 'unread', 'read'] as const).map((filter) => (
|
||||
<button
|
||||
@ -186,7 +184,7 @@ export default function NotificationsPage() {
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{filter === 'all' ? 'Toutes' : filter === 'unread' ? 'Non lues' : 'Lues'}
|
||||
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||
{filter === 'unread' && unreadCount > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-full text-xs">
|
||||
{unreadCount}
|
||||
@ -204,18 +202,18 @@ export default function NotificationsPage() {
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4" />
|
||||
<p className="text-gray-500">Chargement des notifications...</p>
|
||||
<p className="text-gray-500">Loading notifications...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 flex justify-center"><Bell className="h-16 w-16 text-gray-300" /></div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Aucune notification</h3>
|
||||
<div className="text-7xl mb-4">🔔</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">No notifications</h3>
|
||||
<p className="text-gray-500">
|
||||
{selectedFilter === 'unread'
|
||||
? 'Vous êtes à jour !'
|
||||
: 'Aucune notification à afficher'}
|
||||
? "You're all caught up! Great job!"
|
||||
: 'No notifications to display'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -231,7 +229,7 @@ export default function NotificationsPage() {
|
||||
>
|
||||
<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 text-4xl">
|
||||
{getNotificationIcon(notification.type)}
|
||||
</div>
|
||||
|
||||
@ -245,14 +243,14 @@ export default function NotificationsPage() {
|
||||
{!notification.read && (
|
||||
<span className="flex items-center space-x-1 px-2 py-1 bg-blue-600 text-white text-xs font-medium rounded-full">
|
||||
<span className="w-2 h-2 bg-white rounded-full animate-pulse" />
|
||||
<span>NOUVEAU</span>
|
||||
<span>NEW</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, notification.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg"
|
||||
title="Supprimer la notification"
|
||||
title="Delete notification"
|
||||
>
|
||||
<Trash2 className="w-5 h-5 text-red-600" />
|
||||
</button>
|
||||
@ -302,7 +300,7 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
{notification.actionUrl && (
|
||||
<span className="text-sm text-blue-600 font-medium group-hover:underline flex items-center space-x-1">
|
||||
<span>Voir les détails</span>
|
||||
<span>View details</span>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
@ -332,10 +330,11 @@ export default function NotificationsPage() {
|
||||
<div className="mt-6 bg-white rounded-lg shadow-sm border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Page <span className="font-semibold">{currentPage}</span> sur{' '}
|
||||
Showing page <span className="font-semibold">{currentPage}</span> of{' '}
|
||||
<span className="font-semibold">{totalPages}</span>
|
||||
{' • '}
|
||||
<span className="font-semibold">{total}</span> notification{total !== 1 ? 's' : ''} au total
|
||||
<span className="font-semibold">{total}</span> total notification
|
||||
{total !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
@ -344,7 +343,7 @@ export default function NotificationsPage() {
|
||||
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span>Précédent</span>
|
||||
<span>Previous</span>
|
||||
</button>
|
||||
<div className="flex items-center space-x-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
@ -379,7 +378,7 @@ export default function NotificationsPage() {
|
||||
disabled={currentPage === totalPages}
|
||||
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span>Suivant</span>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -21,7 +21,6 @@ import {
|
||||
Plus,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import ExportButton from '@/components/ExportButton';
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
@ -75,33 +74,17 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-gray-900">Tableau de Bord</h1>
|
||||
<p className="text-gray-600 mt-1 text-sm">
|
||||
Vue d'ensemble de vos réservations et performances
|
||||
Vue d'ensemble de vos bookings et performances
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<ExportButton
|
||||
data={topCarriers || []}
|
||||
filename="tableau-de-bord-transporteurs"
|
||||
columns={[
|
||||
{ key: 'carrierName', label: 'Transporteur' },
|
||||
{ key: 'totalBookings', label: 'Total Réservations' },
|
||||
{ key: 'acceptedBookings', label: 'Acceptées' },
|
||||
{ key: 'rejectedBookings', label: 'Refusées' },
|
||||
{ key: 'totalWeightKG', label: 'Poids Total (KG)', format: (v) => v?.toLocaleString('fr-FR') || '0' },
|
||||
{ key: 'totalVolumeCBM', label: 'Volume Total (CBM)', format: (v) => v?.toFixed(2) || '0' },
|
||||
{ key: 'acceptanceRate', label: 'Taux d\'acceptation (%)', format: (v) => v?.toFixed(1) || '0' },
|
||||
{ key: 'avgPriceUSD', label: 'Prix moyen ($)', format: (v) => v?.toFixed(2) || '0' },
|
||||
]}
|
||||
/>
|
||||
<Link href="/dashboard/search-advanced">
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white gap-2 shadow-lg text-base px-6 py-5 font-semibold">
|
||||
<Plus className="h-5 w-5" />
|
||||
Nouvelle Réservation
|
||||
<Link href="/dashboard/bookings">
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white gap-2 shadow-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
Nouveau Booking
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards - Compact with Color */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
@ -208,7 +191,7 @@ export default function DashboardPage() {
|
||||
<Card className="border border-gray-200 shadow-sm bg-white">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<CardTitle className="text-base font-semibold text-gray-900">
|
||||
Distribution des Réservations
|
||||
Distribution des Bookings
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-gray-600">
|
||||
Répartition par statut
|
||||
@ -250,7 +233,7 @@ export default function DashboardPage() {
|
||||
Poids par Transporteur
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-gray-600">
|
||||
Top 5 transporteurs par poids (KG)
|
||||
Top 5 carriers par poids (KG)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
@ -299,7 +282,7 @@ export default function DashboardPage() {
|
||||
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
|
||||
<Package className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<p className="text-xs font-medium text-gray-600 mb-1">Total Réservations</p>
|
||||
<p className="text-xs font-medium text-gray-600 mb-1">Total Bookings</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{csvKpisLoading
|
||||
? '--'
|
||||
@ -377,7 +360,7 @@ export default function DashboardPage() {
|
||||
{carrier.carrierName}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
|
||||
<span>{carrier.totalBookings} réservations</span>
|
||||
<span>{carrier.totalBookings} bookings</span>
|
||||
<span>•</span>
|
||||
<span>{carrier.totalWeightKG.toLocaleString()} KG</span>
|
||||
</div>
|
||||
@ -417,15 +400,15 @@ export default function DashboardPage() {
|
||||
<Package className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">
|
||||
Aucune réservation
|
||||
Aucun booking
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-4 max-w-sm mx-auto">
|
||||
Créez votre première réservation pour voir vos statistiques
|
||||
Créez votre premier booking pour voir vos statistiques
|
||||
</p>
|
||||
<Link href="/dashboard/bookings">
|
||||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
|
||||
<Plus className="mr-1.5 h-3 w-3" />
|
||||
Créer une réservation
|
||||
Créer un booking
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -17,18 +17,18 @@ 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'),
|
||||
currentPassword: z.string().min(1, 'Current password is required'),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(12, 'Le mot de passe doit contenir au moins 12 caractères')
|
||||
.min(12, 'Password must be at least 12 characters')
|
||||
.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'
|
||||
'Password must contain uppercase, lowercase, number, and special character'
|
||||
),
|
||||
confirmPassword: z.string().min(1, 'Veuillez confirmer votre mot de passe'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine(data => data.newPassword === data.confirmPassword, {
|
||||
message: 'Les mots de passe ne correspondent pas',
|
||||
message: "Passwords don't match",
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
@ -36,9 +36,9 @@ 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'),
|
||||
firstName: z.string().min(2, 'First name must be at least 2 characters'),
|
||||
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
});
|
||||
|
||||
type ProfileFormData = z.infer<typeof profileSchema>;
|
||||
@ -101,14 +101,14 @@ export default function ProfilePage() {
|
||||
return updateUser(user.id, data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSuccessMessage('Profil mis à jour avec succès !');
|
||||
setSuccessMessage('Profile updated successfully!');
|
||||
setErrorMessage('');
|
||||
refreshUser();
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setErrorMessage(error.message || 'Échec de la mise à jour du profil');
|
||||
setErrorMessage(error.message || 'Failed to update profile');
|
||||
setSuccessMessage('');
|
||||
},
|
||||
});
|
||||
@ -122,7 +122,7 @@ export default function ProfilePage() {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSuccessMessage('Mot de passe mis à jour avec succès !');
|
||||
setSuccessMessage('Password updated successfully!');
|
||||
setErrorMessage('');
|
||||
passwordForm.reset({
|
||||
currentPassword: '',
|
||||
@ -132,7 +132,7 @@ export default function ProfilePage() {
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setErrorMessage(error.message || 'Échec de la mise à jour du mot de passe');
|
||||
setErrorMessage(error.message || 'Failed to update password');
|
||||
setSuccessMessage('');
|
||||
},
|
||||
});
|
||||
@ -151,7 +151,7 @@ export default function ProfilePage() {
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Chargement du profil...</p>
|
||||
<p className="text-gray-600">Loading profile...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -162,12 +162,12 @@ export default function ProfilePage() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 mb-4">Impossible de charger le profil</p>
|
||||
<p className="text-red-600 mb-4">Unable to load user profile</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Réessayer
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -178,8 +178,8 @@ export default function ProfilePage() {
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg shadow-lg p-6 text-white">
|
||||
<h1 className="text-3xl font-bold mb-2">Mon Profil</h1>
|
||||
<p className="text-blue-100">Gérez vos paramètres de compte et préférences</p>
|
||||
<h1 className="text-3xl font-bold mb-2">My Profile</h1>
|
||||
<p className="text-blue-100">Manage your account settings and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
@ -230,7 +230,7 @@ export default function ProfilePage() {
|
||||
{user?.role}
|
||||
</span>
|
||||
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
|
||||
Actif
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -249,7 +249,7 @@ export default function ProfilePage() {
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Informations personnelles
|
||||
Profile Information
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('password')}
|
||||
@ -259,7 +259,7 @@ export default function ProfilePage() {
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Modifier le mot de passe
|
||||
Change Password
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
@ -274,7 +274,7 @@ export default function ProfilePage() {
|
||||
htmlFor="firstName"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Prénom
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
{...profileForm.register('firstName')}
|
||||
@ -295,7 +295,7 @@ export default function ProfilePage() {
|
||||
htmlFor="lastName"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Nom
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
{...profileForm.register('lastName')}
|
||||
@ -314,7 +314,7 @@ export default function ProfilePage() {
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Adresse email
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
{...profileForm.register('email')}
|
||||
@ -323,7 +323,7 @@ export default function ProfilePage() {
|
||||
disabled
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">L'adresse email ne peut pas être modifiée</p>
|
||||
<p className="mt-1 text-xs text-gray-500">Email cannot be changed</p>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
@ -333,7 +333,7 @@ export default function ProfilePage() {
|
||||
disabled={updateProfileMutation.isPending}
|
||||
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{updateProfileMutation.isPending ? 'Enregistrement...' : 'Enregistrer'}
|
||||
{updateProfileMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -345,7 +345,7 @@ export default function ProfilePage() {
|
||||
htmlFor="currentPassword"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Mot de passe actuel
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
{...passwordForm.register('currentPassword')}
|
||||
@ -367,7 +367,7 @@ export default function ProfilePage() {
|
||||
htmlFor="newPassword"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Nouveau mot de passe
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
{...passwordForm.register('newPassword')}
|
||||
@ -382,7 +382,8 @@ export default function ProfilePage() {
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Au moins 12 caractères avec majuscule, minuscule, chiffre et caractère spécial
|
||||
Must be at least 12 characters with uppercase, lowercase, number, and special
|
||||
character
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -392,7 +393,7 @@ export default function ProfilePage() {
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Confirmer le nouveau mot de passe
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
{...passwordForm.register('confirmPassword')}
|
||||
@ -415,7 +416,7 @@ export default function ProfilePage() {
|
||||
disabled={updatePasswordMutation.isPending}
|
||||
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{updatePasswordMutation.isPending ? 'Mise à jour...' : 'Mettre à jour'}
|
||||
{updatePasswordMutation.isPending ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -2,20 +2,14 @@
|
||||
* Advanced Rate Search Page
|
||||
*
|
||||
* Complete search form with all filters and best options display
|
||||
* Uses only ports available in CSV rates for origin/destination selection
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, Loader2 } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getAvailableOrigins,
|
||||
getAvailableDestinations,
|
||||
RoutePortInfo,
|
||||
} from '@/lib/api/rates';
|
||||
import { searchPorts, Port } from '@/lib/api/ports';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// Import dynamique pour éviter les erreurs SSR avec Leaflet
|
||||
@ -98,60 +92,24 @@ export default function AdvancedSearchPage() {
|
||||
const [destinationSearch, setDestinationSearch] = useState('');
|
||||
const [showOriginDropdown, setShowOriginDropdown] = useState(false);
|
||||
const [showDestinationDropdown, setShowDestinationDropdown] = useState(false);
|
||||
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
|
||||
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(null);
|
||||
const [selectedOriginPort, setSelectedOriginPort] = useState<Port | null>(null);
|
||||
const [selectedDestinationPort, setSelectedDestinationPort] = useState<Port | null>(null);
|
||||
|
||||
// Fetch available origins from CSV rates
|
||||
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
|
||||
queryKey: ['available-origins'],
|
||||
queryFn: getAvailableOrigins,
|
||||
// Port autocomplete queries
|
||||
const { data: originPortsData } = useQuery({
|
||||
queryKey: ['ports', originSearch],
|
||||
queryFn: () => searchPorts({ query: originSearch, limit: 10 }),
|
||||
enabled: originSearch.length >= 2 && showOriginDropdown,
|
||||
});
|
||||
|
||||
// Fetch available destinations based on selected origin
|
||||
const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({
|
||||
queryKey: ['available-destinations', searchForm.origin],
|
||||
queryFn: () => getAvailableDestinations(searchForm.origin),
|
||||
enabled: !!searchForm.origin,
|
||||
const { data: destinationPortsData } = useQuery({
|
||||
queryKey: ['ports', destinationSearch],
|
||||
queryFn: () => searchPorts({ query: destinationSearch, limit: 10 }),
|
||||
enabled: destinationSearch.length >= 2 && showDestinationDropdown,
|
||||
});
|
||||
|
||||
// Filter origins based on search input
|
||||
const filteredOrigins = (originsData?.origins || []).filter(port => {
|
||||
if (!originSearch || originSearch.length < 1) return true;
|
||||
const searchLower = originSearch.toLowerCase();
|
||||
return (
|
||||
port.code.toLowerCase().includes(searchLower) ||
|
||||
port.name.toLowerCase().includes(searchLower) ||
|
||||
port.city.toLowerCase().includes(searchLower) ||
|
||||
port.countryName.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
// Filter destinations based on search input
|
||||
const filteredDestinations = (destinationsData?.destinations || []).filter(port => {
|
||||
if (!destinationSearch || destinationSearch.length < 1) return true;
|
||||
const searchLower = destinationSearch.toLowerCase();
|
||||
return (
|
||||
port.code.toLowerCase().includes(searchLower) ||
|
||||
port.name.toLowerCase().includes(searchLower) ||
|
||||
port.city.toLowerCase().includes(searchLower) ||
|
||||
port.countryName.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
// Reset destination when origin changes
|
||||
useEffect(() => {
|
||||
if (searchForm.origin && selectedDestinationPort) {
|
||||
// Check if current destination is still valid for new origin
|
||||
const isValidDestination = destinationsData?.destinations?.some(
|
||||
d => d.code === searchForm.destination
|
||||
);
|
||||
if (!isValidDestination) {
|
||||
setSearchForm(prev => ({ ...prev, destination: '' }));
|
||||
setSelectedDestinationPort(null);
|
||||
setDestinationSearch('');
|
||||
}
|
||||
}
|
||||
}, [searchForm.origin, destinationsData]);
|
||||
const originPorts = originPortsData?.ports || [];
|
||||
const destinationPorts = destinationPortsData?.ports || [];
|
||||
|
||||
// Calculate total volume and weight
|
||||
const calculateTotals = () => {
|
||||
@ -230,51 +188,37 @@ export default function AdvancedSearchPage() {
|
||||
<h2 className="text-xl font-semibold text-gray-900">1. Informations Générales</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Origin Port with Autocomplete - Limited to CSV routes */}
|
||||
{/* Origin Port with Autocomplete */}
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Port d'origine * {searchForm.origin && <span className="text-green-600 text-xs">✓ Sélectionné</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={originSearch}
|
||||
onChange={e => {
|
||||
setOriginSearch(e.target.value);
|
||||
setShowOriginDropdown(true);
|
||||
// Clear selection if user modifies the input
|
||||
if (selectedOriginPort && e.target.value !== selectedOriginPort.displayName) {
|
||||
setSearchForm({ ...searchForm, origin: '', destination: '' });
|
||||
setSelectedOriginPort(null);
|
||||
setSelectedDestinationPort(null);
|
||||
setDestinationSearch('');
|
||||
if (e.target.value.length < 2) {
|
||||
setSearchForm({ ...searchForm, origin: '' });
|
||||
}
|
||||
}}
|
||||
onFocus={() => setShowOriginDropdown(true)}
|
||||
onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)}
|
||||
placeholder="Rechercher un port d'origine..."
|
||||
placeholder="ex: Rotterdam, Paris, FRPAR"
|
||||
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'
|
||||
}`}
|
||||
/>
|
||||
{isLoadingOrigins && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showOriginDropdown && filteredOrigins.length > 0 && (
|
||||
{showOriginDropdown && originPorts && originPorts.length > 0 && (
|
||||
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
|
||||
{filteredOrigins.slice(0, 15).map((port: RoutePortInfo) => (
|
||||
{originPorts.map((port: Port) => (
|
||||
<button
|
||||
key={port.code}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchForm({ ...searchForm, origin: port.code, destination: '' });
|
||||
setSearchForm({ ...searchForm, origin: port.code });
|
||||
setOriginSearch(port.displayName);
|
||||
setSelectedOriginPort(port);
|
||||
setSelectedDestinationPort(null);
|
||||
setDestinationSearch('');
|
||||
setShowOriginDropdown(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
|
||||
@ -285,60 +229,34 @@ export default function AdvancedSearchPage() {
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filteredOrigins.length > 15 && (
|
||||
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
|
||||
+{filteredOrigins.length - 15} autres résultats. Affinez votre recherche.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showOriginDropdown && filteredOrigins.length === 0 && !isLoadingOrigins && originsData && (
|
||||
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
|
||||
<p className="text-sm text-gray-500">Aucun port d'origine trouvé pour "{originSearch}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Destination Port with Autocomplete - Limited to routes from selected origin */}
|
||||
{/* Destination Port with Autocomplete */}
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Port de destination * {searchForm.destination && <span className="text-green-600 text-xs">✓ Sélectionné</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={destinationSearch}
|
||||
onChange={e => {
|
||||
setDestinationSearch(e.target.value);
|
||||
setShowDestinationDropdown(true);
|
||||
// Clear selection if user modifies the input
|
||||
if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) {
|
||||
if (e.target.value.length < 2) {
|
||||
setSearchForm({ ...searchForm, destination: '' });
|
||||
setSelectedDestinationPort(null);
|
||||
}
|
||||
}}
|
||||
onFocus={() => setShowDestinationDropdown(true)}
|
||||
onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
|
||||
disabled={!searchForm.origin}
|
||||
placeholder={searchForm.origin ? 'Rechercher une destination...' : 'Sélectionnez d\'abord un port d\'origine'}
|
||||
placeholder="ex: Shanghai, New York, CNSHA"
|
||||
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
|
||||
searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300'
|
||||
} ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||||
}`}
|
||||
/>
|
||||
{isLoadingDestinations && searchForm.origin && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{searchForm.origin && destinationsData?.total !== undefined && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{destinationsData.total} destination{destinationsData.total > 1 ? 's' : ''} disponible{destinationsData.total > 1 ? 's' : ''} depuis {selectedOriginPort?.name || searchForm.origin}
|
||||
</p>
|
||||
)}
|
||||
{showDestinationDropdown && filteredDestinations.length > 0 && (
|
||||
{showDestinationDropdown && destinationPorts && destinationPorts.length > 0 && (
|
||||
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
|
||||
{filteredDestinations.slice(0, 15).map((port: RoutePortInfo) => (
|
||||
{destinationPorts.map((port: Port) => (
|
||||
<button
|
||||
key={port.code}
|
||||
type="button"
|
||||
@ -356,23 +274,13 @@ export default function AdvancedSearchPage() {
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filteredDestinations.length > 15 && (
|
||||
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
|
||||
+{filteredDestinations.length - 15} autres résultats. Affinez votre recherche.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showDestinationDropdown && filteredDestinations.length === 0 && !isLoadingDestinations && searchForm.origin && destinationsData && (
|
||||
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
|
||||
<p className="text-sm text-gray-500">Aucune destination trouvée pour "{destinationSearch}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carte interactive de la route maritime */}
|
||||
{selectedOriginPort && selectedDestinationPort && selectedOriginPort.latitude && selectedDestinationPort.latitude && (
|
||||
{selectedOriginPort && selectedDestinationPort && (
|
||||
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
@ -384,12 +292,12 @@ export default function AdvancedSearchPage() {
|
||||
</div>
|
||||
<PortRouteMap
|
||||
portA={{
|
||||
lat: selectedOriginPort.latitude,
|
||||
lng: selectedOriginPort.longitude!,
|
||||
lat: selectedOriginPort.coordinates.latitude,
|
||||
lng: selectedOriginPort.coordinates.longitude,
|
||||
}}
|
||||
portB={{
|
||||
lat: selectedDestinationPort.latitude,
|
||||
lng: selectedDestinationPort.longitude!,
|
||||
lat: selectedDestinationPort.coordinates.latitude,
|
||||
lng: selectedDestinationPort.coordinates.longitude,
|
||||
}}
|
||||
height="400px"
|
||||
/>
|
||||
@ -733,7 +641,7 @@ export default function AdvancedSearchPage() {
|
||||
disabled={!searchForm.origin || !searchForm.destination}
|
||||
className="px-6 py-3 text-base font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
<Search className="h-5 w-5 mr-2" /> Rechercher les tarifs
|
||||
🔍 Rechercher les tarifs
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -4,7 +4,6 @@ import { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
|
||||
import type { CsvRateSearchResult } from '@/types/rates';
|
||||
import { Search, Lightbulb, DollarSign, Scale, Zap, Trophy, XCircle, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface BestOptions {
|
||||
eco: CsvRateSearchResult;
|
||||
@ -122,7 +121,7 @@ export default function SearchResultsPage() {
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
|
||||
<div className="mb-4 flex justify-center"><XCircle className="h-16 w-16 text-red-500" /></div>
|
||||
<div className="text-6xl mb-4">❌</div>
|
||||
<h3 className="text-xl font-bold text-red-900 mb-2">Erreur</h3>
|
||||
<p className="text-red-700 mb-4">{error}</p>
|
||||
<button
|
||||
@ -149,13 +148,13 @@ export default function SearchResultsPage() {
|
||||
</button>
|
||||
|
||||
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
|
||||
<div className="mb-4 flex justify-center"><Search className="h-16 w-16 text-yellow-500" /></div>
|
||||
<div className="text-6xl mb-4">🔍</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">Aucun résultat trouvé</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Aucun tarif ne correspond à votre recherche pour le trajet {origin} → {destination}
|
||||
</p>
|
||||
<div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-2 flex items-center"><Lightbulb className="h-5 w-5 mr-2 text-yellow-500" /> Suggestions :</h4>
|
||||
<h4 className="font-semibold text-gray-900 mb-2">💡 Suggestions :</h4>
|
||||
<ul className="text-sm text-gray-700 space-y-2">
|
||||
<li>
|
||||
• <strong>Ports disponibles :</strong> NLRTM, DEHAM, FRLEH, BEGNE (origine) → USNYC, USLAX,
|
||||
@ -191,7 +190,7 @@ export default function SearchResultsPage() {
|
||||
text: 'text-green-800',
|
||||
button: 'bg-green-600 hover:bg-green-700',
|
||||
},
|
||||
icon: <DollarSign className="h-10 w-10 text-green-600" />,
|
||||
icon: '💰',
|
||||
badge: 'Le moins cher',
|
||||
},
|
||||
{
|
||||
@ -203,7 +202,7 @@ export default function SearchResultsPage() {
|
||||
text: 'text-blue-800',
|
||||
button: 'bg-blue-600 hover:bg-blue-700',
|
||||
},
|
||||
icon: <Scale className="h-10 w-10 text-blue-600" />,
|
||||
icon: '⚖️',
|
||||
badge: 'Équilibré',
|
||||
},
|
||||
{
|
||||
@ -215,7 +214,7 @@ export default function SearchResultsPage() {
|
||||
text: 'text-purple-800',
|
||||
button: 'bg-purple-600 hover:bg-purple-700',
|
||||
},
|
||||
icon: <Zap className="h-10 w-10 text-purple-600" />,
|
||||
icon: '⚡',
|
||||
badge: 'Le plus rapide',
|
||||
},
|
||||
];
|
||||
@ -254,7 +253,7 @@ export default function SearchResultsPage() {
|
||||
{bestOptions && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
|
||||
<Trophy className="h-8 w-8 mr-3 text-yellow-500" />
|
||||
<span className="text-3xl mr-3">🏆</span>
|
||||
Meilleurs choix pour votre recherche
|
||||
</h2>
|
||||
|
||||
@ -270,7 +269,7 @@ export default function SearchResultsPage() {
|
||||
<div className={`p-6 ${card.colors.bg}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span>{card.icon}</span>
|
||||
<span className="text-4xl">{card.icon}</span>
|
||||
<div>
|
||||
<h3 className={`text-xl font-bold ${card.colors.text}`}>{card.type}</h3>
|
||||
<span className="text-xs bg-white px-2 py-1 rounded-full text-gray-600 font-medium">
|
||||
@ -367,7 +366,7 @@ export default function SearchResultsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
||||
<span>✓ Valide jusqu'au {new Date(result.validUntil).toLocaleDateString('fr-FR')}</span>
|
||||
{result.hasSurcharges && <span className="text-orange-600 flex items-center"><AlertTriangle className="h-4 w-4 mr-1" /> Surcharges applicables</span>}
|
||||
{result.hasSurcharges && <span className="text-orange-600">⚠️ Surcharges applicables</span>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@ -11,7 +11,6 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import Image from 'next/image';
|
||||
import { searchRates } from '@/lib/api';
|
||||
import { searchPorts, Port } from '@/lib/api/ports';
|
||||
import { Search, Leaf, Package } from 'lucide-react';
|
||||
|
||||
type ContainerType = '20GP' | '40GP' | '40HC' | '45HC' | '20RF' | '40RF';
|
||||
type Mode = 'SEA' | 'AIR' | 'ROAD' | 'RAIL';
|
||||
@ -123,9 +122,9 @@ export default function RateSearchPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Recherche de Tarifs Maritime</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Search Shipping Rates</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Comparez les tarifs de plusieurs transporteurs en temps réel
|
||||
Compare rates from multiple carriers in real-time
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -136,7 +135,7 @@ export default function RateSearchPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Origin Port */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Port d'origine *</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Origin Port *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
@ -147,7 +146,7 @@ export default function RateSearchPage() {
|
||||
setSearchForm({ ...searchForm, originPort: '' });
|
||||
}
|
||||
}}
|
||||
placeholder="ex : Rotterdam, Shanghai"
|
||||
placeholder="e.g., Rotterdam, Shanghai"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
{originPorts && originPorts.length > 0 && (
|
||||
@ -175,7 +174,7 @@ export default function RateSearchPage() {
|
||||
{/* Destination Port */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Port de destination *
|
||||
Destination Port *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@ -187,7 +186,7 @@ export default function RateSearchPage() {
|
||||
setSearchForm({ ...searchForm, destinationPort: '' });
|
||||
}
|
||||
}}
|
||||
placeholder="ex : Los Angeles, Hambourg"
|
||||
placeholder="e.g., Los Angeles, Hamburg"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
{destinationPorts && destinationPorts.length > 0 && (
|
||||
@ -217,7 +216,7 @@ export default function RateSearchPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Type de conteneur *
|
||||
Container Type *
|
||||
</label>
|
||||
<select
|
||||
value={searchForm.containerType}
|
||||
@ -236,7 +235,7 @@ export default function RateSearchPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Quantité *</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Quantity *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@ -251,7 +250,7 @@ export default function RateSearchPage() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Date de départ *
|
||||
Departure Date *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
@ -265,7 +264,6 @@ export default function RateSearchPage() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Mode *</label>
|
||||
|
||||
<select
|
||||
value={searchForm.mode}
|
||||
onChange={e => setSearchForm({ ...searchForm, mode: e.target.value as Mode })}
|
||||
@ -287,7 +285,7 @@ export default function RateSearchPage() {
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="hazmat" className="ml-2 block text-sm text-gray-900">
|
||||
Marchandises dangereuses (manutention spéciale requise)
|
||||
Hazardous Materials (requires special handling)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@ -301,12 +299,12 @@ export default function RateSearchPage() {
|
||||
{isSearching ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
Recherche en cours...
|
||||
Searching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="h-5 w-5 mr-2" />
|
||||
Rechercher des tarifs
|
||||
<span className="mr-2">🔍</span>
|
||||
Search Rates
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@ -317,7 +315,7 @@ export default function RateSearchPage() {
|
||||
{/* Error */}
|
||||
{searchError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-800">La recherche de tarifs a échoué. Veuillez réessayer.</div>
|
||||
<div className="text-sm text-red-800">Failed to search rates. Please try again.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -328,20 +326,20 @@ export default function RateSearchPage() {
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow p-4 space-y-6 sticky top-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Trier par</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Sort By</h3>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value as any)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="price">Prix (croissant)</option>
|
||||
<option value="transitTime">Temps de transit</option>
|
||||
<option value="co2">Émissions CO2</option>
|
||||
<option value="price">Price (Low to High)</option>
|
||||
<option value="transitTime">Transit Time</option>
|
||||
<option value="co2">CO2 Emissions</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Fourchette de prix (USD)</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Price Range (USD)</h3>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="range"
|
||||
@ -353,14 +351,14 @@ export default function RateSearchPage() {
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
Jusqu'à {priceRange[1].toLocaleString()} $
|
||||
Up to ${priceRange[1].toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
Temps de transit max (jours)
|
||||
Max Transit Time (days)
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
@ -371,13 +369,13 @@ export default function RateSearchPage() {
|
||||
onChange={e => setTransitTimeMax(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-sm text-gray-600">{transitTimeMax} jours</div>
|
||||
<div className="text-sm text-gray-600">{transitTimeMax} days</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{availableCarriers.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Transporteurs</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Carriers</h3>
|
||||
<div className="space-y-2">
|
||||
{availableCarriers.map(carrier => (
|
||||
<label key={carrier} className="flex items-center">
|
||||
@ -400,7 +398,8 @@ export default function RateSearchPage() {
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{filteredAndSortedQuotes.length} tarif{filteredAndSortedQuotes.length !== 1 ? 's' : ''} trouvé{filteredAndSortedQuotes.length !== 1 ? 's' : ''}
|
||||
{filteredAndSortedQuotes.length} Rate
|
||||
{filteredAndSortedQuotes.length !== 1 ? 's' : ''} Found
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@ -419,9 +418,9 @@ export default function RateSearchPage() {
|
||||
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun tarif trouvé</h3>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No rates found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Essayez d'ajuster vos filtres ou vos critères de recherche
|
||||
Try adjusting your filters or search criteria
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -468,19 +467,19 @@ export default function RateSearchPage() {
|
||||
{/* Route Info */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase">Départ</div>
|
||||
<div className="text-xs text-gray-500 uppercase">Departure</div>
|
||||
<div className="text-sm font-medium text-gray-900 mt-1">
|
||||
{new Date(quote.route.etd).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase">Temps de transit</div>
|
||||
<div className="text-xs text-gray-500 uppercase">Transit Time</div>
|
||||
<div className="text-sm font-medium text-gray-900 mt-1">
|
||||
{quote.route.transitDays} jours
|
||||
{quote.route.transitDays} days
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase">Arrivée</div>
|
||||
<div className="text-xs text-gray-500 uppercase">Arrival</div>
|
||||
<div className="text-sm font-medium text-gray-900 mt-1">
|
||||
{new Date(quote.route.eta).toLocaleDateString()}
|
||||
</div>
|
||||
@ -531,14 +530,14 @@ export default function RateSearchPage() {
|
||||
<div className="mt-4 flex items-center space-x-4 text-sm text-gray-500">
|
||||
{quote.co2Emissions && (
|
||||
<div className="flex items-center">
|
||||
<Leaf className="h-4 w-4 mr-1 text-green-500" />
|
||||
<span className="mr-1">🌱</span>
|
||||
{quote.co2Emissions.value} kg CO2
|
||||
</div>
|
||||
)}
|
||||
{quote.availability && (
|
||||
<div className="flex items-center">
|
||||
<Package className="h-4 w-4 mr-1 text-blue-500" />
|
||||
{quote.availability} conteneurs disponibles
|
||||
<span className="mr-1">📦</span>
|
||||
{quote.availability} containers available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -546,7 +545,7 @@ export default function RateSearchPage() {
|
||||
{/* Surcharges */}
|
||||
{quote.pricing.surcharges && quote.pricing.surcharges.length > 0 && (
|
||||
<div className="mt-4 text-sm">
|
||||
<div className="text-gray-500 mb-2">Surcharges incluses :</div>
|
||||
<div className="text-gray-500 mb-2">Includes surcharges:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quote.pricing.surcharges.map((surcharge: any, idx: number) => (
|
||||
<span
|
||||
@ -566,7 +565,7 @@ export default function RateSearchPage() {
|
||||
href={`/dashboard/bookings/new?quoteId=${quote.id}`}
|
||||
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"
|
||||
>
|
||||
Réserver
|
||||
Book Now
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -592,9 +591,10 @@ export default function RateSearchPage() {
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="mt-4 text-lg font-medium text-gray-900">Rechercher des tarifs maritimes</h3>
|
||||
<h3 className="mt-4 text-lg font-medium text-gray-900">Search for Shipping Rates</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Saisissez votre origine, destination et détails du conteneur pour comparer les tarifs de plusieurs transporteurs
|
||||
Enter your origin, destination, and container details to compare rates from multiple
|
||||
carriers
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -13,7 +13,6 @@ import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
|
||||
import { createInvitation } from '@/lib/api/invitations';
|
||||
import { useAuth } from '@/lib/context/auth-context';
|
||||
import Link from 'next/link';
|
||||
import ExportButton from '@/components/ExportButton';
|
||||
|
||||
export default function UsersManagementPage() {
|
||||
const router = useRouter();
|
||||
@ -54,7 +53,7 @@ export default function UsersManagementPage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||
setSuccess('Invitation envoyée avec succès ! L\'utilisateur recevra un email avec un lien d\'inscription.');
|
||||
setSuccess('Invitation sent successfully! The user will receive an email with a registration link.');
|
||||
setShowInviteModal(false);
|
||||
setInviteForm({
|
||||
email: '',
|
||||
@ -65,7 +64,7 @@ export default function UsersManagementPage() {
|
||||
setTimeout(() => setSuccess(''), 5000);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Échec de l\'envoi de l\'invitation');
|
||||
setError(err.response?.data?.message || 'Failed to send invitation');
|
||||
setTimeout(() => setError(''), 5000);
|
||||
},
|
||||
});
|
||||
@ -76,11 +75,11 @@ export default function UsersManagementPage() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
setSuccess('Rôle mis à jour avec succès');
|
||||
setSuccess('Role updated successfully');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Échec de la mise à jour du rôle');
|
||||
setError(err.response?.data?.message || 'Failed to update role');
|
||||
setTimeout(() => setError(''), 5000);
|
||||
},
|
||||
});
|
||||
@ -92,11 +91,11 @@ export default function UsersManagementPage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||
setSuccess('Statut de l\'utilisateur mis à jour avec succès');
|
||||
setSuccess('User status updated successfully');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Échec de la mise à jour du statut');
|
||||
setError(err.response?.data?.message || 'Failed to update user status');
|
||||
setTimeout(() => setError(''), 5000);
|
||||
},
|
||||
});
|
||||
@ -106,11 +105,11 @@ export default function UsersManagementPage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||
setSuccess('Utilisateur supprimé avec succès');
|
||||
setSuccess('User deleted successfully');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Échec de la suppression de l\'utilisateur');
|
||||
setError(err.response?.data?.message || 'Failed to delete user');
|
||||
setTimeout(() => setError(''), 5000);
|
||||
},
|
||||
});
|
||||
@ -144,7 +143,7 @@ export default function UsersManagementPage() {
|
||||
|
||||
const handleToggleActive = (userId: string, isActive: boolean) => {
|
||||
if (
|
||||
window.confirm(`Êtes-vous sûr de vouloir ${isActive ? 'désactiver' : 'activer'} cet utilisateur ?`)
|
||||
window.confirm(`Are you sure you want to ${isActive ? 'deactivate' : 'activate'} this user?`)
|
||||
) {
|
||||
toggleActiveMutation.mutate({ id: userId, isActive });
|
||||
}
|
||||
@ -152,7 +151,7 @@ export default function UsersManagementPage() {
|
||||
|
||||
const handleDelete = (userId: string) => {
|
||||
if (
|
||||
window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.')
|
||||
window.confirm('Are you sure you want to delete this user? This action cannot be undone.')
|
||||
) {
|
||||
deleteMutation.mutate(userId);
|
||||
}
|
||||
@ -180,17 +179,17 @@ export default function UsersManagementPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-sm font-medium text-amber-800">Limite de licences atteinte</h3>
|
||||
<h3 className="text-sm font-medium text-amber-800">License limit reached</h3>
|
||||
<p className="mt-1 text-sm text-amber-700">
|
||||
Votre organisation a utilisé toutes les licences disponibles ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
|
||||
Mettez à niveau votre abonnement pour inviter plus d'utilisateurs.
|
||||
Your organization has used all available licenses ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
|
||||
Upgrade your subscription to invite more users.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Link
|
||||
href="/dashboard/settings/subscription"
|
||||
className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"
|
||||
>
|
||||
Mettre à niveau l'abonnement
|
||||
Upgrade Subscription
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@ -207,14 +206,14 @@ export default function UsersManagementPage() {
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm text-blue-800">
|
||||
{licenseStatus.availableLicenses} licence{licenseStatus.availableLicenses !== 1 ? 's' : ''} restante{licenseStatus.availableLicenses !== 1 ? 's' : ''} ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} utilisées)
|
||||
{licenseStatus.availableLicenses} license{licenseStatus.availableLicenses !== 1 ? 's' : ''} remaining ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} used)
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/settings/subscription"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Gérer l'abonnement
|
||||
Manage Subscription
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@ -223,37 +222,16 @@ export default function UsersManagementPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Gestion des Utilisateurs</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Gérez les membres de l'équipe et leurs permissions</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Manage team members and their permissions</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<ExportButton
|
||||
data={users?.users || []}
|
||||
filename="utilisateurs"
|
||||
columns={[
|
||||
{ key: 'firstName', label: 'Prénom' },
|
||||
{ key: 'lastName', label: 'Nom' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'role', label: 'Rôle', format: (v) => {
|
||||
const labels: Record<string, string> = {
|
||||
ADMIN: 'Administrateur',
|
||||
MANAGER: 'Manager',
|
||||
USER: 'Utilisateur',
|
||||
VIEWER: 'Lecteur',
|
||||
};
|
||||
return labels[v] || v;
|
||||
}},
|
||||
{ key: 'isActive', label: 'Statut', format: (v) => v ? 'Actif' : 'Inactif' },
|
||||
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
|
||||
]}
|
||||
/>
|
||||
{licenseStatus?.canInvite ? (
|
||||
<button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<span className="mr-2">+</span>
|
||||
Inviter un utilisateur
|
||||
Invite User
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
@ -261,11 +239,10 @@ export default function UsersManagementPage() {
|
||||
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"
|
||||
>
|
||||
<span className="mr-2">+</span>
|
||||
Mettre à niveau
|
||||
Upgrade to Invite
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="rounded-md bg-green-50 p-4">
|
||||
@ -284,7 +261,7 @@ export default function UsersManagementPage() {
|
||||
{isLoading ? (
|
||||
<div className="px-6 py-12 text-center text-gray-500">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
Chargement des utilisateurs...
|
||||
Loading users...
|
||||
</div>
|
||||
) : users?.users && users.users.length > 0 ? (
|
||||
<div className="overflow-x-auto overflow-y-visible">
|
||||
@ -292,19 +269,19 @@ export default function UsersManagementPage() {
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Utilisateur
|
||||
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">
|
||||
Rôle
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Statut
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date de création
|
||||
Last Login
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
@ -361,7 +338,7 @@ export default function UsersManagementPage() {
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{user.isActive ? 'Actif' : 'Inactif'}
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
@ -409,8 +386,8 @@ export default function UsersManagementPage() {
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun utilisateur</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Commencez par inviter un membre de l'équipe</p>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No users</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by inviting a team member</p>
|
||||
<div className="mt-6">
|
||||
{licenseStatus?.canInvite ? (
|
||||
<button
|
||||
@ -513,7 +490,7 @@ export default function UsersManagementPage() {
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Inviter un utilisateur</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900">Invite User</h3>
|
||||
<button
|
||||
onClick={() => setShowInviteModal(false)}
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
@ -533,7 +510,7 @@ export default function UsersManagementPage() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Prénom *
|
||||
First Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@ -544,7 +521,7 @@ export default function UsersManagementPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Nom *</label>
|
||||
<label className="block text-sm font-medium text-gray-700">Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
@ -556,7 +533,7 @@ export default function UsersManagementPage() {
|
||||
</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">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
@ -567,20 +544,20 @@ export default function UsersManagementPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Rôle *</label>
|
||||
<label className="block text-sm font-medium text-gray-700">Role *</label>
|
||||
<select
|
||||
value={inviteForm.role}
|
||||
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
|
||||
>
|
||||
<option value="USER">Utilisateur</option>
|
||||
<option value="USER">User</option>
|
||||
<option value="MANAGER">Manager</option>
|
||||
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Administrateur</option>}
|
||||
<option value="VIEWER">Lecteur</option>
|
||||
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
|
||||
<option value="VIEWER">Viewer</option>
|
||||
</select>
|
||||
{currentUser?.role !== 'ADMIN' && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Seuls les administrateurs peuvent attribuer le rôle ADMIN
|
||||
Only platform administrators can assign the ADMIN role
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -591,14 +568,14 @@ export default function UsersManagementPage() {
|
||||
disabled={inviteMutation.isPending}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
|
||||
>
|
||||
{inviteMutation.isPending ? 'Envoi en cours...' : 'Envoyer l\'invitation'}
|
||||
{inviteMutation.isPending ? 'Inviting...' : 'Send Invitation'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInviteModal(false)}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm"
|
||||
>
|
||||
Annuler
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -2,181 +2,107 @@
|
||||
* Track & Trace Page
|
||||
*
|
||||
* Allows users to track their shipments by entering tracking numbers
|
||||
* and selecting the carrier. Includes search history and vessel position map.
|
||||
* and selecting the carrier. Redirects to carrier's tracking page.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Search,
|
||||
Package,
|
||||
FileText,
|
||||
ClipboardList,
|
||||
Lightbulb,
|
||||
History,
|
||||
MapPin,
|
||||
X,
|
||||
Clock,
|
||||
Ship,
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Globe,
|
||||
Anchor,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Search history item type
|
||||
interface SearchHistoryItem {
|
||||
id: string;
|
||||
trackingNumber: string;
|
||||
carrierId: string;
|
||||
carrierName: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// Carrier tracking URLs with official brand colors
|
||||
// Carrier tracking URLs - the tracking number will be appended
|
||||
const carriers = [
|
||||
{
|
||||
id: 'maersk',
|
||||
name: 'Maersk',
|
||||
color: '#00243D', // Maersk dark blue
|
||||
textColor: 'text-white',
|
||||
logo: '🚢',
|
||||
trackingUrl: 'https://www.maersk.com/tracking/',
|
||||
placeholder: 'Ex: MSKU1234567',
|
||||
description: 'N° conteneur ou B/L',
|
||||
logo: '/assets/logos/carriers/maersk.svg',
|
||||
description: 'Container or B/L number',
|
||||
},
|
||||
{
|
||||
id: 'msc',
|
||||
name: 'MSC',
|
||||
color: '#002B5C', // MSC blue
|
||||
textColor: 'text-white',
|
||||
logo: '🛳️',
|
||||
trackingUrl: 'https://www.msc.com/track-a-shipment?query=',
|
||||
placeholder: 'Ex: MSCU1234567',
|
||||
description: 'N° conteneur, B/L ou réservation',
|
||||
logo: '/assets/logos/carriers/msc.svg',
|
||||
description: 'Container, B/L or Booking number',
|
||||
},
|
||||
{
|
||||
id: 'cma-cgm',
|
||||
name: 'CMA CGM',
|
||||
color: '#E30613', // CMA CGM red
|
||||
textColor: 'text-white',
|
||||
logo: '⚓',
|
||||
trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=',
|
||||
placeholder: 'Ex: CMAU1234567',
|
||||
description: 'N° conteneur ou B/L',
|
||||
logo: '/assets/logos/carriers/cmacgm.svg',
|
||||
description: 'Container or B/L number',
|
||||
},
|
||||
{
|
||||
id: 'hapag-lloyd',
|
||||
name: 'Hapag-Lloyd',
|
||||
color: '#FF6600', // Hapag orange
|
||||
textColor: 'text-white',
|
||||
logo: '🔷',
|
||||
trackingUrl: 'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=',
|
||||
placeholder: 'Ex: HLCU1234567',
|
||||
description: 'N° conteneur',
|
||||
logo: '/assets/logos/carriers/hapag.svg',
|
||||
description: 'Container number',
|
||||
},
|
||||
{
|
||||
id: 'cosco',
|
||||
name: 'COSCO',
|
||||
color: '#003A70', // COSCO blue
|
||||
textColor: 'text-white',
|
||||
logo: '🌊',
|
||||
trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=',
|
||||
placeholder: 'Ex: COSU1234567',
|
||||
description: 'N° conteneur ou B/L',
|
||||
logo: '/assets/logos/carriers/cosco.svg',
|
||||
description: 'Container or B/L number',
|
||||
},
|
||||
{
|
||||
id: 'one',
|
||||
name: 'ONE',
|
||||
color: '#FF00FF', // ONE magenta
|
||||
textColor: 'text-white',
|
||||
name: 'ONE (Ocean Network Express)',
|
||||
logo: '🟣',
|
||||
trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=',
|
||||
placeholder: 'Ex: ONEU1234567',
|
||||
description: 'N° conteneur ou B/L',
|
||||
logo: '/assets/logos/carriers/one.svg',
|
||||
description: 'Container or B/L number',
|
||||
},
|
||||
{
|
||||
id: 'evergreen',
|
||||
name: 'Evergreen',
|
||||
color: '#006633', // Evergreen green
|
||||
textColor: 'text-white',
|
||||
logo: '🌲',
|
||||
trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=',
|
||||
placeholder: 'Ex: EGHU1234567',
|
||||
description: 'N° conteneur ou B/L',
|
||||
logo: '/assets/logos/carriers/evergreen.svg',
|
||||
description: 'Container or B/L number',
|
||||
},
|
||||
{
|
||||
id: 'yangming',
|
||||
name: 'Yang Ming',
|
||||
color: '#FFD700', // Yang Ming yellow
|
||||
textColor: 'text-gray-900',
|
||||
logo: '🟡',
|
||||
trackingUrl: 'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=',
|
||||
placeholder: 'Ex: YMLU1234567',
|
||||
description: 'N° conteneur',
|
||||
logo: '/assets/logos/carriers/yangming.svg',
|
||||
description: 'Container number',
|
||||
},
|
||||
{
|
||||
id: 'zim',
|
||||
name: 'ZIM',
|
||||
color: '#1E3A8A', // ZIM blue
|
||||
textColor: 'text-white',
|
||||
logo: '🔵',
|
||||
trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=',
|
||||
placeholder: 'Ex: ZIMU1234567',
|
||||
description: 'N° conteneur ou B/L',
|
||||
logo: '/assets/logos/carriers/zim.svg',
|
||||
description: 'Container or B/L number',
|
||||
},
|
||||
{
|
||||
id: 'hmm',
|
||||
name: 'HMM',
|
||||
color: '#E65100', // HMM orange
|
||||
textColor: 'text-white',
|
||||
name: 'HMM (Hyundai)',
|
||||
logo: '🟠',
|
||||
trackingUrl: 'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=',
|
||||
placeholder: 'Ex: HDMU1234567',
|
||||
description: 'N° conteneur ou B/L',
|
||||
logo: '/assets/logos/carriers/hmm.svg',
|
||||
description: 'Container or B/L number',
|
||||
},
|
||||
];
|
||||
|
||||
// Local storage keys
|
||||
const HISTORY_KEY = 'xpeditis_track_history';
|
||||
|
||||
export default function TrackTracePage() {
|
||||
const [trackingNumber, setTrackingNumber] = useState('');
|
||||
const [selectedCarrier, setSelectedCarrier] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]);
|
||||
const [showMap, setShowMap] = useState(false);
|
||||
const [isMapFullscreen, setIsMapFullscreen] = useState(false);
|
||||
const [isMapLoading, setIsMapLoading] = useState(true);
|
||||
|
||||
// Load history from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedHistory = localStorage.getItem(HISTORY_KEY);
|
||||
if (savedHistory) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedHistory);
|
||||
setSearchHistory(parsed.map((item: any) => ({
|
||||
...item,
|
||||
timestamp: new Date(item.timestamp)
|
||||
})));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse search history:', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save to localStorage
|
||||
const saveHistory = (history: SearchHistoryItem[]) => {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||
setSearchHistory(history);
|
||||
};
|
||||
|
||||
const handleTrack = () => {
|
||||
// Validation
|
||||
if (!trackingNumber.trim()) {
|
||||
setError('Veuillez entrer un numéro de tracking');
|
||||
return;
|
||||
@ -188,43 +114,15 @@ export default function TrackTracePage() {
|
||||
|
||||
setError('');
|
||||
|
||||
// Find the carrier and build the tracking URL
|
||||
const carrier = carriers.find(c => c.id === selectedCarrier);
|
||||
if (carrier) {
|
||||
// Add to history
|
||||
const newHistoryItem: SearchHistoryItem = {
|
||||
id: Date.now().toString(),
|
||||
trackingNumber: trackingNumber.trim(),
|
||||
carrierId: carrier.id,
|
||||
carrierName: carrier.name,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// Keep only last 10 unique searches
|
||||
const updatedHistory = [newHistoryItem, ...searchHistory.filter(
|
||||
h => !(h.trackingNumber === newHistoryItem.trackingNumber && h.carrierId === newHistoryItem.carrierId)
|
||||
)].slice(0, 10);
|
||||
|
||||
saveHistory(updatedHistory);
|
||||
|
||||
const trackingUrl = carrier.trackingUrl + encodeURIComponent(trackingNumber.trim());
|
||||
// Open in new tab
|
||||
window.open(trackingUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const handleHistoryClick = (item: SearchHistoryItem) => {
|
||||
setTrackingNumber(item.trackingNumber);
|
||||
setSelectedCarrier(item.carrierId);
|
||||
};
|
||||
|
||||
const handleDeleteHistory = (id: string) => {
|
||||
const updatedHistory = searchHistory.filter(h => h.id !== id);
|
||||
saveHistory(updatedHistory);
|
||||
};
|
||||
|
||||
const handleClearHistory = () => {
|
||||
saveHistory([]);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleTrack();
|
||||
@ -233,25 +131,11 @@ export default function TrackTracePage() {
|
||||
|
||||
const selectedCarrierData = carriers.find(c => c.id === selectedCarrier);
|
||||
|
||||
const formatTimeAgo = (date: Date) => {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'À l\'instant';
|
||||
if (diffMins < 60) return `Il y a ${diffMins}min`;
|
||||
if (diffHours < 24) return `Il y a ${diffHours}h`;
|
||||
if (diffDays < 7) return `Il y a ${diffDays}j`;
|
||||
return date.toLocaleDateString('fr-FR');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Suivi des expéditions</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Track & Trace</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Suivez vos expéditions en temps réel. Entrez votre numéro de tracking et sélectionnez le transporteur.
|
||||
</p>
|
||||
@ -261,15 +145,15 @@ export default function TrackTracePage() {
|
||||
<Card className="bg-white shadow-lg border-blue-100">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Search className="h-5 w-5 text-blue-600" />
|
||||
<span className="text-2xl">🔍</span>
|
||||
Rechercher une expédition
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Entrez votre numéro de conteneur, connaissement (B/L) ou référence de réservation
|
||||
Entrez votre numéro de conteneur, connaissement (B/L) ou référence de booking
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Carrier Selection - US 5.1: Professional carrier cards with brand colors */}
|
||||
{/* Carrier Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Sélectionnez le transporteur
|
||||
@ -283,20 +167,14 @@ export default function TrackTracePage() {
|
||||
setSelectedCarrier(carrier.id);
|
||||
setError('');
|
||||
}}
|
||||
className={`flex flex-col items-center justify-center p-4 rounded-xl border-2 transition-all hover:scale-105 ${
|
||||
className={`flex flex-col items-center justify-center p-3 rounded-lg border-2 transition-all ${
|
||||
selectedCarrier === carrier.id
|
||||
? 'border-blue-500 shadow-lg ring-2 ring-blue-200'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:shadow-md'
|
||||
? 'border-blue-500 bg-blue-50 shadow-md'
|
||||
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{/* Carrier logo/badge with brand color */}
|
||||
<div
|
||||
className={`w-12 h-12 rounded-lg flex items-center justify-center text-sm font-bold mb-2 shadow-sm ${carrier.textColor}`}
|
||||
style={{ backgroundColor: carrier.color }}
|
||||
>
|
||||
{carrier.name.length <= 3 ? carrier.name : carrier.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-gray-800 text-center leading-tight">{carrier.name}</span>
|
||||
<span className="text-2xl mb-1">{carrier.logo}</span>
|
||||
<span className="text-xs font-medium text-gray-700 text-center">{carrier.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -319,42 +197,22 @@ export default function TrackTracePage() {
|
||||
}}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={selectedCarrierData?.placeholder || 'Ex: MSKU1234567'}
|
||||
className="text-lg font-mono border-gray-300 focus:border-blue-500 h-12"
|
||||
className="text-lg font-mono border-gray-300 focus:border-blue-500"
|
||||
/>
|
||||
{selectedCarrierData && (
|
||||
<p className="mt-1 text-xs text-gray-500">{selectedCarrierData.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* US 5.2: Harmonized button color */}
|
||||
<Button
|
||||
onClick={handleTrack}
|
||||
size="lg"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-8 h-12 font-semibold shadow-md"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6"
|
||||
>
|
||||
<Search className="mr-2 h-5 w-5" />
|
||||
<span className="mr-2">🔍</span>
|
||||
Rechercher
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button - Map */}
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
<Button
|
||||
variant={showMap ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setShowMap(!showMap);
|
||||
if (!showMap) setIsMapLoading(true);
|
||||
}}
|
||||
className={showMap
|
||||
? "bg-blue-600 hover:bg-blue-700 text-white"
|
||||
: "text-gray-700 border-gray-300 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700"
|
||||
}
|
||||
>
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
{showMap ? 'Masquer la carte maritime' : 'Afficher la carte maritime'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
@ -364,221 +222,12 @@ export default function TrackTracePage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Vessel Position Map - Large immersive display */}
|
||||
{showMap && (
|
||||
<div className={`${isMapFullscreen ? 'fixed inset-0 z-50 bg-gray-900' : ''}`}>
|
||||
<Card className={`bg-white shadow-xl overflow-hidden ${isMapFullscreen ? 'h-full rounded-none' : ''}`}>
|
||||
{/* Map Header */}
|
||||
<div className={`flex items-center justify-between px-6 py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white ${isMapFullscreen ? '' : 'rounded-t-lg'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-white/20 rounded-lg">
|
||||
<Globe className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Carte Maritime Mondiale</h3>
|
||||
<p className="text-blue-100 text-sm">Position des navires en temps réel</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Fullscreen Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsMapFullscreen(!isMapFullscreen)}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
{isMapFullscreen ? (
|
||||
<>
|
||||
<Minimize2 className="h-4 w-4 mr-2" />
|
||||
Réduire
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Maximize2 className="h-4 w-4 mr-2" />
|
||||
Plein écran
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowMap(false);
|
||||
setIsMapFullscreen(false);
|
||||
}}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map Container */}
|
||||
<div className={`relative w-full ${isMapFullscreen ? 'h-[calc(100vh-80px)]' : 'h-[70vh] min-h-[500px] max-h-[800px]'}`}>
|
||||
{/* Loading State */}
|
||||
{isMapLoading && (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center z-10">
|
||||
<div className="text-center">
|
||||
<div className="relative">
|
||||
<Ship className="h-16 w-16 text-blue-600 animate-pulse" />
|
||||
<div className="absolute -bottom-1 left-1/2 transform -translate-x-1/2">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-blue-700 font-medium">Chargement de la carte...</p>
|
||||
<p className="text-blue-500 text-sm">Connexion à MarineTraffic</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MarineTraffic Map */}
|
||||
<iframe
|
||||
src="https://www.marinetraffic.com/en/ais/embed/zoom:3/centery:25/centerx:0/maptype:4/shownames:true/mmsi:0/shipid:0/fleet:/fleet_id:/vtypes:/showmenu:true/remember:false"
|
||||
className="w-full h-full border-0"
|
||||
title="Carte maritime en temps réel"
|
||||
loading="lazy"
|
||||
onLoad={() => setIsMapLoading(false)}
|
||||
/>
|
||||
|
||||
{/* Map Legend Overlay */}
|
||||
<div className={`absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? 'max-w-xs' : 'max-w-[280px]'}`}>
|
||||
<h4 className="font-semibold text-gray-800 text-sm mb-3 flex items-center gap-2">
|
||||
<Anchor className="h-4 w-4 text-blue-600" />
|
||||
Légende
|
||||
</h4>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
<span className="text-gray-600">Cargos</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<span className="text-gray-600">Tankers</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||
<span className="text-gray-600">Passagers</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||
<span className="text-gray-600">High Speed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats Overlay */}
|
||||
<div className={`absolute top-4 right-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? '' : 'hidden lg:block'}`}>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-blue-600">90K+</p>
|
||||
<p className="text-gray-500 text-xs">Navires actifs</p>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-gray-200" />
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-green-600">3,500+</p>
|
||||
<p className="text-gray-500 text-xs">Ports mondiaux</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map Footer */}
|
||||
<div className="px-6 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Données fournies par MarineTraffic - Mise à jour en temps réel
|
||||
</p>
|
||||
<a
|
||||
href="https://www.marinetraffic.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1"
|
||||
>
|
||||
Ouvrir sur MarineTraffic
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search History */}
|
||||
<Card className="bg-white shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<History className="h-5 w-5 text-gray-600" />
|
||||
Historique des recherches
|
||||
</CardTitle>
|
||||
{searchHistory.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearHistory}
|
||||
className="text-gray-500 hover:text-red-600 text-xs"
|
||||
>
|
||||
Effacer tout
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{searchHistory.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Clock className="h-10 w-10 mx-auto mb-3 text-gray-300" />
|
||||
<p className="text-sm">Aucune recherche récente</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Vos recherches apparaîtront ici</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{searchHistory.map(item => {
|
||||
const carrier = carriers.find(c => c.id === item.carrierId);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:bg-gray-50 group cursor-pointer transition-colors"
|
||||
onClick={() => handleHistoryClick(item)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-8 h-8 rounded flex items-center justify-center text-xs font-bold ${carrier?.textColor || 'text-white'}`}
|
||||
style={{ backgroundColor: carrier?.color || '#666' }}
|
||||
>
|
||||
{item.carrierName.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-gray-900">{item.trackingNumber}</p>
|
||||
<p className="text-xs text-gray-500">{item.carrierName} • {formatTimeAgo(item.timestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteHistory(item.id);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-red-50 rounded transition-opacity"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-400 hover:text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card className="bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Package className="h-5 w-5 text-blue-600" />
|
||||
<span>📦</span>
|
||||
Numéro de conteneur
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@ -593,14 +242,14 @@ export default function TrackTracePage() {
|
||||
<Card className="bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
<span>📋</span>
|
||||
Connaissement (B/L)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600">
|
||||
Le numéro de connaissement (Bill of Lading) est fourni par le transporteur lors de la confirmation de réservation.
|
||||
Le format varie selon le transporteur.
|
||||
Le numéro de Bill of Lading est fourni par le transporteur lors de la confirmation de booking.
|
||||
Format variable selon le carrier.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -608,8 +257,8 @@ export default function TrackTracePage() {
|
||||
<Card className="bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5 text-blue-600" />
|
||||
Référence de réservation
|
||||
<span>📝</span>
|
||||
Référence de booking
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@ -623,7 +272,7 @@ export default function TrackTracePage() {
|
||||
{/* Info Box */}
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lightbulb className="h-5 w-5 text-blue-600 flex-shrink-0" />
|
||||
<span className="text-xl">💡</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-800">Comment fonctionne le suivi ?</p>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Shield, ClipboardList, DollarSign, Pencil, Lightbulb, Plus } from 'lucide-react';
|
||||
|
||||
const clausesICC = [
|
||||
{
|
||||
@ -65,7 +64,7 @@ export default function AssurancePage() {
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-10 h-10 text-blue-600" />
|
||||
<span className="text-4xl">🛡️</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Assurance Maritime (Cargo Insurance)</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
@ -89,7 +88,7 @@ export default function AssurancePage() {
|
||||
|
||||
{/* ICC Clauses */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Clauses ICC (Institute Cargo Clauses)</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Clauses ICC (Institute Cargo Clauses)</h2>
|
||||
<div className="space-y-4">
|
||||
{clausesICC.map((clause) => (
|
||||
<Card key={clause.code} className={`bg-white ${clause.recommended ? 'border-green-300 border-2' : ''}`}>
|
||||
@ -139,7 +138,7 @@ export default function AssurancePage() {
|
||||
{/* Valeur assurée */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Calcul de la Valeur Assurée</h3>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">💰 Calcul de la Valeur Assurée</h3>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="text-center mb-4">
|
||||
<p className="text-lg font-mono bg-blue-100 text-blue-800 p-3 rounded inline-block">
|
||||
@ -166,7 +165,7 @@ export default function AssurancePage() {
|
||||
|
||||
{/* Extensions */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Plus className="w-5 h-5" /> Extensions de Garantie</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">➕ Extensions de Garantie</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{extensionsGaranties.map((ext) => (
|
||||
<Card key={ext.name} className="bg-white">
|
||||
@ -182,7 +181,7 @@ export default function AssurancePage() {
|
||||
{/* Process */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Pencil className="w-5 h-5" /> En Cas de Sinistre</h3>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">📝 En Cas de Sinistre</h3>
|
||||
<ol className="list-decimal list-inside space-y-3 text-gray-700">
|
||||
<li><strong>Constater</strong> : Émettre des réserves précises sur le bon de livraison</li>
|
||||
<li><strong>Préserver</strong> : Ne pas modifier l'état des marchandises (photos, témoins)</li>
|
||||
@ -196,7 +195,7 @@ export default function AssurancePage() {
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
|
||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Toujours opter pour ICC A (All Risks) sauf marchandises très résistantes</li>
|
||||
<li>Vérifier les exclusions et souscrire les extensions nécessaires</li>
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Calculator, Ruler, ClipboardList, Banknote, BarChart3, Lightbulb, Scale } from 'lucide-react';
|
||||
|
||||
const surcharges = [
|
||||
{
|
||||
@ -87,7 +86,7 @@ export default function CalculFretPage() {
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Calculator className="w-10 h-10 text-blue-600" />
|
||||
<span className="text-4xl">🧮</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Calcul du Fret Maritime</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
@ -99,7 +98,7 @@ export default function CalculFretPage() {
|
||||
{/* Base Calculation */}
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><Ruler className="w-5 h-5" /> Principes de Base</h3>
|
||||
<h3 className="font-semibold text-blue-900 mb-3">📐 Principes de Base</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800">FCL (Conteneur Complet)</h4>
|
||||
@ -122,7 +121,7 @@ export default function CalculFretPage() {
|
||||
{/* Weight Calculation */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Scale className="w-5 h-5" /> Poids Taxable (LCL)</h3>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">⚖️ Poids Taxable (LCL)</h3>
|
||||
<div className="bg-white p-4 rounded-lg border mb-4">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-mono bg-blue-100 text-blue-800 p-3 rounded inline-block">
|
||||
@ -158,7 +157,7 @@ export default function CalculFretPage() {
|
||||
|
||||
{/* Surcharges */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Surcharges Courantes</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Surcharges Courantes</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{surcharges.map((sur) => (
|
||||
<Card key={sur.code} className="bg-white">
|
||||
@ -181,7 +180,7 @@ export default function CalculFretPage() {
|
||||
|
||||
{/* Additional fees */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Banknote className="w-5 h-5" /> Frais Additionnels</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">💵 Frais Additionnels</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="overflow-x-auto">
|
||||
@ -211,7 +210,7 @@ export default function CalculFretPage() {
|
||||
{/* Example calculation */}
|
||||
<Card className="mt-8 bg-green-50 border-green-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-green-900 mb-3 flex items-center gap-2"><BarChart3 className="w-5 h-5" /> Exemple de Devis FCL</h3>
|
||||
<h3 className="font-semibold text-green-900 mb-3">📊 Exemple de Devis FCL</h3>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<p className="text-sm text-gray-600 mb-3">Conteneur 40' Shanghai → Le Havre</p>
|
||||
<div className="space-y-2 font-mono text-sm">
|
||||
@ -255,7 +254,7 @@ export default function CalculFretPage() {
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils pour Optimiser</h3>
|
||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils pour Optimiser</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Demandez des devis "All-in" pour éviter les surprises de surcharges</li>
|
||||
<li>Comparez les transitaires sur le total, pas seulement le fret de base</li>
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Package, PackageOpen, Truck, Cylinder, Snowflake, type LucideIcon } from 'lucide-react';
|
||||
|
||||
const containers = [
|
||||
{
|
||||
@ -23,7 +22,7 @@ const containers = [
|
||||
tare: '2,300 kg',
|
||||
},
|
||||
usage: 'Marchandises générales sèches',
|
||||
icon: Package,
|
||||
icon: '📦',
|
||||
},
|
||||
{
|
||||
type: '40\' Standard (40\' DRY)',
|
||||
@ -39,7 +38,7 @@ const containers = [
|
||||
tare: '3,800 kg',
|
||||
},
|
||||
usage: 'Marchandises générales, cargo volumineux',
|
||||
icon: Package,
|
||||
icon: '📦',
|
||||
},
|
||||
{
|
||||
type: '40\' High Cube (40\' HC)',
|
||||
@ -55,7 +54,7 @@ const containers = [
|
||||
tare: '4,020 kg',
|
||||
},
|
||||
usage: 'Cargo léger mais volumineux',
|
||||
icon: Package,
|
||||
icon: '📦',
|
||||
},
|
||||
{
|
||||
type: 'Reefer (Réfrigéré)',
|
||||
@ -71,7 +70,7 @@ const containers = [
|
||||
temperature: '-30°C à +30°C',
|
||||
},
|
||||
usage: 'Produits périssables, pharmaceutiques',
|
||||
icon: Snowflake,
|
||||
icon: '❄️',
|
||||
},
|
||||
{
|
||||
type: 'Open Top',
|
||||
@ -87,7 +86,7 @@ const containers = [
|
||||
tare: '2,400 kg / 4,100 kg',
|
||||
},
|
||||
usage: 'Cargo hors gabarit en hauteur, machinerie',
|
||||
icon: PackageOpen,
|
||||
icon: '📭',
|
||||
},
|
||||
{
|
||||
type: 'Flat Rack',
|
||||
@ -103,7 +102,7 @@ const containers = [
|
||||
tare: '2,700 kg / 4,700 kg',
|
||||
},
|
||||
usage: 'Cargo très lourd ou surdimensionné',
|
||||
icon: Truck,
|
||||
icon: '🚛',
|
||||
},
|
||||
{
|
||||
type: 'Tank Container',
|
||||
@ -119,7 +118,7 @@ const containers = [
|
||||
tare: '3,500 kg',
|
||||
},
|
||||
usage: 'Liquides, gaz, produits chimiques',
|
||||
icon: Cylinder,
|
||||
icon: '🛢️',
|
||||
},
|
||||
];
|
||||
|
||||
@ -149,7 +148,7 @@ export default function ConteneursPage() {
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="w-10 h-10 text-blue-600" />
|
||||
<span className="text-4xl">📦</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Conteneurs et Types de Cargo</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
@ -190,7 +189,7 @@ export default function ConteneursPage() {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<container.icon className="w-6 h-6 text-blue-600" />
|
||||
<span className="text-2xl">{container.icon}</span>
|
||||
<div>
|
||||
<span className="text-lg">{container.type}</span>
|
||||
<span className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 text-xs font-mono rounded">
|
||||
@ -261,35 +260,35 @@ export default function ConteneursPage() {
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Package className="w-5 h-5 text-green-700 mt-0.5" />
|
||||
<span className="text-xl">📦</span>
|
||||
<div>
|
||||
<p className="font-medium text-green-900">Marchandises générales</p>
|
||||
<p className="text-sm text-green-800">→ 20' ou 40' Standard (DRY)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Snowflake className="w-5 h-5 text-green-700 mt-0.5" />
|
||||
<span className="text-xl">❄️</span>
|
||||
<div>
|
||||
<p className="font-medium text-green-900">Produits réfrigérés/congelés</p>
|
||||
<p className="text-sm text-green-800">→ Reefer 20' ou 40'</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<PackageOpen className="w-5 h-5 text-green-700 mt-0.5" />
|
||||
<span className="text-xl">📭</span>
|
||||
<div>
|
||||
<p className="font-medium text-green-900">Cargo hors gabarit (hauteur)</p>
|
||||
<p className="text-sm text-green-800">→ Open Top</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Truck className="w-5 h-5 text-green-700 mt-0.5" />
|
||||
<span className="text-xl">🚛</span>
|
||||
<div>
|
||||
<p className="font-medium text-green-900">Machinerie lourde/surdimensionnée</p>
|
||||
<p className="text-sm text-green-800">→ Flat Rack</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Cylinder className="w-5 h-5 text-green-700 mt-0.5" />
|
||||
<span className="text-xl">🛢️</span>
|
||||
<div>
|
||||
<p className="font-medium text-green-900">Liquides en vrac</p>
|
||||
<p className="text-sm text-green-800">→ Tank Container ou Flexitank</p>
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { FileText, ClipboardList, FileStack, Package, Receipt, Factory, type LucideIcon } from 'lucide-react';
|
||||
|
||||
const documents = [
|
||||
{
|
||||
@ -19,7 +18,7 @@ const documents = [
|
||||
{ name: 'B/L au porteur', desc: 'Propriété à celui qui le détient' },
|
||||
],
|
||||
importance: 'Critique',
|
||||
icon: FileText,
|
||||
icon: '📄',
|
||||
},
|
||||
{
|
||||
name: 'Sea Waybill',
|
||||
@ -30,7 +29,7 @@ const documents = [
|
||||
{ name: 'Express', desc: 'Libération rapide sans documents originaux' },
|
||||
],
|
||||
importance: 'Important',
|
||||
icon: ClipboardList,
|
||||
icon: '📋',
|
||||
},
|
||||
{
|
||||
name: 'Manifest',
|
||||
@ -41,7 +40,7 @@ const documents = [
|
||||
{ name: 'Freight Manifest', desc: 'Inclut les informations de fret' },
|
||||
],
|
||||
importance: 'Obligatoire',
|
||||
icon: FileStack,
|
||||
icon: '📑',
|
||||
},
|
||||
{
|
||||
name: 'Packing List',
|
||||
@ -52,7 +51,7 @@ const documents = [
|
||||
{ name: 'Détaillée', desc: 'Avec poids, dimensions, marquage' },
|
||||
],
|
||||
importance: 'Important',
|
||||
icon: Package,
|
||||
icon: '📦',
|
||||
},
|
||||
{
|
||||
name: 'Commercial Invoice',
|
||||
@ -63,7 +62,7 @@ const documents = [
|
||||
{ name: 'Définitive', desc: 'Document final de facturation' },
|
||||
],
|
||||
importance: 'Critique',
|
||||
icon: Receipt,
|
||||
icon: '🧾',
|
||||
},
|
||||
{
|
||||
name: 'Certificate of Origin',
|
||||
@ -75,7 +74,7 @@ const documents = [
|
||||
{ name: 'Non préférentiel', desc: 'Attestation simple d\'origine' },
|
||||
],
|
||||
importance: 'Selon destination',
|
||||
icon: Factory,
|
||||
icon: '🏭',
|
||||
},
|
||||
];
|
||||
|
||||
@ -121,7 +120,7 @@ export default function DocumentsTransportPage() {
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardList className="w-10 h-10 text-blue-600" />
|
||||
<span className="text-4xl">📋</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Documents de Transport Maritime</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
@ -150,7 +149,7 @@ export default function DocumentsTransportPage() {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<doc.icon className="w-6 h-6 text-blue-600" />
|
||||
<span className="text-2xl">{doc.icon}</span>
|
||||
<div>
|
||||
<span className="text-lg">{doc.name}</span>
|
||||
<span className="text-gray-500 text-sm ml-2">({doc.french})</span>
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { ShieldCheck, ClipboardList, FileText, DollarSign, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const regimesDouaniers = [
|
||||
{
|
||||
@ -98,7 +97,7 @@ export default function DouanesPage() {
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="w-10 h-10 text-blue-600" />
|
||||
<span className="text-4xl">🛃</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Procédures Douanières</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
@ -135,7 +134,7 @@ export default function DouanesPage() {
|
||||
|
||||
{/* Régimes douaniers */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Régimes Douaniers</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Régimes Douaniers</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{regimesDouaniers.map((regime) => (
|
||||
<Card key={regime.code} className="bg-white">
|
||||
@ -157,7 +156,7 @@ export default function DouanesPage() {
|
||||
|
||||
{/* Documents requis */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><FileText className="w-5 h-5" /> Documents Requis</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Requis</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
@ -180,7 +179,7 @@ export default function DouanesPage() {
|
||||
{/* Droits et taxes */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Droits et Taxes</h3>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">💰 Droits et Taxes</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Droits de douane</h4>
|
||||
@ -204,7 +203,7 @@ export default function DouanesPage() {
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Points d'Attention</h3>
|
||||
<h3 className="font-semibold text-amber-900 mb-3">⚠️ Points d'Attention</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Toujours vérifier le classement tarifaire avant l'importation</li>
|
||||
<li>Conserver tous les documents 3 ans minimum (contrôle a posteriori)</li>
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { ClipboardList, Hash, Package, FileText, Tag, Shuffle, Lightbulb, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const classesIMDG = [
|
||||
{
|
||||
@ -111,7 +110,7 @@ export default function IMDGPage() {
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-10 h-10 text-orange-500" />
|
||||
<span className="text-4xl">⚠️</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Marchandises Dangereuses (Code IMDG)</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
@ -124,7 +123,7 @@ export default function IMDGPage() {
|
||||
{/* Key Info */}
|
||||
<Card className="bg-red-50 border-red-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-red-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Responsabilités de l'Expéditeur</h3>
|
||||
<h3 className="font-semibold text-red-900 mb-3">⚠️ Responsabilités de l'Expéditeur</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-red-800">
|
||||
<li>Classer correctement la marchandise selon le Code IMDG</li>
|
||||
<li>Utiliser des emballages homologués UN</li>
|
||||
@ -137,7 +136,7 @@ export default function IMDGPage() {
|
||||
|
||||
{/* Classes */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Les 9 Classes de Danger</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Les 9 Classes de Danger</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{classesIMDG.map((cls) => (
|
||||
<Card key={cls.class} className="bg-white overflow-hidden">
|
||||
@ -172,7 +171,7 @@ export default function IMDGPage() {
|
||||
{/* UN Number */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Hash className="w-5 h-5" /> Numéro ONU (UN Number)</h3>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">🔢 Numéro ONU (UN Number)</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Chaque matière dangereuse est identifiée par un numéro ONU à 4 chiffres.
|
||||
Ce numéro permet de retrouver toutes les informations dans le Code IMDG.
|
||||
@ -196,7 +195,7 @@ export default function IMDGPage() {
|
||||
|
||||
{/* Packaging Groups */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Package className="w-5 h-5" /> Groupes d'Emballage</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📦 Groupes d'Emballage</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
@ -220,7 +219,7 @@ export default function IMDGPage() {
|
||||
|
||||
{/* Documents */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><FileText className="w-5 h-5" /> Documents Requis</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Requis</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-3">
|
||||
@ -241,7 +240,7 @@ export default function IMDGPage() {
|
||||
{/* Labeling */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Tag className="w-5 h-5" /> Marquage et Étiquetage</h3>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">🏷️ Marquage et Étiquetage</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Colis</h4>
|
||||
@ -268,7 +267,7 @@ export default function IMDGPage() {
|
||||
{/* Segregation */}
|
||||
<Card className="mt-8 bg-orange-50 border-orange-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-orange-900 mb-3 flex items-center gap-2"><Shuffle className="w-5 h-5" /> Ségrégation</h3>
|
||||
<h3 className="font-semibold text-orange-900 mb-3">🔀 Ségrégation</h3>
|
||||
<p className="text-orange-800 mb-3">
|
||||
Certaines classes de marchandises dangereuses ne peuvent pas être chargées ensemble.
|
||||
Le Code IMDG définit des règles strictes de ségrégation :
|
||||
@ -297,7 +296,7 @@ export default function IMDGPage() {
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
|
||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Vérifier l'acceptation par la compagnie maritime (certaines refusent certaines classes)</li>
|
||||
<li>Anticiper les surcharges DG (dangerous goods) qui peuvent être significatives</li>
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { ScrollText, Ship, ArrowUpFromLine, ArrowDownToLine } from 'lucide-react';
|
||||
|
||||
const incoterms = [
|
||||
{
|
||||
@ -120,7 +119,7 @@ export default function IncotermsPage() {
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<ScrollText className="w-10 h-10 text-blue-600" />
|
||||
<span className="text-4xl">📜</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Incoterms 2020</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
@ -147,11 +146,8 @@ export default function IncotermsPage() {
|
||||
{categories.map((category) => (
|
||||
<div key={category} className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
<span className="flex items-center gap-2">
|
||||
{category === 'Maritime' ? <><Ship className="w-5 h-5" /> Incoterms Maritimes</> :
|
||||
category === 'Départ' ? <><ArrowUpFromLine className="w-5 h-5" /> Incoterms de Départ</> :
|
||||
<><ArrowDownToLine className="w-5 h-5" /> Incoterms d'Arrivée</>}
|
||||
</span>
|
||||
{category === 'Maritime' ? '🚢 Incoterms Maritimes' :
|
||||
category === 'Départ' ? '📤 Incoterms de Départ' : '📥 Incoterms d\'Arrivée'}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{incoterms
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Package, Truck, Scale } from 'lucide-react';
|
||||
|
||||
export default function LclVsFclPage() {
|
||||
return (
|
||||
@ -27,7 +26,7 @@ export default function LclVsFclPage() {
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Scale className="w-10 h-10 text-blue-600" />
|
||||
<span className="text-4xl">⚖️</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">LCL vs FCL</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
@ -41,7 +40,7 @@ export default function LclVsFclPage() {
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-blue-900 flex items-center gap-2">
|
||||
<Package className="w-6 h-6" />
|
||||
<span className="text-2xl">📦</span>
|
||||
LCL - Groupage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@ -65,7 +64,7 @@ export default function LclVsFclPage() {
|
||||
<Card className="bg-green-50 border-green-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-green-900 flex items-center gap-2">
|
||||
<Truck className="w-6 h-6" />
|
||||
<span className="text-2xl">🚛</span>
|
||||
FCL - Complet
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { CreditCard, RefreshCw, Users, ClipboardList, FileText, Calendar, DollarSign, ScrollText, Lightbulb, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const typesLC = [
|
||||
{
|
||||
@ -94,7 +93,7 @@ export default function LettreCreditPage() {
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="w-10 h-10 text-blue-600" />
|
||||
<span className="text-4xl">💳</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Lettre de Crédit (L/C)</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
@ -107,7 +106,7 @@ export default function LettreCreditPage() {
|
||||
{/* How it works */}
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><RefreshCw className="w-5 h-5" /> Fonctionnement Simplifié</h3>
|
||||
<h3 className="font-semibold text-blue-900 mb-3">🔄 Fonctionnement Simplifié</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-2">
|
||||
{[
|
||||
{ step: '1', title: 'Demande', desc: 'Acheteur demande l\'ouverture à sa banque' },
|
||||
@ -130,7 +129,7 @@ export default function LettreCreditPage() {
|
||||
|
||||
{/* Parties */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Users className="w-5 h-5" /> Parties Impliquées</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">👥 Parties Impliquées</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
@ -150,7 +149,7 @@ export default function LettreCreditPage() {
|
||||
|
||||
{/* Types */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Types de Lettres de Crédit</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Types de Lettres de Crédit</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{typesLC.map((lc) => (
|
||||
<Card key={lc.type} className={`bg-white ${lc.recommended ? 'border-green-300 border-2' : ''}`}>
|
||||
@ -173,7 +172,7 @@ export default function LettreCreditPage() {
|
||||
|
||||
{/* Documents */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><FileText className="w-5 h-5" /> Documents Typiquement Requis</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Typiquement Requis</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@ -193,7 +192,7 @@ export default function LettreCreditPage() {
|
||||
{/* Key Dates */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Calendar className="w-5 h-5" /> Dates Clés à Surveiller</h3>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">📅 Dates Clés à Surveiller</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Date d'expédition</h4>
|
||||
@ -220,7 +219,7 @@ export default function LettreCreditPage() {
|
||||
{/* Costs */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Coûts Typiques</h3>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">💰 Coûts Typiques</h3>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
@ -250,7 +249,7 @@ export default function LettreCreditPage() {
|
||||
{/* Common Errors */}
|
||||
<Card className="mt-8 bg-red-50 border-red-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-red-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Erreurs Fréquentes (Réserves)</h3>
|
||||
<h3 className="font-semibold text-red-900 mb-3">⚠️ Erreurs Fréquentes (Réserves)</h3>
|
||||
<p className="text-red-800 mb-3">
|
||||
Ces erreurs entraînent des "réserves" de la banque et peuvent bloquer le paiement :
|
||||
</p>
|
||||
@ -265,7 +264,7 @@ export default function LettreCreditPage() {
|
||||
{/* UCP 600 */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><ScrollText className="w-5 h-5" /> Règles UCP 600</h3>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">📜 Règles UCP 600</h3>
|
||||
<p className="text-gray-600">
|
||||
Les Règles et Usances Uniformes (UCP 600) de la Chambre de Commerce Internationale (CCI)
|
||||
régissent les lettres de crédit documentaires depuis 2007. Points clés :
|
||||
@ -282,7 +281,7 @@ export default function LettreCreditPage() {
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
|
||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Vérifier minutieusement les termes de la L/C dès réception</li>
|
||||
<li>Demander des modifications AVANT expédition si nécessaire</li>
|
||||
|
||||
@ -6,112 +6,89 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
ScrollText,
|
||||
ClipboardList,
|
||||
Package,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
Shield,
|
||||
Calculator,
|
||||
Globe,
|
||||
Anchor,
|
||||
AlertTriangle,
|
||||
CreditCard,
|
||||
Timer,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WikiTopic {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
href: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const wikiTopics: WikiTopic[] = [
|
||||
const wikiTopics = [
|
||||
{
|
||||
title: 'Incoterms 2020',
|
||||
description: 'Les règles internationales pour l\'interprétation des termes commerciaux',
|
||||
icon: ScrollText,
|
||||
icon: '📜',
|
||||
href: '/dashboard/wiki/incoterms',
|
||||
tags: ['FOB', 'CIF', 'EXW', 'DDP'],
|
||||
},
|
||||
{
|
||||
title: 'Documents de Transport',
|
||||
description: 'Les documents essentiels pour le transport maritime',
|
||||
icon: ClipboardList,
|
||||
icon: '📋',
|
||||
href: '/dashboard/wiki/documents-transport',
|
||||
tags: ['B/L', 'Sea Waybill', 'Manifest'],
|
||||
},
|
||||
{
|
||||
title: 'Conteneurs et Types de Cargo',
|
||||
description: 'Guide complet des types de conteneurs maritimes',
|
||||
icon: Package,
|
||||
icon: '📦',
|
||||
href: '/dashboard/wiki/conteneurs',
|
||||
tags: ['20\'', '40\'', 'Reefer', 'Open Top'],
|
||||
},
|
||||
{
|
||||
title: 'LCL vs FCL',
|
||||
description: 'Différences entre groupage et conteneur complet',
|
||||
icon: Scale,
|
||||
icon: '⚖️',
|
||||
href: '/dashboard/wiki/lcl-vs-fcl',
|
||||
tags: ['Groupage', 'Complet', 'Coûts'],
|
||||
},
|
||||
{
|
||||
title: 'Procédures Douanières',
|
||||
description: 'Guide des formalités douanières import/export',
|
||||
icon: ShieldCheck,
|
||||
icon: '🛃',
|
||||
href: '/dashboard/wiki/douanes',
|
||||
tags: ['Déclaration', 'Tarifs', 'Régimes'],
|
||||
},
|
||||
{
|
||||
title: 'Assurance Maritime',
|
||||
description: 'Protection des marchandises en transit',
|
||||
icon: Shield,
|
||||
icon: '🛡️',
|
||||
href: '/dashboard/wiki/assurance',
|
||||
tags: ['ICC A', 'ICC B', 'ICC C'],
|
||||
},
|
||||
{
|
||||
title: 'Calcul du Fret Maritime',
|
||||
description: 'Comment sont calculés les coûts de transport',
|
||||
icon: Calculator,
|
||||
icon: '🧮',
|
||||
href: '/dashboard/wiki/calcul-fret',
|
||||
tags: ['CBM', 'THC', 'BAF', 'CAF'],
|
||||
},
|
||||
{
|
||||
title: 'Ports et Routes Maritimes',
|
||||
description: 'Les principales routes commerciales mondiales',
|
||||
icon: Globe,
|
||||
icon: '🌍',
|
||||
href: '/dashboard/wiki/ports-routes',
|
||||
tags: ['Hub', 'Détroits', 'Canaux'],
|
||||
},
|
||||
{
|
||||
title: 'VGM (Verified Gross Mass)',
|
||||
description: 'Obligation de pesée des conteneurs (SOLAS)',
|
||||
icon: Anchor,
|
||||
icon: '⚓',
|
||||
href: '/dashboard/wiki/vgm',
|
||||
tags: ['SOLAS', 'Pesée', 'Certification'],
|
||||
},
|
||||
{
|
||||
title: 'Marchandises Dangereuses (IMDG)',
|
||||
description: 'Transport de matières dangereuses par mer',
|
||||
icon: AlertTriangle,
|
||||
icon: '⚠️',
|
||||
href: '/dashboard/wiki/imdg',
|
||||
tags: ['Classes', 'Étiquetage', 'Sécurité'],
|
||||
},
|
||||
{
|
||||
title: 'Lettre de Crédit (L/C)',
|
||||
description: 'Instrument de paiement international sécurisé',
|
||||
icon: CreditCard,
|
||||
icon: '💳',
|
||||
href: '/dashboard/wiki/lettre-credit',
|
||||
tags: ['Banque', 'Paiement', 'Sécurité'],
|
||||
},
|
||||
{
|
||||
title: 'Transit Time et Délais',
|
||||
description: 'Comprendre les délais en transport maritime',
|
||||
icon: Timer,
|
||||
icon: '⏱️',
|
||||
href: '/dashboard/wiki/transit-time',
|
||||
tags: ['Cut-off', 'Free time', 'Demurrage'],
|
||||
},
|
||||
@ -130,16 +107,12 @@ export default function WikiPage() {
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{wikiTopics.map((topic) => {
|
||||
const IconComponent = topic.icon;
|
||||
return (
|
||||
{wikiTopics.map((topic) => (
|
||||
<Link key={topic.href} href={topic.href} className="block group">
|
||||
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-blue-300 bg-white">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="h-10 w-10 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<IconComponent className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-4xl">{topic.icon}</span>
|
||||
</div>
|
||||
<CardTitle className="text-xl mt-3 group-hover:text-blue-600 transition-colors">
|
||||
{topic.title}
|
||||
@ -162,8 +135,7 @@ export default function WikiPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer info */}
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Globe, BarChart3, Ship, Trophy, RefreshCw, Lightbulb, Anchor } from 'lucide-react';
|
||||
|
||||
const majorRoutes = [
|
||||
{
|
||||
@ -114,7 +113,7 @@ export default function PortsRoutesPage() {
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-10 h-10 text-blue-600" />
|
||||
<span className="text-4xl">🌍</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Ports et Routes Maritimes</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
@ -126,7 +125,7 @@ export default function PortsRoutesPage() {
|
||||
{/* Key Stats */}
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><BarChart3 className="w-5 h-5" /> Chiffres Clés du Maritime Mondial</h3>
|
||||
<h3 className="font-semibold text-blue-900 mb-3">📊 Chiffres Clés du Maritime Mondial</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-blue-700">80%</p>
|
||||
@ -150,7 +149,7 @@ export default function PortsRoutesPage() {
|
||||
|
||||
{/* Major Routes */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Ship className="w-5 h-5" /> Routes Commerciales Majeures</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">🛳️ Routes Commerciales Majeures</h2>
|
||||
<div className="space-y-4">
|
||||
{majorRoutes.map((route) => (
|
||||
<Card key={route.name} className="bg-white">
|
||||
@ -185,7 +184,7 @@ export default function PortsRoutesPage() {
|
||||
|
||||
{/* Strategic Passages */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Anchor className="w-5 h-5" /> Passages Stratégiques</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">⚓ Passages Stratégiques</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{strategicPassages.map((passage) => (
|
||||
<Card key={passage.name} className="bg-white">
|
||||
@ -223,7 +222,7 @@ export default function PortsRoutesPage() {
|
||||
|
||||
{/* Top Ports */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Trophy className="w-5 h-5" /> Top 10 Ports Mondiaux (TEU)</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">🏆 Top 10 Ports Mondiaux (TEU)</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="overflow-x-auto">
|
||||
@ -262,7 +261,7 @@ export default function PortsRoutesPage() {
|
||||
{/* Hub Ports Info */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><RefreshCw className="w-5 h-5" /> Ports Hub vs Ports Régionaux</h3>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">🔄 Ports Hub vs Ports Régionaux</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Ports Hub (Transbordement)</h4>
|
||||
@ -287,7 +286,7 @@ export default function PortsRoutesPage() {
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
|
||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Privilégiez les routes directes pour réduire les délais et risques</li>
|
||||
<li>Anticipez les congestions portuaires (Los Angeles, Rotterdam en haute saison)</li>
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { BookOpen, Calendar, Ship, DollarSign, RefreshCw, Lightbulb, Clock, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const etapesTimeline = [
|
||||
{
|
||||
@ -133,7 +132,7 @@ export default function TransitTimePage() {
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="w-10 h-10 text-blue-600" />
|
||||
<span className="text-4xl">⏱️</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Transit Time et Délais</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
@ -146,7 +145,7 @@ export default function TransitTimePage() {
|
||||
{/* Key Terms */}
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><BookOpen className="w-5 h-5" /> Termes Clés</h3>
|
||||
<h3 className="font-semibold text-blue-900 mb-3">📖 Termes Clés</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800">ETD</h4>
|
||||
@ -170,7 +169,7 @@ export default function TransitTimePage() {
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Calendar className="w-5 h-5" /> Timeline d'une Expédition FCL</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📅 Timeline d'une Expédition FCL</h2>
|
||||
<div className="space-y-3">
|
||||
{etapesTimeline.map((item, index) => (
|
||||
<Card key={item.etape} className={`bg-white ${index === 5 || index === 7 ? 'border-blue-300 border-2' : ''}`}>
|
||||
@ -196,7 +195,7 @@ export default function TransitTimePage() {
|
||||
|
||||
{/* Transit Times */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Ship className="w-5 h-5" /> Transit Times Indicatifs</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">🚢 Transit Times Indicatifs</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="overflow-x-auto">
|
||||
@ -229,7 +228,7 @@ export default function TransitTimePage() {
|
||||
{/* Free Time */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Clock className="w-5 h-5" /> Free Time (Jours Gratuits)</h3>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">⏰ Free Time (Jours Gratuits)</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Période pendant laquelle le conteneur peut rester au terminal ou chez l'importateur
|
||||
sans frais supplémentaires.
|
||||
@ -258,7 +257,7 @@ export default function TransitTimePage() {
|
||||
|
||||
{/* Late Fees */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Frais de Retard</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">💸 Frais de Retard</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{fraisRetard.map((frais) => (
|
||||
<Card key={frais.nom} className="bg-white border-red-200">
|
||||
@ -284,7 +283,7 @@ export default function TransitTimePage() {
|
||||
{/* Factors affecting transit */}
|
||||
<Card className="mt-8 bg-orange-50 border-orange-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-orange-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Facteurs Impactant les Délais</h3>
|
||||
<h3 className="font-semibold text-orange-900 mb-3">⚡ Facteurs Impactant les Délais</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-orange-800">Retards potentiels</h4>
|
||||
@ -313,7 +312,7 @@ export default function TransitTimePage() {
|
||||
{/* Roll-over */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><RefreshCw className="w-5 h-5" /> Roll-over (Report)</h3>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">🔄 Roll-over (Report)</h3>
|
||||
<p className="text-gray-600 mb-3">
|
||||
Situation où un conteneur n'est pas chargé sur le navire prévu et est reporté
|
||||
sur le prochain départ.
|
||||
@ -337,7 +336,7 @@ export default function TransitTimePage() {
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils pour Optimiser les Délais</h3>
|
||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils pour Optimiser les Délais</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Réserver tôt, surtout en haute saison (2-3 semaines d'avance)</li>
|
||||
<li>Respecter les cut-off avec une marge de sécurité (24h minimum)</li>
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Shield, Construction, Truck, ClipboardList, Microscope, User, Ruler, Lightbulb, Anchor, Scale, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const methodesPesee = [
|
||||
{
|
||||
@ -70,7 +69,7 @@ export default function VGMPage() {
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Anchor className="w-10 h-10 text-blue-600" />
|
||||
<span className="text-4xl">⚓</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">VGM (Verified Gross Mass)</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
@ -86,19 +85,19 @@ export default function VGMPage() {
|
||||
<h3 className="font-semibold text-blue-900 mb-3">Pourquoi le VGM ?</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800">
|
||||
<div>
|
||||
<h4 className="font-medium flex items-center gap-1"><Shield className="w-4 h-4" /> Sécurité</h4>
|
||||
<h4 className="font-medium">🛡️ Sécurité</h4>
|
||||
<p className="text-sm">Les conteneurs mal déclarés causent des accidents graves (chute de conteneurs, navires instables).</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium flex items-center gap-1"><Scale className="w-4 h-4" /> Stabilité du navire</h4>
|
||||
<h4 className="font-medium">⚖️ Stabilité du navire</h4>
|
||||
<p className="text-sm">Le capitaine doit connaître le poids exact pour calculer le plan de chargement.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium flex items-center gap-1"><Construction className="w-4 h-4" /> Équipements portuaires</h4>
|
||||
<h4 className="font-medium">🏗️ Équipements portuaires</h4>
|
||||
<p className="text-sm">Les grues et portiques sont dimensionnés pour des charges maximales.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium flex items-center gap-1"><Truck className="w-4 h-4" /> Transport terrestre</h4>
|
||||
<h4 className="font-medium">🚛 Transport terrestre</h4>
|
||||
<p className="text-sm">Évite les surcharges sur les camions et wagons de pré/post-acheminement.</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -107,7 +106,7 @@ export default function VGMPage() {
|
||||
|
||||
{/* VGM Components */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Composants du VGM</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Composants du VGM</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="bg-gray-50 p-4 rounded-lg border mb-4">
|
||||
@ -132,7 +131,7 @@ export default function VGMPage() {
|
||||
|
||||
{/* Methods */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Microscope className="w-5 h-5" /> Méthodes de Détermination</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">🔬 Méthodes de Détermination</h2>
|
||||
<div className="space-y-4">
|
||||
{methodesPesee.map((method) => (
|
||||
<Card key={method.method} className="bg-white">
|
||||
@ -187,7 +186,7 @@ export default function VGMPage() {
|
||||
{/* Responsibility */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><User className="w-5 h-5" /> Responsabilités</h3>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">👤 Responsabilités</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Expéditeur (Shipper)</h4>
|
||||
@ -214,7 +213,7 @@ export default function VGMPage() {
|
||||
{/* Tolerances */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Ruler className="w-5 h-5" /> Tolérances</h3>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">📏 Tolérances</h3>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<p className="text-gray-600 mb-3">
|
||||
Les tolérances varient selon les compagnies maritimes et les ports, mais généralement :
|
||||
@ -235,7 +234,7 @@ export default function VGMPage() {
|
||||
|
||||
{/* Sanctions */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Sanctions par Région</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">⚠️ Sanctions par Région</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-3">
|
||||
@ -253,7 +252,7 @@ export default function VGMPage() {
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Bonnes Pratiques</h3>
|
||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Bonnes Pratiques</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Transmettre le VGM au moins 24-48h avant le cut-off</li>
|
||||
<li>Utiliser des balances étalonnées et certifiées</li>
|
||||
|
||||
@ -13,66 +13,6 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useAuth } from '@/lib/context/auth-context';
|
||||
|
||||
interface FieldErrors {
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// Map backend error messages to French user-friendly messages
|
||||
function getErrorMessage(error: any): { message: string; field?: 'email' | 'password' | 'general' } {
|
||||
const errorMessage = error?.message || error?.response?.message || '';
|
||||
|
||||
// Network or server errors
|
||||
if (error?.name === 'TypeError' || errorMessage.includes('fetch')) {
|
||||
return {
|
||||
message: 'Impossible de se connecter au serveur. Vérifiez votre connexion internet.',
|
||||
field: 'general'
|
||||
};
|
||||
}
|
||||
|
||||
// Backend error messages
|
||||
if (errorMessage.includes('Invalid credentials') || errorMessage.includes('Identifiants')) {
|
||||
return {
|
||||
message: 'Email ou mot de passe incorrect',
|
||||
field: 'general'
|
||||
};
|
||||
}
|
||||
|
||||
if (errorMessage.includes('inactive') || errorMessage.includes('désactivé')) {
|
||||
return {
|
||||
message: 'Votre compte a été désactivé. Contactez le support pour plus d\'informations.',
|
||||
field: 'general'
|
||||
};
|
||||
}
|
||||
|
||||
if (errorMessage.includes('not found') || errorMessage.includes('introuvable')) {
|
||||
return {
|
||||
message: 'Aucun compte trouvé avec cet email',
|
||||
field: 'email'
|
||||
};
|
||||
}
|
||||
|
||||
if (errorMessage.includes('password') || errorMessage.includes('mot de passe')) {
|
||||
return {
|
||||
message: 'Mot de passe incorrect',
|
||||
field: 'password'
|
||||
};
|
||||
}
|
||||
|
||||
if (errorMessage.includes('Too many') || errorMessage.includes('rate limit')) {
|
||||
return {
|
||||
message: 'Trop de tentatives de connexion. Veuillez réessayer dans quelques minutes.',
|
||||
field: 'general'
|
||||
};
|
||||
}
|
||||
|
||||
// Default error
|
||||
return {
|
||||
message: errorMessage || 'Une erreur est survenue. Veuillez réessayer.',
|
||||
field: 'general'
|
||||
};
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
@ -80,64 +20,17 @@ export default function LoginPage() {
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
|
||||
// Validate form fields
|
||||
const validateForm = (): boolean => {
|
||||
const errors: FieldErrors = {};
|
||||
|
||||
// Email validation
|
||||
if (!email.trim()) {
|
||||
errors.email = 'L\'adresse email est requise';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.email = 'L\'adresse email n\'est pas valide';
|
||||
}
|
||||
|
||||
// Password validation
|
||||
if (!password) {
|
||||
errors.password = 'Le mot de passe est requis';
|
||||
} else if (password.length < 6) {
|
||||
errors.password = 'Le mot de passe doit contenir au moins 6 caractères';
|
||||
}
|
||||
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
// Handle input changes - keep errors visible until successful login
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.target.value);
|
||||
};
|
||||
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(e.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setFieldErrors({});
|
||||
|
||||
// Validate form before submission
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
// Navigation is handled by the login function in auth context
|
||||
} catch (err: any) {
|
||||
const { message, field } = getErrorMessage(err);
|
||||
|
||||
if (field === 'email') {
|
||||
setFieldErrors({ email: message });
|
||||
} else if (field === 'password') {
|
||||
setFieldErrors({ password: message });
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
setError(err.message || 'Identifiants incorrects');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -172,20 +65,7 @@ export default function LoginPage() {
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-body-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
@ -194,74 +74,38 @@ export default function LoginPage() {
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className={`label ${fieldErrors.email ? 'text-red-600' : ''}`}>
|
||||
<label htmlFor="email" className="label">
|
||||
Adresse email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={`input w-full ${
|
||||
fieldErrors.email
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500 bg-red-50'
|
||||
: ''
|
||||
}`}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="input w-full"
|
||||
placeholder="votre.email@entreprise.com"
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
aria-invalid={!!fieldErrors.email}
|
||||
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<p id="email-error" className="mt-1.5 text-body-sm text-red-600 flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{fieldErrors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="password" className={`label ${fieldErrors.password ? 'text-red-600' : ''}`}>
|
||||
<label htmlFor="password" className="label">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
className={`input w-full ${
|
||||
fieldErrors.password
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500 bg-red-50'
|
||||
: ''
|
||||
}`}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="input w-full"
|
||||
placeholder="••••••••••"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
aria-invalid={!!fieldErrors.password}
|
||||
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<p id="password-error" className="mt-1.5 text-body-sm text-red-600 flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{fieldErrors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
|
||||
@ -1,221 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { Ship, Home, ArrowRight, LayoutDashboard, Anchor } from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
|
||||
export default function NotFound() {
|
||||
const heroRef = useRef(null);
|
||||
const isHeroInView = useInView(heroRef, { once: true });
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col">
|
||||
<LandingHeader transparentOnTop={true} />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section
|
||||
ref={heroRef}
|
||||
className="relative flex-1 flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-brand-navy via-brand-navy/95 to-brand-navy/90">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-10 left-10 w-72 h-72 lg:w-96 lg:h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-32 right-10 w-72 h-72 lg:w-96 lg:h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-brand-turquoise/50 rounded-full blur-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 max-w-4xl mx-auto px-6 text-center pt-32 pb-40">
|
||||
{/* Badge */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-10 border border-white/20"
|
||||
>
|
||||
<Anchor className="w-5 h-5 text-brand-turquoise" />
|
||||
<span className="text-white/90 text-sm font-medium font-heading">
|
||||
Erreur 404
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Animated Ship + Waves illustration */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={isHeroInView ? { opacity: 1, scale: 1 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
className="relative mb-8 flex justify-center"
|
||||
>
|
||||
<div className="relative w-64 h-40 lg:w-80 lg:h-48">
|
||||
{/* Ship */}
|
||||
<svg
|
||||
viewBox="0 0 200 120"
|
||||
className="absolute inset-0 w-full h-full animate-float"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* Hull */}
|
||||
<path
|
||||
d="M40 75 L55 95 L145 95 L160 75 Z"
|
||||
fill="#34CCCD"
|
||||
opacity="0.9"
|
||||
/>
|
||||
{/* Deck */}
|
||||
<rect x="60" y="58" width="80" height="17" rx="2" fill="white" opacity="0.9" />
|
||||
{/* Bridge */}
|
||||
<rect x="85" y="35" width="35" height="23" rx="2" fill="white" opacity="0.85" />
|
||||
{/* Window */}
|
||||
<rect x="92" y="40" width="8" height="6" rx="1" fill="#34CCCD" opacity="0.7" />
|
||||
<rect x="105" y="40" width="8" height="6" rx="1" fill="#34CCCD" opacity="0.7" />
|
||||
{/* Smokestack */}
|
||||
<rect x="95" y="22" width="12" height="13" rx="1" fill="#10183A" opacity="0.8" />
|
||||
<rect x="95" y="22" width="12" height="4" rx="1" fill="#34CCCD" opacity="0.6" />
|
||||
{/* Mast */}
|
||||
<line x1="102" y1="10" x2="102" y2="22" stroke="white" strokeWidth="1.5" opacity="0.7" />
|
||||
{/* Flag */}
|
||||
<path d="M102 10 L115 15 L102 20" fill="#34CCCD" opacity="0.8" />
|
||||
{/* Containers on deck */}
|
||||
<rect x="65" y="60" width="12" height="10" rx="1" fill="#067224" opacity="0.7" />
|
||||
<rect x="79" y="60" width="12" height="10" rx="1" fill="#34CCCD" opacity="0.5" />
|
||||
<rect x="130" y="60" width="12" height="10" rx="1" fill="#067224" opacity="0.5" />
|
||||
<rect x="144" y="60" width="10" height="10" rx="1" fill="white" opacity="0.3" />
|
||||
</svg>
|
||||
|
||||
{/* Waves layer 1 */}
|
||||
<svg
|
||||
viewBox="0 0 400 30"
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[150%] animate-wave"
|
||||
preserveAspectRatio="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0,15 C50,5 100,25 150,15 C200,5 250,25 300,15 C350,5 400,25 400,15 L400,30 L0,30 Z"
|
||||
fill="#34CCCD"
|
||||
opacity="0.3"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Waves layer 2 */}
|
||||
<svg
|
||||
viewBox="0 0 400 30"
|
||||
className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-[160%] animate-wave-slow"
|
||||
preserveAspectRatio="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0,18 C60,8 120,28 180,18 C240,8 300,28 360,18 L400,18 L400,30 L0,30 Z"
|
||||
fill="#34CCCD"
|
||||
opacity="0.15"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 404 */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="text-8xl lg:text-[12rem] font-bold text-white mb-2 leading-none font-heading tracking-tight"
|
||||
>
|
||||
404
|
||||
</motion.h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
className="text-3xl lg:text-5xl font-bold mb-6 font-heading"
|
||||
>
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||
Page introuvable
|
||||
</span>
|
||||
</motion.h2>
|
||||
|
||||
{/* Description */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
className="text-lg lg:text-xl text-white/70 mb-12 max-w-2xl mx-auto leading-relaxed font-body"
|
||||
>
|
||||
Ce navire a pris le large... La page que vous cherchez
|
||||
n'existe pas ou a été déplacée.
|
||||
</motion.p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.7 }}
|
||||
className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2 font-heading"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
<span>Retour à l'accueil</span>
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="group px-8 py-4 bg-white/10 backdrop-blur-sm text-white border border-white/20 rounded-lg hover:bg-white/20 transition-all hover:shadow-xl font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2 font-heading"
|
||||
>
|
||||
<LayoutDashboard className="w-5 h-5" />
|
||||
<span>Tableau de bord</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Bottom wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg
|
||||
className="w-full h-16 lg:h-24"
|
||||
viewBox="0 0 1440 120"
|
||||
preserveAspectRatio="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0,60 C240,90 480,30 720,60 C960,90 1200,30 1440,60 L1440,120 L0,120 Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LandingFooter />
|
||||
|
||||
<style jsx global>{`
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
25% { transform: translateY(-6px) rotate(1deg); }
|
||||
75% { transform: translateY(4px) rotate(-1deg); }
|
||||
}
|
||||
@keyframes wave {
|
||||
0% { transform: translateX(-50%) translateX(0); }
|
||||
100% { transform: translateX(-50%) translateX(-50px); }
|
||||
}
|
||||
@keyframes wave-slow {
|
||||
0% { transform: translateX(-50%) translateX(0); }
|
||||
100% { transform: translateX(-50%) translateX(50px); }
|
||||
}
|
||||
.animate-float {
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
.animate-wave {
|
||||
animation: wave 3s ease-in-out infinite alternate;
|
||||
}
|
||||
.animate-wave-slow {
|
||||
animation: wave-slow 4s ease-in-out infinite alternate;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -52,7 +52,7 @@ export default function LandingPage() {
|
||||
const features = [
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Tableau de bord',
|
||||
title: 'Dashboard Analytics',
|
||||
description:
|
||||
'Suivez tous vos KPIs en temps réel : bookings, volumes, revenus et alertes personnalisées.',
|
||||
color: 'from-blue-500 to-cyan-500',
|
||||
@ -60,7 +60,7 @@ export default function LandingPage() {
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
title: 'Gestion des Réservations',
|
||||
title: 'Gestion des Bookings',
|
||||
description: 'Créez, gérez et suivez vos réservations maritimes LCL/FCL avec un historique complet.',
|
||||
color: 'from-purple-500 to-pink-500',
|
||||
link: '/dashboard/bookings',
|
||||
@ -74,7 +74,7 @@ export default function LandingPage() {
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
title: 'Suivi des expéditions',
|
||||
title: 'Track & Trace',
|
||||
description:
|
||||
'Suivez vos conteneurs en temps réel auprès de 10+ transporteurs majeurs (Maersk, MSC, CMA CGM...).',
|
||||
color: 'from-green-500 to-emerald-500',
|
||||
@ -101,13 +101,13 @@ export default function LandingPage() {
|
||||
const tools = [
|
||||
{
|
||||
icon: LayoutDashboard,
|
||||
title: 'Tableau de bord',
|
||||
title: 'Dashboard',
|
||||
description: 'Vue d\'ensemble de votre activité maritime',
|
||||
link: '/dashboard',
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
title: 'Mes Réservations',
|
||||
title: 'Mes Bookings',
|
||||
description: 'Gérez toutes vos réservations en un seul endroit',
|
||||
link: '/dashboard/bookings',
|
||||
},
|
||||
@ -119,7 +119,7 @@ export default function LandingPage() {
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
title: 'Suivi des expéditions',
|
||||
title: 'Track & Trace',
|
||||
description: 'Suivez vos conteneurs en temps réel',
|
||||
link: '/dashboard/track-trace',
|
||||
},
|
||||
@ -158,7 +158,7 @@ export default function LandingPage() {
|
||||
{ text: 'Support par email', included: true },
|
||||
{ text: 'Gestion des documents', included: false },
|
||||
{ text: 'Notifications temps réel', included: false },
|
||||
{ text: 'Accès API', included: false },
|
||||
{ text: 'API access', included: false },
|
||||
],
|
||||
cta: 'Commencer gratuitement',
|
||||
highlighted: false,
|
||||
@ -176,7 +176,7 @@ export default function LandingPage() {
|
||||
{ text: 'Support prioritaire', included: true },
|
||||
{ text: 'Gestion des documents', included: true },
|
||||
{ text: 'Notifications temps réel', included: true },
|
||||
{ text: 'Accès API', included: false },
|
||||
{ text: 'API access', included: false },
|
||||
],
|
||||
cta: 'Essai gratuit 14 jours',
|
||||
highlighted: true,
|
||||
@ -187,10 +187,10 @@ export default function LandingPage() {
|
||||
period: '',
|
||||
description: 'Pour les grandes entreprises',
|
||||
features: [
|
||||
{ text: 'Tout Professionnel +', included: true },
|
||||
{ text: 'Accès API complet', included: true },
|
||||
{ text: 'Tout Professional +', included: true },
|
||||
{ text: 'API access complet', included: true },
|
||||
{ text: 'Intégrations personnalisées', included: true },
|
||||
{ text: 'Responsable de compte dédié', included: true },
|
||||
{ text: 'Account manager dédié', included: true },
|
||||
{ text: 'SLA garanti 99.9%', included: true },
|
||||
{ text: 'Formation sur site', included: true },
|
||||
{ text: 'Multi-organisations', included: true },
|
||||
@ -323,7 +323,7 @@ export default function LandingPage() {
|
||||
href="/dashboard"
|
||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
>
|
||||
<span>Accéder au tableau de bord</span>
|
||||
<span>Accéder au Dashboard</span>
|
||||
<LayoutDashboard className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
) : (
|
||||
@ -709,13 +709,13 @@ export default function LandingPage() {
|
||||
{
|
||||
step: '03',
|
||||
title: 'Réservez',
|
||||
description: 'Confirmez votre réservation en un clic',
|
||||
description: 'Confirmez votre booking en un clic',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
step: '04',
|
||||
title: 'Suivez',
|
||||
description: 'Suivez votre envoi en temps réel',
|
||||
description: 'Trackez votre envoi en temps réel',
|
||||
icon: Container,
|
||||
},
|
||||
].map((step, index) => {
|
||||
@ -833,7 +833,7 @@ export default function LandingPage() {
|
||||
href="/dashboard"
|
||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
>
|
||||
<span>Accéder au tableau de bord</span>
|
||||
<span>Accéder au Dashboard</span>
|
||||
<LayoutDashboard className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@ -1,279 +1,265 @@
|
||||
/**
|
||||
* Cookie Consent Banner
|
||||
* GDPR Compliant - French version
|
||||
* GDPR Compliant
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Cookie, X, Settings, Check, Shield } from 'lucide-react';
|
||||
import { useCookieConsent } from '@/lib/context/cookie-context';
|
||||
import type { CookiePreferences } from '@/lib/api/gdpr';
|
||||
|
||||
interface CookiePreferences {
|
||||
essential: boolean; // Always true (required for functionality)
|
||||
functional: boolean;
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
}
|
||||
|
||||
export default function CookieConsent() {
|
||||
const {
|
||||
preferences,
|
||||
showBanner,
|
||||
showSettings,
|
||||
isLoading,
|
||||
setShowBanner,
|
||||
setShowSettings,
|
||||
acceptAll,
|
||||
acceptEssentialOnly,
|
||||
savePreferences,
|
||||
openPreferences,
|
||||
} = useCookieConsent();
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [preferences, setPreferences] = useState<CookiePreferences>({
|
||||
essential: true,
|
||||
functional: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
});
|
||||
|
||||
const [localPrefs, setLocalPrefs] = useState<CookiePreferences>(preferences);
|
||||
useEffect(() => {
|
||||
// Check if user has already made a choice
|
||||
const consent = localStorage.getItem('cookieConsent');
|
||||
if (!consent) {
|
||||
setShowBanner(true);
|
||||
} else {
|
||||
const savedPreferences = JSON.parse(consent);
|
||||
setPreferences(savedPreferences);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync local prefs when context changes
|
||||
React.useEffect(() => {
|
||||
setLocalPrefs(preferences);
|
||||
}, [preferences]);
|
||||
|
||||
const handleSaveCustom = async () => {
|
||||
await savePreferences(localPrefs);
|
||||
const acceptAll = () => {
|
||||
const allAccepted: CookiePreferences = {
|
||||
essential: true,
|
||||
functional: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
};
|
||||
savePreferences(allAccepted);
|
||||
};
|
||||
|
||||
// Don't render anything while loading
|
||||
if (isLoading) {
|
||||
const acceptEssentialOnly = () => {
|
||||
const essentialOnly: CookiePreferences = {
|
||||
essential: true,
|
||||
functional: false,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
};
|
||||
savePreferences(essentialOnly);
|
||||
};
|
||||
|
||||
const saveCustomPreferences = () => {
|
||||
savePreferences(preferences);
|
||||
};
|
||||
|
||||
const savePreferences = (prefs: CookiePreferences) => {
|
||||
localStorage.setItem('cookieConsent', JSON.stringify(prefs));
|
||||
localStorage.setItem('cookieConsentDate', new Date().toISOString());
|
||||
setShowBanner(false);
|
||||
setShowSettings(false);
|
||||
|
||||
// Apply preferences
|
||||
applyPreferences(prefs);
|
||||
};
|
||||
|
||||
const applyPreferences = (prefs: CookiePreferences) => {
|
||||
// Enable/disable analytics tracking
|
||||
if (prefs.analytics) {
|
||||
// Enable Google Analytics, Sentry, etc.
|
||||
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||
(window as any).gtag('consent', 'update', {
|
||||
analytics_storage: 'granted',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||
(window as any).gtag('consent', 'update', {
|
||||
analytics_storage: 'denied',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Enable/disable marketing tracking
|
||||
if (prefs.marketing) {
|
||||
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||
(window as any).gtag('consent', 'update', {
|
||||
ad_storage: 'granted',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||
(window as any).gtag('consent', 'update', {
|
||||
ad_storage: 'denied',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!showBanner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Cookie Button (shown when banner is closed) */}
|
||||
<AnimatePresence>
|
||||
{!showBanner && (
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
|
||||
onClick={openPreferences}
|
||||
className="fixed bottom-4 left-4 z-40 p-3 bg-brand-navy text-white rounded-full shadow-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-brand-turquoise focus:ring-offset-2 transition-colors"
|
||||
aria-label="Ouvrir les paramètres de cookies"
|
||||
>
|
||||
<Cookie className="w-5 h-5" />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Cookie Banner */}
|
||||
<AnimatePresence>
|
||||
{showBanner && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 shadow-2xl"
|
||||
>
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t-2 border-gray-200 shadow-2xl">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<AnimatePresence mode="wait">
|
||||
{!showSettings ? (
|
||||
// Simple banner
|
||||
<motion.div
|
||||
key="simple"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cookie className="w-5 h-5 text-brand-navy" />
|
||||
<h3 className="text-lg font-semibold text-brand-navy">
|
||||
Nous utilisons des cookies
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 max-w-2xl">
|
||||
Nous utilisons des cookies pour améliorer votre expérience, analyser le
|
||||
trafic du site et personnaliser le contenu. En cliquant sur « Tout
|
||||
accepter », vous consentez à notre utilisation des cookies.{' '}
|
||||
<Link
|
||||
href="/cookies"
|
||||
className="text-brand-turquoise hover:text-brand-turquoise/80 underline"
|
||||
>
|
||||
En savoir plus
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">🍪 We use cookies</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
We use cookies to improve your experience, analyze site traffic, and personalize
|
||||
content. By clicking "Accept All", you consent to our use of cookies.{' '}
|
||||
<Link href="/privacy" className="text-blue-600 hover:text-blue-800 underline">
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Personnaliser
|
||||
Customize
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptEssentialOnly}
|
||||
className="px-4 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Essentiel uniquement
|
||||
Essential Only
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptAll}
|
||||
className="flex items-center justify-center gap-2 px-6 py-2.5 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
|
||||
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
Tout accepter
|
||||
Accept All
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : (
|
||||
// Detailed settings
|
||||
<motion.div
|
||||
key="settings"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-brand-navy" />
|
||||
<h3 className="text-lg font-semibold text-brand-navy">
|
||||
Préférences de cookies
|
||||
</h3>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Cookie Preferences</h3>
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded-full hover:bg-gray-100 transition-colors"
|
||||
aria-label="Fermer les paramètres"
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 mb-6 max-h-[40vh] overflow-y-auto pr-2">
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Essential Cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
Cookies essentiels
|
||||
</h4>
|
||||
<span className="px-2 py-0.5 text-xs font-medium text-brand-navy bg-brand-navy/10 rounded-full">
|
||||
Toujours actif
|
||||
<div className="flex items-center">
|
||||
<h4 className="text-sm font-semibold text-gray-900">Essential Cookies</h4>
|
||||
<span className="ml-2 px-2 py-1 text-xs font-medium text-gray-600 bg-gray-200 rounded">
|
||||
Always Active
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Nécessaires au fonctionnement du site. Ne peuvent pas être désactivés.
|
||||
Required for the website to function. Cannot be disabled.
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={true}
|
||||
disabled
|
||||
className="h-5 w-5 text-brand-navy border-gray-300 rounded cursor-not-allowed opacity-60"
|
||||
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Functional Cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
Cookies fonctionnels
|
||||
</h4>
|
||||
<h4 className="text-sm font-semibold text-gray-900">Functional Cookies</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Permettent de mémoriser vos préférences et paramètres (langue, région).
|
||||
Remember your preferences and settings (e.g., language, region).
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localPrefs.functional}
|
||||
onChange={e =>
|
||||
setLocalPrefs({ ...localPrefs, functional: e.target.checked })
|
||||
}
|
||||
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
|
||||
checked={preferences.functional}
|
||||
onChange={e => setPreferences({ ...preferences, functional: e.target.checked })}
|
||||
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
Cookies analytiques
|
||||
</h4>
|
||||
<h4 className="text-sm font-semibold text-gray-900">Analytics Cookies</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Nous aident à comprendre comment les visiteurs interagissent avec notre
|
||||
site (Google Analytics, Sentry).
|
||||
Help us understand how visitors interact with our website (Google Analytics,
|
||||
Sentry).
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localPrefs.analytics}
|
||||
onChange={e =>
|
||||
setLocalPrefs({ ...localPrefs, analytics: e.target.checked })
|
||||
}
|
||||
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
|
||||
checked={preferences.analytics}
|
||||
onChange={e => setPreferences({ ...preferences, analytics: e.target.checked })}
|
||||
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Marketing Cookies */}
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-gray-900">
|
||||
Cookies marketing
|
||||
</h4>
|
||||
<h4 className="text-sm font-semibold text-gray-900">Marketing Cookies</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Utilisés pour afficher des publicités personnalisées et mesurer
|
||||
l'efficacité des campagnes.
|
||||
Used to deliver personalized ads and measure campaign effectiveness.
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localPrefs.marketing}
|
||||
onChange={e =>
|
||||
setLocalPrefs({ ...localPrefs, marketing: e.target.checked })
|
||||
}
|
||||
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
|
||||
checked={preferences.marketing}
|
||||
onChange={e => setPreferences({ ...preferences, marketing: e.target.checked })}
|
||||
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={handleSaveCustom}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-6 py-2.5 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
|
||||
onClick={saveCustomPreferences}
|
||||
className="flex-1 px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
Enregistrer mes préférences
|
||||
Save Preferences
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptAll}
|
||||
className="flex-1 px-6 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
|
||||
className="flex-1 px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Tout accepter
|
||||
Accept All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-gray-500 text-center">
|
||||
Vous pouvez modifier vos préférences à tout moment dans les paramètres de
|
||||
votre compte ou en cliquant sur l'icône cookie en bas à gauche.{' '}
|
||||
<Link href="/cookies" className="text-brand-turquoise hover:underline">
|
||||
Politique de cookies
|
||||
</Link>
|
||||
You can change your preferences at any time in your account settings or by clicking
|
||||
the cookie icon in the footer.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,244 +0,0 @@
|
||||
/**
|
||||
* Export Button Component
|
||||
*
|
||||
* Reusable component for exporting data to CSV or Excel
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
|
||||
|
||||
interface ExportButtonProps<T> {
|
||||
data: T[];
|
||||
filename: string;
|
||||
columns: {
|
||||
key: keyof T | string;
|
||||
label: string;
|
||||
format?: (value: any, row: T) => string;
|
||||
}[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ExportButton<T extends Record<string, any>>({
|
||||
data,
|
||||
filename,
|
||||
columns,
|
||||
disabled = false,
|
||||
}: ExportButtonProps<T>) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const formatValue = (value: any): string => {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'boolean') return value ? 'Oui' : 'Non';
|
||||
if (value instanceof Date) return value.toLocaleDateString('fr-FR');
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const getNestedValue = (obj: T, path: string): any => {
|
||||
return path.split('.').reduce((acc, part) => acc?.[part], obj as any);
|
||||
};
|
||||
|
||||
const generateCSV = (): string => {
|
||||
// Headers
|
||||
const headers = columns.map(col => `"${col.label.replace(/"/g, '""')}"`).join(';');
|
||||
|
||||
// Rows
|
||||
const rows = data.map(row => {
|
||||
return columns
|
||||
.map(col => {
|
||||
const value = getNestedValue(row, col.key as string);
|
||||
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
|
||||
// Escape quotes and wrap in quotes
|
||||
return `"${formattedValue.replace(/"/g, '""')}"`;
|
||||
})
|
||||
.join(';');
|
||||
});
|
||||
|
||||
return [headers, ...rows].join('\n');
|
||||
};
|
||||
|
||||
const downloadFile = (content: string, mimeType: string, extension: string) => {
|
||||
const BOM = '\uFEFF'; // UTF-8 BOM for Excel compatibility
|
||||
const blob = new Blob([BOM + content], { type: `${mimeType};charset=utf-8` });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${filename}_${new Date().toISOString().split('T')[0]}.${extension}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleExportCSV = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const csv = generateCSV();
|
||||
downloadFile(csv, 'text/csv', 'csv');
|
||||
} catch (error) {
|
||||
console.error('Export CSV error:', error);
|
||||
alert('Erreur lors de l\'export CSV');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
// Generate Excel-compatible XML (SpreadsheetML)
|
||||
const xmlHeader = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?mso-application progid="Excel.Sheet"?>
|
||||
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
|
||||
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
|
||||
<Styles>
|
||||
<Style ss:ID="Header">
|
||||
<Font ss:Bold="1" ss:Color="#FFFFFF"/>
|
||||
<Interior ss:Color="#3B82F6" ss:Pattern="Solid"/>
|
||||
<Alignment ss:Horizontal="Center" ss:Vertical="Center"/>
|
||||
</Style>
|
||||
<Style ss:ID="Default">
|
||||
<Alignment ss:Vertical="Center"/>
|
||||
</Style>
|
||||
</Styles>
|
||||
<Worksheet ss:Name="Export">
|
||||
<Table>`;
|
||||
|
||||
// Column widths
|
||||
const columnWidths = columns.map(() => '<Column ss:AutoFitWidth="1" ss:Width="120"/>').join('');
|
||||
|
||||
// Header row
|
||||
const headerRow = `<Row ss:StyleID="Header">
|
||||
${columns.map(col => `<Cell><Data ss:Type="String">${escapeXml(col.label)}</Data></Cell>`).join('')}
|
||||
</Row>`;
|
||||
|
||||
// Data rows
|
||||
const dataRows = data
|
||||
.map(row => {
|
||||
const cells = columns
|
||||
.map(col => {
|
||||
const value = getNestedValue(row, col.key as string);
|
||||
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
|
||||
const type = typeof value === 'number' ? 'Number' : 'String';
|
||||
return `<Cell ss:StyleID="Default"><Data ss:Type="${type}">${escapeXml(formattedValue)}</Data></Cell>`;
|
||||
})
|
||||
.join('');
|
||||
return `<Row>${cells}</Row>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const xmlFooter = `</Table>
|
||||
</Worksheet>
|
||||
</Workbook>`;
|
||||
|
||||
const xmlContent = xmlHeader + columnWidths + headerRow + dataRows + xmlFooter;
|
||||
downloadFile(xmlContent, 'application/vnd.ms-excel', 'xls');
|
||||
} catch (error) {
|
||||
console.error('Export Excel error:', error);
|
||||
alert('Erreur lors de l\'export Excel');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const escapeXml = (str: string): string => {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={disabled || data.length === 0 || isExporting}
|
||||
className="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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Export en cours...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Exporter
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && !isExporting && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="flex items-center w-full px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<FileText className="mr-3 h-4 w-4 text-green-600" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Export CSV</div>
|
||||
<div className="text-xs text-gray-500">Compatible Excel, Google Sheets</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportExcel}
|
||||
className="flex items-center w-full px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<FileSpreadsheet className="mr-3 h-4 w-4 text-blue-600" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Export Excel</div>
|
||||
<div className="text-xs text-gray-500">Format .xls natif</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 px-4 py-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
{data.length} ligne{data.length > 1 ? 's' : ''} à exporter
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -11,18 +11,6 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { listNotifications, markNotificationAsRead, markAllNotificationsAsRead } from '@/lib/api';
|
||||
import type { NotificationResponse } from '@/types/api';
|
||||
import NotificationPanel from './NotificationPanel';
|
||||
import {
|
||||
CheckCircle,
|
||||
RefreshCw,
|
||||
XCircle,
|
||||
DollarSign,
|
||||
Ship,
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
Megaphone,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function NotificationDropdown() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@ -95,17 +83,17 @@ export default function NotificationDropdown() {
|
||||
return colors[priority as keyof typeof colors] || colors.low;
|
||||
};
|
||||
|
||||
const getNotificationIcon = (type: string): LucideIcon => {
|
||||
const icons: Record<string, LucideIcon> = {
|
||||
BOOKING_CONFIRMED: CheckCircle,
|
||||
BOOKING_UPDATED: RefreshCw,
|
||||
BOOKING_CANCELLED: XCircle,
|
||||
RATE_ALERT: DollarSign,
|
||||
CARRIER_UPDATE: Ship,
|
||||
SYSTEM: Settings,
|
||||
WARNING: AlertTriangle,
|
||||
const getNotificationIcon = (type: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
BOOKING_CONFIRMED: '✅',
|
||||
BOOKING_UPDATED: '🔄',
|
||||
BOOKING_CANCELLED: '❌',
|
||||
RATE_ALERT: '💰',
|
||||
CARRIER_UPDATE: '🚢',
|
||||
SYSTEM: '⚙️',
|
||||
WARNING: '⚠️',
|
||||
};
|
||||
return icons[type] || Megaphone;
|
||||
return icons[type] || '📢';
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
@ -116,10 +104,10 @@ export default function NotificationDropdown() {
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'À l\'instant';
|
||||
if (diffMins < 60) return `Il y a ${diffMins}min`;
|
||||
if (diffHours < 24) return `Il y a ${diffHours}h`;
|
||||
if (diffDays < 7) return `Il y a ${diffDays}j`;
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
@ -158,7 +146,7 @@ export default function NotificationDropdown() {
|
||||
disabled={markAllAsReadMutation.isPending}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
|
||||
>
|
||||
Tout marquer comme lu
|
||||
Mark all as read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -166,11 +154,11 @@ export default function NotificationDropdown() {
|
||||
{/* Notifications List */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">Chargement des notifications...</div>
|
||||
<div className="p-4 text-center text-sm text-gray-500">Loading notifications...</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<Bell className="h-10 w-10 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500">Aucune nouvelle notification</p>
|
||||
<div className="text-4xl mb-2">🔔</div>
|
||||
<p className="text-sm text-gray-500">No new notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
@ -184,8 +172,8 @@ export default function NotificationDropdown() {
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
{(() => { const Icon = getNotificationIcon(notification.type); return <Icon className="h-5 w-5 text-gray-500" />; })()}
|
||||
<div className="flex-shrink-0 text-2xl">
|
||||
{getNotificationIcon(notification.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
@ -222,7 +210,7 @@ export default function NotificationDropdown() {
|
||||
}}
|
||||
className="w-full text-center text-sm text-blue-600 hover:text-blue-800 font-medium py-2 hover:bg-blue-50 rounded transition-colors"
|
||||
>
|
||||
Voir toutes les notifications
|
||||
View all notifications
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
deleteNotification
|
||||
} from '@/lib/api';
|
||||
import type { NotificationResponse } from '@/types/api';
|
||||
import { X, Trash2, CheckCheck, Filter, Bell, Package, RefreshCw, XCircle, CheckCircle, Mail, Timer, FileText, Megaphone, User, Building2 } from 'lucide-react';
|
||||
import { X, Trash2, CheckCheck, Filter } from 'lucide-react';
|
||||
|
||||
interface NotificationPanelProps {
|
||||
isOpen: boolean;
|
||||
@ -83,7 +83,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
||||
|
||||
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('Voulez-vous vraiment supprimer cette notification ?')) {
|
||||
if (confirm('Are you sure you want to delete this notification?')) {
|
||||
deleteNotificationMutation.mutate(notificationId);
|
||||
}
|
||||
};
|
||||
@ -98,22 +98,22 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
||||
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300';
|
||||
};
|
||||
|
||||
const getNotificationIconComponent = (type: string) => {
|
||||
const icons: Record<string, typeof Bell> = {
|
||||
booking_created: Package,
|
||||
booking_updated: RefreshCw,
|
||||
booking_cancelled: XCircle,
|
||||
booking_confirmed: CheckCircle,
|
||||
csv_booking_accepted: CheckCircle,
|
||||
csv_booking_rejected: XCircle,
|
||||
csv_booking_request_sent: Mail,
|
||||
rate_quote_expiring: Timer,
|
||||
document_uploaded: FileText,
|
||||
system_announcement: Megaphone,
|
||||
user_invited: User,
|
||||
organization_update: Building2,
|
||||
const getNotificationIcon = (type: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
booking_created: '📦',
|
||||
booking_updated: '🔄',
|
||||
booking_cancelled: '❌',
|
||||
booking_confirmed: '✅',
|
||||
csv_booking_accepted: '✅',
|
||||
csv_booking_rejected: '❌',
|
||||
csv_booking_request_sent: '📧',
|
||||
rate_quote_expiring: '⏰',
|
||||
document_uploaded: '📄',
|
||||
system_announcement: '📢',
|
||||
user_invited: '👤',
|
||||
organization_update: '🏢',
|
||||
};
|
||||
return icons[type.toLowerCase()] || Bell;
|
||||
return icons[type.toLowerCase()] || '🔔';
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
@ -124,13 +124,13 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'À l\'instant';
|
||||
if (diffMins < 60) return `Il y a ${diffMins}min`;
|
||||
if (diffHours < 24) return `Il y a ${diffHours}h`;
|
||||
if (diffDays < 7) return `Il y a ${diffDays}j`;
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
});
|
||||
};
|
||||
@ -158,7 +158,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-600"
|
||||
aria-label="Fermer le panneau"
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
@ -182,7 +182,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
||||
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{filter === 'all' ? 'Toutes' : filter === 'unread' ? 'Non lues' : 'Lues'}
|
||||
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -194,7 +194,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
||||
className="flex items-center space-x-1 text-sm text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
|
||||
>
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
<span>Tout marquer comme lu</span>
|
||||
<span>Mark all as read</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -205,18 +205,18 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
|
||||
<p className="text-sm text-gray-500">Chargement des notifications...</p>
|
||||
<p className="text-sm text-gray-500">Loading notifications...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<Bell className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Aucune notification</h3>
|
||||
<div className="text-6xl mb-4">🔔</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No notifications</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{selectedFilter === 'unread'
|
||||
? 'Vous êtes à jour !'
|
||||
: 'Aucune notification à afficher'}
|
||||
? "You're all caught up!"
|
||||
: 'No notifications to display'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -232,8 +232,8 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{(() => { const Icon = getNotificationIconComponent(notification.type); return <Icon className="h-6 w-6 text-gray-500" />; })()}
|
||||
<div className="flex-shrink-0 text-3xl">
|
||||
{getNotificationIcon(notification.type)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@ -250,7 +250,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, notification.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-red-100 rounded"
|
||||
title="Supprimer la notification"
|
||||
title="Delete notification"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
@ -287,7 +287,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
||||
</div>
|
||||
{notification.actionUrl && (
|
||||
<span className="text-xs text-blue-600 font-medium group-hover:underline">
|
||||
Voir les détails →
|
||||
View details →
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -303,7 +303,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t bg-gray-50">
|
||||
<div className="text-sm text-gray-600">
|
||||
Page {currentPage} sur {totalPages}
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
@ -311,14 +311,14 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Précédent
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Suivant
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { MapContainer, TileLayer, Polyline, Marker, useMap } from "react-leaflet";
|
||||
import { MapContainer, TileLayer, Polyline, Marker } from "react-leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import L from "leaflet";
|
||||
|
||||
@ -20,352 +19,22 @@ const DefaultIcon = L.icon({
|
||||
});
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
|
||||
// Maritime waypoints for major shipping routes
|
||||
const WAYPOINTS = {
|
||||
// Mediterranean / Suez route
|
||||
gibraltar: { lat: 36.1, lng: -5.3 },
|
||||
suezNorth: { lat: 31.2, lng: 32.3 },
|
||||
suezSouth: { lat: 29.9, lng: 32.5 },
|
||||
babElMandeb: { lat: 12.6, lng: 43.3 },
|
||||
|
||||
// Indian Ocean
|
||||
sriLanka: { lat: 6.0, lng: 80.0 },
|
||||
|
||||
// Southeast Asia
|
||||
malacca: { lat: 1.3, lng: 103.8 },
|
||||
singapore: { lat: 1.2, lng: 103.8 },
|
||||
|
||||
// East Asia
|
||||
hongKong: { lat: 22.3, lng: 114.2 },
|
||||
taiwan: { lat: 23.5, lng: 121.0 },
|
||||
|
||||
// Atlantic
|
||||
azores: { lat: 38.7, lng: -27.2 },
|
||||
|
||||
// Americas
|
||||
panama: { lat: 9.0, lng: -79.5 },
|
||||
|
||||
// Cape route (alternative to Suez)
|
||||
capeTown: { lat: -34.0, lng: 18.5 },
|
||||
capeAgulhas: { lat: -34.8, lng: 20.0 },
|
||||
};
|
||||
|
||||
type Region = 'northEurope' | 'medEurope' | 'eastAsia' | 'southeastAsia' | 'india' | 'middleEast' | 'eastAfrica' | 'westAfrica' | 'northAmerica' | 'southAmerica' | 'oceania' | 'unknown';
|
||||
|
||||
// Determine the region of a port based on coordinates
|
||||
function getRegion(port: { lat: number; lng: number }): Region {
|
||||
const { lat, lng } = port;
|
||||
|
||||
// North Europe (including UK, Scandinavia, North Sea, Baltic)
|
||||
if (lat > 45 && lat < 70 && lng > -15 && lng < 30) return 'northEurope';
|
||||
|
||||
// Mediterranean Europe
|
||||
if (lat > 30 && lat <= 45 && lng > -10 && lng < 40) return 'medEurope';
|
||||
|
||||
// East Asia (China, Japan, Korea)
|
||||
if (lat > 20 && lat < 55 && lng > 100 && lng < 150) return 'eastAsia';
|
||||
|
||||
// Southeast Asia (Vietnam, Thailand, Malaysia, Indonesia, Philippines)
|
||||
if (lat > -10 && lat <= 20 && lng > 95 && lng < 130) return 'southeastAsia';
|
||||
|
||||
// India / South Asia
|
||||
if (lat > 5 && lat < 35 && lng > 65 && lng < 95) return 'india';
|
||||
|
||||
// Middle East (Persian Gulf, Red Sea)
|
||||
if (lat > 10 && lat < 35 && lng > 30 && lng < 65) return 'middleEast';
|
||||
|
||||
// East Africa
|
||||
if (lat > -35 && lat < 15 && lng > 25 && lng < 55) return 'eastAfrica';
|
||||
|
||||
// West Africa
|
||||
if (lat > -35 && lat < 35 && lng > -25 && lng < 25) return 'westAfrica';
|
||||
|
||||
// North America (East Coast mainly)
|
||||
if (lat > 10 && lat < 60 && lng > -130 && lng < -50) return 'northAmerica';
|
||||
|
||||
// South America
|
||||
if (lat > -60 && lat <= 10 && lng > -90 && lng < -30) return 'southAmerica';
|
||||
|
||||
// Oceania (Australia, New Zealand)
|
||||
if (lat > -50 && lat < 0 && lng > 110 && lng < 180) return 'oceania';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// Calculate maritime route waypoints between two ports
|
||||
function calculateMaritimeRoute(
|
||||
portA: { lat: number; lng: number },
|
||||
portB: { lat: number; lng: number }
|
||||
): Array<{ lat: number; lng: number }> {
|
||||
const regionA = getRegion(portA);
|
||||
const regionB = getRegion(portB);
|
||||
|
||||
const route: Array<{ lat: number; lng: number }> = [portA];
|
||||
|
||||
// Europe to East Asia via Suez
|
||||
if (
|
||||
(regionA === 'northEurope' || regionA === 'medEurope') &&
|
||||
(regionB === 'eastAsia' || regionB === 'southeastAsia')
|
||||
) {
|
||||
if (regionA === 'northEurope') {
|
||||
route.push(WAYPOINTS.gibraltar);
|
||||
}
|
||||
route.push(WAYPOINTS.suezNorth);
|
||||
route.push(WAYPOINTS.suezSouth);
|
||||
route.push(WAYPOINTS.babElMandeb);
|
||||
route.push(WAYPOINTS.sriLanka);
|
||||
route.push(WAYPOINTS.malacca);
|
||||
if (regionB === 'eastAsia') {
|
||||
route.push(WAYPOINTS.hongKong);
|
||||
}
|
||||
}
|
||||
// East Asia to Europe via Suez (reverse)
|
||||
else if (
|
||||
(regionA === 'eastAsia' || regionA === 'southeastAsia') &&
|
||||
(regionB === 'northEurope' || regionB === 'medEurope')
|
||||
) {
|
||||
if (regionA === 'eastAsia') {
|
||||
route.push(WAYPOINTS.hongKong);
|
||||
}
|
||||
route.push(WAYPOINTS.malacca);
|
||||
route.push(WAYPOINTS.sriLanka);
|
||||
route.push(WAYPOINTS.babElMandeb);
|
||||
route.push(WAYPOINTS.suezSouth);
|
||||
route.push(WAYPOINTS.suezNorth);
|
||||
if (regionB === 'northEurope') {
|
||||
route.push(WAYPOINTS.gibraltar);
|
||||
}
|
||||
}
|
||||
// Europe to India via Suez
|
||||
else if (
|
||||
(regionA === 'northEurope' || regionA === 'medEurope') &&
|
||||
regionB === 'india'
|
||||
) {
|
||||
if (regionA === 'northEurope') {
|
||||
route.push(WAYPOINTS.gibraltar);
|
||||
}
|
||||
route.push(WAYPOINTS.suezNorth);
|
||||
route.push(WAYPOINTS.suezSouth);
|
||||
route.push(WAYPOINTS.babElMandeb);
|
||||
}
|
||||
// India to Europe via Suez (reverse)
|
||||
else if (
|
||||
regionA === 'india' &&
|
||||
(regionB === 'northEurope' || regionB === 'medEurope')
|
||||
) {
|
||||
route.push(WAYPOINTS.babElMandeb);
|
||||
route.push(WAYPOINTS.suezSouth);
|
||||
route.push(WAYPOINTS.suezNorth);
|
||||
if (regionB === 'northEurope') {
|
||||
route.push(WAYPOINTS.gibraltar);
|
||||
}
|
||||
}
|
||||
// Europe to Middle East via Suez
|
||||
else if (
|
||||
(regionA === 'northEurope' || regionA === 'medEurope') &&
|
||||
regionB === 'middleEast'
|
||||
) {
|
||||
if (regionA === 'northEurope') {
|
||||
route.push(WAYPOINTS.gibraltar);
|
||||
}
|
||||
route.push(WAYPOINTS.suezNorth);
|
||||
route.push(WAYPOINTS.suezSouth);
|
||||
}
|
||||
// Middle East to Europe via Suez (reverse)
|
||||
else if (
|
||||
regionA === 'middleEast' &&
|
||||
(regionB === 'northEurope' || regionB === 'medEurope')
|
||||
) {
|
||||
route.push(WAYPOINTS.suezSouth);
|
||||
route.push(WAYPOINTS.suezNorth);
|
||||
if (regionB === 'northEurope') {
|
||||
route.push(WAYPOINTS.gibraltar);
|
||||
}
|
||||
}
|
||||
// Europe to Southeast Asia
|
||||
else if (
|
||||
(regionA === 'northEurope' || regionA === 'medEurope') &&
|
||||
regionB === 'southeastAsia'
|
||||
) {
|
||||
if (regionA === 'northEurope') {
|
||||
route.push(WAYPOINTS.gibraltar);
|
||||
}
|
||||
route.push(WAYPOINTS.suezNorth);
|
||||
route.push(WAYPOINTS.suezSouth);
|
||||
route.push(WAYPOINTS.babElMandeb);
|
||||
route.push(WAYPOINTS.sriLanka);
|
||||
route.push(WAYPOINTS.malacca);
|
||||
}
|
||||
// Southeast Asia to Europe (reverse)
|
||||
else if (
|
||||
regionA === 'southeastAsia' &&
|
||||
(regionB === 'northEurope' || regionB === 'medEurope')
|
||||
) {
|
||||
route.push(WAYPOINTS.malacca);
|
||||
route.push(WAYPOINTS.sriLanka);
|
||||
route.push(WAYPOINTS.babElMandeb);
|
||||
route.push(WAYPOINTS.suezSouth);
|
||||
route.push(WAYPOINTS.suezNorth);
|
||||
if (regionB === 'northEurope') {
|
||||
route.push(WAYPOINTS.gibraltar);
|
||||
}
|
||||
}
|
||||
// East Asia to India
|
||||
else if (
|
||||
(regionA === 'eastAsia' || regionA === 'southeastAsia') &&
|
||||
regionB === 'india'
|
||||
) {
|
||||
if (regionA === 'eastAsia') {
|
||||
route.push(WAYPOINTS.hongKong);
|
||||
}
|
||||
route.push(WAYPOINTS.malacca);
|
||||
route.push(WAYPOINTS.sriLanka);
|
||||
}
|
||||
// India to East Asia (reverse)
|
||||
else if (
|
||||
regionA === 'india' &&
|
||||
(regionB === 'eastAsia' || regionB === 'southeastAsia')
|
||||
) {
|
||||
route.push(WAYPOINTS.sriLanka);
|
||||
route.push(WAYPOINTS.malacca);
|
||||
if (regionB === 'eastAsia') {
|
||||
route.push(WAYPOINTS.hongKong);
|
||||
}
|
||||
}
|
||||
// Europe to East Africa
|
||||
else if (
|
||||
(regionA === 'northEurope' || regionA === 'medEurope') &&
|
||||
regionB === 'eastAfrica'
|
||||
) {
|
||||
if (regionA === 'northEurope') {
|
||||
route.push(WAYPOINTS.gibraltar);
|
||||
}
|
||||
route.push(WAYPOINTS.suezNorth);
|
||||
route.push(WAYPOINTS.suezSouth);
|
||||
route.push(WAYPOINTS.babElMandeb);
|
||||
}
|
||||
// East Africa to Europe (reverse)
|
||||
else if (
|
||||
regionA === 'eastAfrica' &&
|
||||
(regionB === 'northEurope' || regionB === 'medEurope')
|
||||
) {
|
||||
route.push(WAYPOINTS.babElMandeb);
|
||||
route.push(WAYPOINTS.suezSouth);
|
||||
route.push(WAYPOINTS.suezNorth);
|
||||
if (regionB === 'northEurope') {
|
||||
route.push(WAYPOINTS.gibraltar);
|
||||
}
|
||||
}
|
||||
// Europe to Oceania via Suez
|
||||
else if (
|
||||
(regionA === 'northEurope' || regionA === 'medEurope') &&
|
||||
regionB === 'oceania'
|
||||
) {
|
||||
if (regionA === 'northEurope') {
|
||||
route.push(WAYPOINTS.gibraltar);
|
||||
}
|
||||
route.push(WAYPOINTS.suezNorth);
|
||||
route.push(WAYPOINTS.suezSouth);
|
||||
route.push(WAYPOINTS.babElMandeb);
|
||||
route.push(WAYPOINTS.sriLanka);
|
||||
route.push(WAYPOINTS.malacca);
|
||||
}
|
||||
// Oceania to Europe (reverse)
|
||||
else if (
|
||||
regionA === 'oceania' &&
|
||||
(regionB === 'northEurope' || regionB === 'medEurope')
|
||||
) {
|
||||
route.push(WAYPOINTS.malacca);
|
||||
route.push(WAYPOINTS.sriLanka);
|
||||
route.push(WAYPOINTS.babElMandeb);
|
||||
route.push(WAYPOINTS.suezSouth);
|
||||
route.push(WAYPOINTS.suezNorth);
|
||||
if (regionB === 'northEurope') {
|
||||
route.push(WAYPOINTS.gibraltar);
|
||||
}
|
||||
}
|
||||
// North Europe to Med Europe (simple Atlantic)
|
||||
else if (regionA === 'northEurope' && regionB === 'medEurope') {
|
||||
route.push(WAYPOINTS.gibraltar);
|
||||
}
|
||||
// Med Europe to North Europe (reverse)
|
||||
else if (regionA === 'medEurope' && regionB === 'northEurope') {
|
||||
route.push(WAYPOINTS.gibraltar);
|
||||
}
|
||||
// East Asia to Oceania
|
||||
else if (
|
||||
(regionA === 'eastAsia' || regionA === 'southeastAsia') &&
|
||||
regionB === 'oceania'
|
||||
) {
|
||||
if (regionA === 'eastAsia') {
|
||||
route.push(WAYPOINTS.hongKong);
|
||||
route.push(WAYPOINTS.malacca);
|
||||
}
|
||||
}
|
||||
// Oceania to East Asia (reverse)
|
||||
else if (
|
||||
regionA === 'oceania' &&
|
||||
(regionB === 'eastAsia' || regionB === 'southeastAsia')
|
||||
) {
|
||||
route.push(WAYPOINTS.malacca);
|
||||
if (regionB === 'eastAsia') {
|
||||
route.push(WAYPOINTS.hongKong);
|
||||
}
|
||||
}
|
||||
|
||||
// Add destination
|
||||
route.push(portB);
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
// Component to control map view (fitBounds)
|
||||
function MapController({
|
||||
routePoints
|
||||
}: {
|
||||
routePoints: Array<{ lat: number; lng: number }>
|
||||
}) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (routePoints.length < 2) return;
|
||||
|
||||
// Create bounds from all route points
|
||||
const bounds = L.latLngBounds(
|
||||
routePoints.map(p => [p.lat, p.lng] as [number, number])
|
||||
);
|
||||
|
||||
// Fit the map to show all points with padding
|
||||
map.fitBounds(bounds, {
|
||||
padding: [50, 50],
|
||||
maxZoom: 6,
|
||||
});
|
||||
}, [map, routePoints]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function PortRouteMap({ portA, portB, height = "500px" }: PortRouteMapProps) {
|
||||
// Calculate the maritime route with waypoints
|
||||
const routePoints = useMemo(
|
||||
() => calculateMaritimeRoute(portA, portB),
|
||||
[portA.lat, portA.lng, portB.lat, portB.lng]
|
||||
);
|
||||
|
||||
// Convert route points to Leaflet positions
|
||||
const positions: [number, number][] = routePoints.map(p => [p.lat, p.lng]);
|
||||
|
||||
// Calculate initial center (will be adjusted by MapController)
|
||||
const center = {
|
||||
lat: (portA.lat + portB.lat) / 2,
|
||||
lng: (portA.lng + portB.lng) / 2,
|
||||
};
|
||||
|
||||
const positions: [number, number][] = [
|
||||
[portA.lat, portA.lng],
|
||||
[portB.lat, portB.lng],
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ height }}>
|
||||
<MapContainer
|
||||
center={[center.lat, center.lng]}
|
||||
zoom={2}
|
||||
zoom={4}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
scrollWheelZoom={false}
|
||||
>
|
||||
@ -374,25 +43,10 @@ export default function PortRouteMap({ portA, portB, height = "500px" }: PortRou
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
/>
|
||||
|
||||
{/* Auto-fit bounds to show entire route */}
|
||||
<MapController routePoints={routePoints} />
|
||||
|
||||
{/* Origin marker */}
|
||||
<Marker position={[portA.lat, portA.lng]} />
|
||||
|
||||
{/* Destination marker */}
|
||||
<Marker position={[portB.lat, portB.lng]} />
|
||||
|
||||
{/* Maritime route polyline */}
|
||||
<Polyline
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color: "#2563eb",
|
||||
weight: 3,
|
||||
opacity: 0.8,
|
||||
dashArray: "10, 6",
|
||||
}}
|
||||
/>
|
||||
<Polyline positions={positions} pathOptions={{ color: "#2563eb", weight: 3, opacity: 0.7 }} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -3,45 +3,44 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Users, Building2, Package, FileText, BarChart3, Settings, type LucideIcon } from 'lucide-react';
|
||||
|
||||
interface AdminMenuItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
icon: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const adminMenuItems: AdminMenuItem[] = [
|
||||
{
|
||||
name: 'Utilisateurs',
|
||||
name: 'Users',
|
||||
href: '/dashboard/admin/users',
|
||||
icon: Users,
|
||||
description: 'Gérer les utilisateurs et les permissions',
|
||||
icon: '👥',
|
||||
description: 'Manage users and permissions',
|
||||
},
|
||||
{
|
||||
name: 'Organisations',
|
||||
name: 'Organizations',
|
||||
href: '/dashboard/admin/organizations',
|
||||
icon: Building2,
|
||||
description: 'Gérer les organisations et entreprises',
|
||||
icon: '🏢',
|
||||
description: 'Manage organizations and companies',
|
||||
},
|
||||
{
|
||||
name: 'Réservations',
|
||||
name: 'Bookings',
|
||||
href: '/dashboard/admin/bookings',
|
||||
icon: Package,
|
||||
description: 'Consulter et gérer toutes les réservations',
|
||||
icon: '📦',
|
||||
description: 'View and manage all bookings',
|
||||
},
|
||||
{
|
||||
name: 'Documents',
|
||||
href: '/dashboard/admin/documents',
|
||||
icon: FileText,
|
||||
description: 'Gérer les documents des organisations',
|
||||
icon: '📄',
|
||||
description: 'Manage organization documents',
|
||||
},
|
||||
{
|
||||
name: 'Tarifs CSV',
|
||||
name: 'CSV Rates',
|
||||
href: '/dashboard/admin/csv-rates',
|
||||
icon: BarChart3,
|
||||
description: 'Importer et gérer les tarifs CSV',
|
||||
icon: '📊',
|
||||
description: 'Upload and manage CSV rates',
|
||||
},
|
||||
];
|
||||
|
||||
@ -85,8 +84,8 @@ export default function AdminPanelDropdown() {
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Settings className="mr-3 h-5 w-5" />
|
||||
<span className="flex-1 text-left">Administration</span>
|
||||
<span className="mr-3 text-xl">⚙️</span>
|
||||
<span className="flex-1 text-left">Admin Panel</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
@ -108,7 +107,6 @@ export default function AdminPanelDropdown() {
|
||||
<div className="py-2">
|
||||
{adminMenuItems.map(item => {
|
||||
const isActive = pathname.startsWith(item.href);
|
||||
const IconComponent = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
@ -117,7 +115,7 @@ export default function AdminPanelDropdown() {
|
||||
isActive ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<IconComponent className="h-5 w-5 mr-3 mt-0.5 text-gray-500" />
|
||||
<span className="text-2xl mr-3 mt-0.5">{item.icon}</span>
|
||||
<div className="flex-1">
|
||||
<div className={`text-sm font-medium ${isActive ? 'text-blue-700' : 'text-gray-900'}`}>
|
||||
{item.name}
|
||||
|
||||
@ -9,8 +9,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getSubscriptionOverview } from '@/lib/api/subscriptions';
|
||||
import Link from 'next/link';
|
||||
import { UserPlus } from 'lucide-react';
|
||||
|
||||
export default function LicensesTab() {
|
||||
const [error, setError] = useState('');
|
||||
@ -112,17 +110,10 @@ export default function LicensesTab() {
|
||||
|
||||
{/* Active Licenses */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50 flex items-center justify-between">
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Licences actives ({activeLicenses.length})
|
||||
</h3>
|
||||
<Link
|
||||
href="/dashboard/settings/users"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Inviter un utilisateur
|
||||
</Link>
|
||||
</div>
|
||||
{activeLicenses.length === 0 ? (
|
||||
<div className="px-6 py-8 text-center text-gray-500">
|
||||
@ -344,6 +335,9 @@ export default function LicensesTab() {
|
||||
<li>
|
||||
Chaque utilisateur actif de votre organisation consomme une licence
|
||||
</li>
|
||||
<li>
|
||||
<strong>Les administrateurs (ADMIN) ont des licences illimitées</strong> et ne sont pas comptés dans le quota
|
||||
</li>
|
||||
<li>
|
||||
Les licences sont automatiquement assignées lors de l'ajout d'un
|
||||
utilisateur
|
||||
|
||||
@ -9,8 +9,6 @@
|
||||
import { useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from '@/lib/context/auth-context';
|
||||
import { CookieProvider } from '@/lib/context/cookie-context';
|
||||
import CookieConsent from '@/components/CookieConsent';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
// Create a client instance per component instance
|
||||
@ -29,12 +27,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<CookieProvider>
|
||||
{children}
|
||||
<CookieConsent />
|
||||
</CookieProvider>
|
||||
</AuthProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -165,12 +165,7 @@ export async function apiRequest<T>(
|
||||
});
|
||||
|
||||
// Handle 401 Unauthorized - token expired
|
||||
// Skip auto-redirect for auth endpoints (login, register, refresh) - they handle their own errors
|
||||
const isAuthEndpoint = endpoint.includes('/auth/login') ||
|
||||
endpoint.includes('/auth/register') ||
|
||||
endpoint.includes('/auth/refresh');
|
||||
|
||||
if (response.status === 401 && !isRetry && !isAuthEndpoint) {
|
||||
if (response.status === 401 && !isRetry && !endpoint.includes('/auth/refresh')) {
|
||||
// Check if we have a refresh token
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
|
||||
@ -5,38 +5,11 @@
|
||||
*/
|
||||
|
||||
import { get, post, patch } from './client';
|
||||
import type { SuccessResponse } from '@/types/api';
|
||||
import type {
|
||||
SuccessResponse,
|
||||
} from '@/types/api';
|
||||
|
||||
/**
|
||||
* Cookie consent preferences
|
||||
*/
|
||||
export interface CookiePreferences {
|
||||
essential: boolean;
|
||||
functional: boolean;
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from consent API
|
||||
*/
|
||||
export interface ConsentResponse extends CookiePreferences {
|
||||
userId: string;
|
||||
consentDate: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to update consent
|
||||
*/
|
||||
export interface UpdateConsentRequest extends CookiePreferences {
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data export response
|
||||
*/
|
||||
// TODO: These types should be moved to @/types/api.ts
|
||||
export interface GdprDataExportResponse {
|
||||
exportId: string;
|
||||
status: 'PENDING' | 'COMPLETED' | 'FAILED';
|
||||
@ -45,50 +18,49 @@ export interface GdprDataExportResponse {
|
||||
downloadUrl?: string;
|
||||
}
|
||||
|
||||
export interface GdprConsentResponse {
|
||||
userId: string;
|
||||
marketingEmails: boolean;
|
||||
dataProcessing: boolean;
|
||||
thirdPartySharing: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateGdprConsentRequest {
|
||||
marketingEmails?: boolean;
|
||||
dataProcessing?: boolean;
|
||||
thirdPartySharing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request data export (GDPR right to data portability)
|
||||
* GET /api/v1/gdpr/export
|
||||
* Triggers download of JSON file
|
||||
* POST /api/v1/gdpr/export
|
||||
* Generates export job and sends download link via email
|
||||
*/
|
||||
export async function requestDataExport(): Promise<Blob> {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${
|
||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
||||
}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Export failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
export async function requestDataExport(): Promise<GdprDataExportResponse> {
|
||||
return post<GdprDataExportResponse>('/api/v1/gdpr/export');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request data export as CSV
|
||||
* GET /api/v1/gdpr/export/csv
|
||||
* Download exported data
|
||||
* GET /api/v1/gdpr/export/:exportId/download
|
||||
* Returns blob (JSON file)
|
||||
*/
|
||||
export async function requestDataExportCSV(): Promise<Blob> {
|
||||
export async function downloadDataExport(exportId: string): Promise<Blob> {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/csv`,
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/${exportId}/download`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${
|
||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
||||
typeof window !== 'undefined' ? localStorage.getItem('access_token') : ''
|
||||
}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Export failed: ${response.statusText}`);
|
||||
throw new Error(`Download failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
@ -96,53 +68,35 @@ export async function requestDataExportCSV(): Promise<Blob> {
|
||||
|
||||
/**
|
||||
* Request account deletion (GDPR right to be forgotten)
|
||||
* DELETE /api/v1/gdpr/delete-account
|
||||
* Initiates account deletion process
|
||||
* POST /api/v1/gdpr/delete-account
|
||||
* Initiates 30-day account deletion process
|
||||
*/
|
||||
export async function requestAccountDeletion(confirmEmail: string, reason?: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/delete-account`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${
|
||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
||||
}`,
|
||||
},
|
||||
body: JSON.stringify({ confirmEmail, reason }),
|
||||
export async function requestAccountDeletion(): Promise<SuccessResponse> {
|
||||
return post<SuccessResponse>('/api/v1/gdpr/delete-account');
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Deletion failed: ${response.statusText}`);
|
||||
}
|
||||
/**
|
||||
* Cancel pending account deletion
|
||||
* POST /api/v1/gdpr/cancel-deletion
|
||||
*/
|
||||
export async function cancelAccountDeletion(): Promise<SuccessResponse> {
|
||||
return post<SuccessResponse>('/api/v1/gdpr/cancel-deletion');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user consent preferences
|
||||
* GET /api/v1/gdpr/consent
|
||||
*/
|
||||
export async function getConsentPreferences(): Promise<ConsentResponse | null> {
|
||||
return get<ConsentResponse | null>('/api/v1/gdpr/consent');
|
||||
export async function getConsentPreferences(): Promise<GdprConsentResponse> {
|
||||
return get<GdprConsentResponse>('/api/v1/gdpr/consent');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update consent preferences
|
||||
* POST /api/v1/gdpr/consent
|
||||
* PATCH /api/v1/gdpr/consent
|
||||
*/
|
||||
export async function updateConsentPreferences(
|
||||
data: UpdateConsentRequest
|
||||
): Promise<ConsentResponse> {
|
||||
return post<ConsentResponse>('/api/v1/gdpr/consent', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw specific consent
|
||||
* POST /api/v1/gdpr/consent/withdraw
|
||||
*/
|
||||
export async function withdrawConsent(
|
||||
consentType: 'functional' | 'analytics' | 'marketing'
|
||||
): Promise<ConsentResponse> {
|
||||
return post<ConsentResponse>('/api/v1/gdpr/consent/withdraw', { consentType });
|
||||
data: UpdateGdprConsentRequest
|
||||
): Promise<GdprConsentResponse> {
|
||||
return patch<GdprConsentResponse>('/api/v1/gdpr/consent', data);
|
||||
}
|
||||
|
||||
@ -107,14 +107,11 @@ export {
|
||||
// GDPR (6 endpoints)
|
||||
export {
|
||||
requestDataExport,
|
||||
requestDataExportCSV,
|
||||
downloadDataExport,
|
||||
requestAccountDeletion,
|
||||
cancelAccountDeletion,
|
||||
getConsentPreferences,
|
||||
updateConsentPreferences,
|
||||
withdrawConsent,
|
||||
type CookiePreferences,
|
||||
type ConsentResponse,
|
||||
type UpdateConsentRequest,
|
||||
} from './gdpr';
|
||||
|
||||
// Admin CSV Rates (5 endpoints) - already exists
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
* Endpoints for searching shipping rates (both API and CSV-based)
|
||||
*/
|
||||
|
||||
import { get, post } from './client';
|
||||
import { post } from './client';
|
||||
import type {
|
||||
RateSearchRequest,
|
||||
RateSearchResponse,
|
||||
@ -14,37 +14,6 @@ import type {
|
||||
FilterOptionsResponse,
|
||||
} from '@/types/api';
|
||||
|
||||
/**
|
||||
* Route Port Info - port details with coordinates
|
||||
*/
|
||||
export interface RoutePortInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
city: string;
|
||||
country: string;
|
||||
countryName: string;
|
||||
displayName: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available Origins Response
|
||||
*/
|
||||
export interface AvailableOriginsResponse {
|
||||
origins: RoutePortInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available Destinations Response
|
||||
*/
|
||||
export interface AvailableDestinationsResponse {
|
||||
origin: string;
|
||||
destinations: RoutePortInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search shipping rates (API-based)
|
||||
* POST /api/v1/rates/search
|
||||
@ -89,29 +58,3 @@ export async function getAvailableCompanies(): Promise<AvailableCompaniesRespons
|
||||
export async function getFilterOptions(): Promise<FilterOptionsResponse> {
|
||||
return post<FilterOptionsResponse>('/api/v1/rates/filters/options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available origin ports from CSV rates
|
||||
* GET /api/v1/rates/available-routes/origins
|
||||
*
|
||||
* Returns only ports that have shipping routes defined in CSV rate files.
|
||||
* Use this to populate origin port selection dropdown.
|
||||
*/
|
||||
export async function getAvailableOrigins(): Promise<AvailableOriginsResponse> {
|
||||
return get<AvailableOriginsResponse>('/api/v1/rates/available-routes/origins');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available destination ports for a given origin
|
||||
* GET /api/v1/rates/available-routes/destinations?origin=XXXX
|
||||
*
|
||||
* Returns only ports that have shipping routes from the specified origin port.
|
||||
* Use this to populate destination port selection after origin is selected.
|
||||
*/
|
||||
export async function getAvailableDestinations(
|
||||
origin: string
|
||||
): Promise<AvailableDestinationsResponse> {
|
||||
return get<AvailableDestinationsResponse>(
|
||||
`/api/v1/rates/available-routes/destinations?origin=${encodeURIComponent(origin)}`
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,228 +0,0 @@
|
||||
/**
|
||||
* Cookie Consent Context
|
||||
*
|
||||
* Provides cookie consent state and methods to the application
|
||||
* Syncs with backend for authenticated users
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
getConsentPreferences,
|
||||
updateConsentPreferences,
|
||||
type CookiePreferences,
|
||||
} from '../api/gdpr';
|
||||
import { getAuthToken } from '../api/client';
|
||||
|
||||
const STORAGE_KEY = 'cookieConsent';
|
||||
const STORAGE_DATE_KEY = 'cookieConsentDate';
|
||||
|
||||
interface CookieContextType {
|
||||
preferences: CookiePreferences;
|
||||
showBanner: boolean;
|
||||
showSettings: boolean;
|
||||
isLoading: boolean;
|
||||
setShowBanner: (show: boolean) => void;
|
||||
setShowSettings: (show: boolean) => void;
|
||||
acceptAll: () => Promise<void>;
|
||||
acceptEssentialOnly: () => Promise<void>;
|
||||
savePreferences: (prefs: CookiePreferences) => Promise<void>;
|
||||
openPreferences: () => void;
|
||||
}
|
||||
|
||||
const defaultPreferences: CookiePreferences = {
|
||||
essential: true,
|
||||
functional: false,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
};
|
||||
|
||||
const CookieContext = createContext<CookieContextType | undefined>(undefined);
|
||||
|
||||
export function CookieProvider({ children }: { children: React.ReactNode }) {
|
||||
const [preferences, setPreferences] = useState<CookiePreferences>(defaultPreferences);
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
|
||||
// Check if user is authenticated
|
||||
const isAuthenticated = useCallback(() => {
|
||||
return !!getAuthToken();
|
||||
}, []);
|
||||
|
||||
// Load preferences from localStorage
|
||||
const loadFromLocalStorage = useCallback((): CookiePreferences | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Save preferences to localStorage
|
||||
const saveToLocalStorage = useCallback((prefs: CookiePreferences) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||
localStorage.setItem(STORAGE_DATE_KEY, new Date().toISOString());
|
||||
}, []);
|
||||
|
||||
// Apply preferences (gtag, etc.)
|
||||
const applyPreferences = useCallback((prefs: CookiePreferences) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Google Analytics consent
|
||||
const gtag = (window as any).gtag;
|
||||
if (gtag) {
|
||||
gtag('consent', 'update', {
|
||||
analytics_storage: prefs.analytics ? 'granted' : 'denied',
|
||||
ad_storage: prefs.marketing ? 'granted' : 'denied',
|
||||
functionality_storage: prefs.functional ? 'granted' : 'denied',
|
||||
});
|
||||
}
|
||||
|
||||
// Sentry (if analytics enabled)
|
||||
const Sentry = (window as any).Sentry;
|
||||
if (Sentry) {
|
||||
if (prefs.analytics) {
|
||||
Sentry.init && Sentry.getCurrentHub && Sentry.getCurrentHub().getClient()?.getOptions();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize preferences
|
||||
useEffect(() => {
|
||||
const initializePreferences = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// First, check localStorage
|
||||
const localPrefs = loadFromLocalStorage();
|
||||
|
||||
if (localPrefs) {
|
||||
setPreferences(localPrefs);
|
||||
applyPreferences(localPrefs);
|
||||
setShowBanner(false);
|
||||
} else {
|
||||
// No local consent - show banner
|
||||
setShowBanner(true);
|
||||
}
|
||||
|
||||
// If authenticated, try to sync with backend
|
||||
if (isAuthenticated() && localPrefs) {
|
||||
try {
|
||||
const backendPrefs = await getConsentPreferences();
|
||||
if (backendPrefs) {
|
||||
// Backend has preferences - use them
|
||||
const prefs: CookiePreferences = {
|
||||
essential: backendPrefs.essential,
|
||||
functional: backendPrefs.functional,
|
||||
analytics: backendPrefs.analytics,
|
||||
marketing: backendPrefs.marketing,
|
||||
};
|
||||
setPreferences(prefs);
|
||||
saveToLocalStorage(prefs);
|
||||
applyPreferences(prefs);
|
||||
} else if (localPrefs) {
|
||||
// No backend preferences but we have local - sync to backend
|
||||
try {
|
||||
await updateConsentPreferences({
|
||||
...localPrefs,
|
||||
userAgent: navigator.userAgent,
|
||||
});
|
||||
} catch (syncError) {
|
||||
console.warn('Failed to sync consent to backend:', syncError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch consent from backend:', error);
|
||||
// Continue with local preferences
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setHasInitialized(true);
|
||||
};
|
||||
|
||||
initializePreferences();
|
||||
}, [isAuthenticated, loadFromLocalStorage, saveToLocalStorage, applyPreferences]);
|
||||
|
||||
// Save preferences (to localStorage and optionally backend)
|
||||
const savePreferences = useCallback(
|
||||
async (prefs: CookiePreferences) => {
|
||||
// Ensure essential is always true
|
||||
const safePrefs = { ...prefs, essential: true };
|
||||
|
||||
setPreferences(safePrefs);
|
||||
saveToLocalStorage(safePrefs);
|
||||
applyPreferences(safePrefs);
|
||||
setShowBanner(false);
|
||||
setShowSettings(false);
|
||||
|
||||
// Sync to backend if authenticated
|
||||
if (isAuthenticated()) {
|
||||
try {
|
||||
await updateConsentPreferences({
|
||||
...safePrefs,
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync consent to backend:', error);
|
||||
// Local save succeeded, backend sync failed - that's okay
|
||||
}
|
||||
}
|
||||
},
|
||||
[isAuthenticated, saveToLocalStorage, applyPreferences]
|
||||
);
|
||||
|
||||
const acceptAll = useCallback(async () => {
|
||||
await savePreferences({
|
||||
essential: true,
|
||||
functional: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
});
|
||||
}, [savePreferences]);
|
||||
|
||||
const acceptEssentialOnly = useCallback(async () => {
|
||||
await savePreferences({
|
||||
essential: true,
|
||||
functional: false,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
});
|
||||
}, [savePreferences]);
|
||||
|
||||
const openPreferences = useCallback(() => {
|
||||
setShowBanner(true);
|
||||
setShowSettings(true);
|
||||
}, []);
|
||||
|
||||
const value: CookieContextType = {
|
||||
preferences,
|
||||
showBanner,
|
||||
showSettings,
|
||||
isLoading,
|
||||
setShowBanner,
|
||||
setShowSettings,
|
||||
acceptAll,
|
||||
acceptEssentialOnly,
|
||||
savePreferences,
|
||||
openPreferences,
|
||||
};
|
||||
|
||||
return <CookieContext.Provider value={value}>{children}</CookieContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCookieConsent() {
|
||||
const context = useContext(CookieContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useCookieConsent must be used within a CookieProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user