fix
This commit is contained in:
parent
1d279a0e12
commit
fd1f57dd1d
@ -1,8 +1,12 @@
|
|||||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
import { CsvBookingService } from '../services/csv-booking.service';
|
import { CsvBookingService } from '../services/csv-booking.service';
|
||||||
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
|
import {
|
||||||
|
CarrierDocumentsResponseDto,
|
||||||
|
VerifyDocumentAccessDto,
|
||||||
|
DocumentAccessRequirementsDto,
|
||||||
|
} from '../dto/carrier-documents.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Booking Actions Controller (Public Routes)
|
* CSV Booking Actions Controller (Public Routes)
|
||||||
@ -91,16 +95,71 @@ export class CsvBookingActionsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get booking documents for carrier (PUBLIC - token-based)
|
* 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
|
* GET /api/v1/csv-booking-actions/documents/:token
|
||||||
*/
|
*/
|
||||||
@Public()
|
@Public()
|
||||||
@Get('documents/:token')
|
@Get('documents/:token')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Get booking documents (public)',
|
summary: 'Get booking documents (public) - Legacy',
|
||||||
description:
|
description:
|
||||||
'Public endpoint for carriers to access booking documents after acceptance. Returns booking summary and documents with signed download URLs.',
|
'Public endpoint for carriers to access booking documents. For new bookings, use POST with password instead.',
|
||||||
})
|
})
|
||||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@ -110,6 +169,7 @@ export class CsvBookingActionsController {
|
|||||||
})
|
})
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
||||||
@ApiResponse({ status: 400, description: 'Booking has not been accepted yet' })
|
@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> {
|
async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> {
|
||||||
return this.csvBookingService.getDocumentsForCarrier(token);
|
return this.csvBookingService.getDocumentsForCarrier(token);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,29 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
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
|
* Booking Summary DTO for Carrier Documents Page
|
||||||
@ -7,6 +32,9 @@ export class BookingSummaryDto {
|
|||||||
@ApiProperty({ description: 'Booking unique ID' })
|
@ApiProperty({ description: 'Booking unique ID' })
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Human-readable booking number' })
|
||||||
|
bookingNumber?: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Carrier/Company name' })
|
@ApiProperty({ description: 'Carrier/Company name' })
|
||||||
carrierName: string;
|
carrierName: string;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
Inject,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity';
|
import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity';
|
||||||
import { PortCode } from '@domain/value-objects/port-code.vo';
|
import { PortCode } from '@domain/value-objects/port-code.vo';
|
||||||
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
||||||
@ -57,6 +65,27 @@ export class CsvBookingService {
|
|||||||
private readonly storageAdapter: StoragePort
|
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
|
* Create a new CSV booking request
|
||||||
*/
|
*/
|
||||||
@ -73,9 +102,14 @@ export class CsvBookingService {
|
|||||||
throw new BadRequestException('At least one document is required');
|
throw new BadRequestException('At least one document is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique confirmation token
|
// Generate unique confirmation token and booking number
|
||||||
const confirmationToken = uuidv4();
|
const confirmationToken = uuidv4();
|
||||||
const bookingId = 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
|
// Upload documents to S3
|
||||||
const documents = await this.uploadDocuments(files, bookingId);
|
const documents = await this.uploadDocuments(files, bookingId);
|
||||||
@ -107,13 +141,26 @@ export class CsvBookingService {
|
|||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
const savedBooking = await this.csvBookingRepository.create(booking);
|
const savedBooking = await this.csvBookingRepository.create(booking);
|
||||||
this.logger.log(`CSV booking created with ID: ${bookingId}`);
|
|
||||||
|
// 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}`);
|
||||||
|
|
||||||
// Send email to carrier and WAIT for confirmation
|
// Send email to carrier and WAIT for confirmation
|
||||||
// The button waits for the email to be sent before responding
|
// The button waits for the email to be sent before responding
|
||||||
try {
|
try {
|
||||||
await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, {
|
await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, {
|
||||||
bookingId,
|
bookingId,
|
||||||
|
bookingNumber,
|
||||||
|
documentPassword,
|
||||||
origin: dto.origin,
|
origin: dto.origin,
|
||||||
destination: dto.destination,
|
destination: dto.destination,
|
||||||
volumeCBM: dto.volumeCBM,
|
volumeCBM: dto.volumeCBM,
|
||||||
@ -203,21 +250,45 @@ export class CsvBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get booking documents for carrier (public endpoint)
|
* Verify password and get booking documents for carrier (public endpoint)
|
||||||
* Only accessible for ACCEPTED bookings
|
* Only accessible for ACCEPTED bookings with correct password
|
||||||
*/
|
*/
|
||||||
async getDocumentsForCarrier(token: string): Promise<CarrierDocumentsResponseDto> {
|
async getDocumentsForCarrier(
|
||||||
|
token: string,
|
||||||
|
password?: string
|
||||||
|
): Promise<CarrierDocumentsResponseDto> {
|
||||||
this.logger.log(`Getting documents for carrier with token: ${token}`);
|
this.logger.log(`Getting documents for carrier with token: ${token}`);
|
||||||
|
|
||||||
const booking = await this.csvBookingRepository.findByToken(token);
|
// Get ORM entity to access passwordHash
|
||||||
|
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||||
|
where: { confirmationToken: token },
|
||||||
|
});
|
||||||
|
|
||||||
if (!booking) {
|
if (!ormBooking) {
|
||||||
throw new NotFoundException('Réservation introuvable');
|
throw new NotFoundException('Réservation introuvable');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow access for ACCEPTED bookings
|
// Only allow access for ACCEPTED bookings
|
||||||
if (booking.status !== CsvBookingStatus.ACCEPTED) {
|
if (ormBooking.status !== 'ACCEPTED') {
|
||||||
throw new BadRequestException('Cette réservation n\'a pas encore été acceptée');
|
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
|
// Generate signed URLs for all documents
|
||||||
@ -240,6 +311,7 @@ export class CsvBookingService {
|
|||||||
return {
|
return {
|
||||||
booking: {
|
booking: {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
|
bookingNumber: ormBooking.bookingNumber || undefined,
|
||||||
carrierName: booking.carrierName,
|
carrierName: booking.carrierName,
|
||||||
origin: booking.origin.getValue(),
|
origin: booking.origin.getValue(),
|
||||||
destination: booking.destination.getValue(),
|
destination: booking.destination.getValue(),
|
||||||
@ -257,6 +329,27 @@ export class CsvBookingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Generate signed URL for a document file path
|
||||||
*/
|
*/
|
||||||
@ -292,6 +385,11 @@ export class CsvBookingService {
|
|||||||
throw new NotFoundException('Booking not found');
|
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)
|
// Accept the booking (domain logic validates status)
|
||||||
booking.accept();
|
booking.accept();
|
||||||
|
|
||||||
@ -299,11 +397,19 @@ export class CsvBookingService {
|
|||||||
const updatedBooking = await this.csvBookingRepository.update(booking);
|
const updatedBooking = await this.csvBookingRepository.update(booking);
|
||||||
this.logger.log(`Booking ${booking.id} accepted`);
|
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
|
// Send document access email to carrier
|
||||||
try {
|
try {
|
||||||
await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, {
|
await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, {
|
||||||
carrierName: booking.carrierName,
|
carrierName: booking.carrierName,
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
|
bookingNumber: bookingNumber || undefined,
|
||||||
|
documentPassword: documentPassword,
|
||||||
origin: booking.origin.getValue(),
|
origin: booking.origin.getValue(),
|
||||||
destination: booking.destination.getValue(),
|
destination: booking.destination.getValue(),
|
||||||
volumeCBM: booking.volumeCBM,
|
volumeCBM: booking.volumeCBM,
|
||||||
|
|||||||
@ -85,6 +85,8 @@ export interface EmailPort {
|
|||||||
carrierEmail: string,
|
carrierEmail: string,
|
||||||
bookingDetails: {
|
bookingDetails: {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
bookingNumber?: string;
|
||||||
|
documentPassword?: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
volumeCBM: number;
|
volumeCBM: number;
|
||||||
@ -129,6 +131,8 @@ export interface EmailPort {
|
|||||||
data: {
|
data: {
|
||||||
carrierName: string;
|
carrierName: string;
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
bookingNumber?: string;
|
||||||
|
documentPassword?: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
volumeCBM: number;
|
volumeCBM: number;
|
||||||
|
|||||||
@ -239,6 +239,8 @@ export class EmailAdapter implements EmailPort {
|
|||||||
carrierEmail: string,
|
carrierEmail: string,
|
||||||
bookingData: {
|
bookingData: {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
bookingNumber?: string;
|
||||||
|
documentPassword?: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
volumeCBM: number;
|
volumeCBM: number;
|
||||||
@ -270,7 +272,7 @@ export class EmailAdapter implements EmailPort {
|
|||||||
|
|
||||||
await this.send({
|
await this.send({
|
||||||
to: carrierEmail,
|
to: carrierEmail,
|
||||||
subject: `Nouvelle demande de réservation - ${bookingData.origin} → ${bookingData.destination}`,
|
subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin} → ${bookingData.destination}`,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -436,6 +438,8 @@ export class EmailAdapter implements EmailPort {
|
|||||||
data: {
|
data: {
|
||||||
carrierName: string;
|
carrierName: string;
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
bookingNumber?: string;
|
||||||
|
documentPassword?: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
volumeCBM: number;
|
volumeCBM: number;
|
||||||
@ -447,6 +451,20 @@ export class EmailAdapter implements EmailPort {
|
|||||||
const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000');
|
const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000');
|
||||||
const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`;
|
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 = `
|
const html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@ -474,6 +492,7 @@ export class EmailAdapter implements EmailPort {
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Documents disponibles</h1>
|
<h1>Documents disponibles</h1>
|
||||||
<p style="margin: 10px 0 0 0; opacity: 0.9;">Votre reservation a ete acceptee</p>
|
<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>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p>Bonjour <strong>${data.carrierName}</strong>,</p>
|
<p>Bonjour <strong>${data.carrierName}</strong>,</p>
|
||||||
@ -498,12 +517,14 @@ export class EmailAdapter implements EmailPort {
|
|||||||
<span class="documents-badge">${data.documentCount} document${data.documentCount > 1 ? 's' : ''} disponible${data.documentCount > 1 ? 's' : ''}</span>
|
<span class="documents-badge">${data.documentCount} document${data.documentCount > 1 ? 's' : ''} disponible${data.documentCount > 1 ? 's' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${passwordSection}
|
||||||
|
|
||||||
<a href="${documentsUrl}" class="cta-button">Acceder aux documents</a>
|
<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>
|
<p style="color: #64748b; font-size: 14px; text-align: center;">Ce lien est permanent. Vous pouvez y acceder a tout moment.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>Reference: ${data.bookingId.substring(0, 8).toUpperCase()}</p>
|
<p>Reference: ${data.bookingNumber || data.bookingId.substring(0, 8).toUpperCase()}</p>
|
||||||
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
|
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -513,7 +534,7 @@ export class EmailAdapter implements EmailPort {
|
|||||||
|
|
||||||
await this.send({
|
await this.send({
|
||||||
to: carrierEmail,
|
to: carrierEmail,
|
||||||
subject: `Documents disponibles - Reservation ${data.origin} → ${data.destination}`,
|
subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin} → ${data.destination}`,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -261,6 +261,8 @@ export class EmailTemplates {
|
|||||||
*/
|
*/
|
||||||
async renderCsvBookingRequest(data: {
|
async renderCsvBookingRequest(data: {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
bookingNumber?: string;
|
||||||
|
documentPassword?: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
volumeCBM: number;
|
volumeCBM: number;
|
||||||
@ -481,6 +483,21 @@ 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.
|
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>
|
</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 -->
|
<!-- Booking Details -->
|
||||||
<div class="section-title">📋 Détails du transport</div>
|
<div class="section-title">📋 Détails du transport</div>
|
||||||
<table class="details-table">
|
<table class="details-table">
|
||||||
|
|||||||
@ -96,6 +96,13 @@ export class CsvBookingOrmEntity {
|
|||||||
@Index()
|
@Index()
|
||||||
confirmationToken: string;
|
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' })
|
@Column({ name: 'requested_at', type: 'timestamp with time zone' })
|
||||||
@Index()
|
@Index()
|
||||||
requestedAt: Date;
|
requestedAt: Date;
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 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"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,9 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
Lock,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface Document {
|
interface Document {
|
||||||
@ -25,6 +28,7 @@ interface Document {
|
|||||||
|
|
||||||
interface BookingSummary {
|
interface BookingSummary {
|
||||||
id: string;
|
id: string;
|
||||||
|
bookingNumber?: string;
|
||||||
carrierName: string;
|
carrierName: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
@ -44,6 +48,12 @@ interface CarrierDocumentsData {
|
|||||||
documents: Document[];
|
documents: Document[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AccessRequirements {
|
||||||
|
requiresPassword: boolean;
|
||||||
|
bookingNumber?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
const documentTypeLabels: Record<string, string> = {
|
const documentTypeLabels: Record<string, string> = {
|
||||||
BILL_OF_LADING: 'Connaissement',
|
BILL_OF_LADING: 'Connaissement',
|
||||||
PACKING_LIST: 'Liste de colisage',
|
PACKING_LIST: 'Liste de colisage',
|
||||||
@ -75,9 +85,18 @@ export default function CarrierDocumentsPage() {
|
|||||||
const [data, setData] = useState<CarrierDocumentsData | null>(null);
|
const [data, setData] = useState<CarrierDocumentsData | null>(null);
|
||||||
const [downloading, setDownloading] = useState<string | null>(null);
|
const [downloading, setDownloading] = useState<string | null>(null);
|
||||||
|
|
||||||
const hasCalledApi = useRef(false);
|
// 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 fetchDocuments = async () => {
|
const hasCalledApi = useRef(false);
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
|
// Check access requirements first
|
||||||
|
const checkRequirements = async () => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError('Lien invalide');
|
setError('Lien invalide');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -85,7 +104,61 @@ export default function CarrierDocumentsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
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}`, {
|
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
@ -103,10 +176,20 @@ export default function CarrierDocumentsPage() {
|
|||||||
|
|
||||||
const errorMessage = errorData.message || 'Erreur lors du chargement des documents';
|
const errorMessage = errorData.message || 'Erreur lors du chargement des documents';
|
||||||
|
|
||||||
if (errorMessage.includes('pas encore été acceptée') || errorMessage.includes('not accepted')) {
|
if (
|
||||||
throw new Error('Cette réservation n\'a pas encore été acceptée. Les documents seront disponibles après l\'acceptation.');
|
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')) {
|
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
|
||||||
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
|
throw new Error('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);
|
throw new Error(errorMessage);
|
||||||
@ -122,12 +205,68 @@ export default function CarrierDocumentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (hasCalledApi.current) return;
|
if (hasCalledApi.current) return;
|
||||||
hasCalledApi.current = true;
|
hasCalledApi.current = true;
|
||||||
fetchDocuments();
|
checkRequirements();
|
||||||
}, [token]);
|
}, [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) => {
|
const handleDownload = async (doc: Document) => {
|
||||||
setDownloading(doc.id);
|
setDownloading(doc.id);
|
||||||
|
|
||||||
@ -146,24 +285,28 @@ export default function CarrierDocumentsPage() {
|
|||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setData(null);
|
||||||
|
setRequirements(null);
|
||||||
|
setPassword('');
|
||||||
|
setPasswordError(null);
|
||||||
hasCalledApi.current = false;
|
hasCalledApi.current = false;
|
||||||
fetchDocuments();
|
checkRequirements();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Loading state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-sky-50">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-sky-50">
|
||||||
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
|
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
|
||||||
<Loader2 className="w-16 h-16 text-sky-600 mx-auto mb-4 animate-spin" />
|
<Loader2 className="w-16 h-16 text-sky-600 mx-auto mb-4 animate-spin" />
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Chargement...</h1>
|
||||||
Chargement des documents...
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600">Veuillez patienter</p>
|
<p className="text-gray-600">Veuillez patienter</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
|
||||||
@ -182,6 +325,91 @@ export default function CarrierDocumentsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Password form state
|
||||||
|
if (requirements?.requiresPassword && !data) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-sky-50 to-cyan-50 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-sky-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<Lock className="w-8 h-8 text-sky-600" />
|
||||||
|
</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-sky-500 focus:border-sky-500 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-sky-600 text-white rounded-lg hover:bg-sky-700 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;
|
if (!data) return null;
|
||||||
|
|
||||||
const { booking, documents } = data;
|
const { booking, documents } = data;
|
||||||
@ -213,6 +441,11 @@ export default function CarrierDocumentsPage() {
|
|||||||
<ArrowRight className="w-6 h-6" />
|
<ArrowRight className="w-6 h-6" />
|
||||||
<span className="text-2xl font-bold">{booking.destination}</span>
|
<span className="text-2xl font-bold">{booking.destination}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{booking.bookingNumber && (
|
||||||
|
<p className="text-center text-sky-100 text-sm mt-1">
|
||||||
|
N° {booking.bookingNumber}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@ -241,10 +474,14 @@ export default function CarrierDocumentsPage() {
|
|||||||
|
|
||||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
||||||
<span className="text-gray-500">
|
<span className="text-gray-500">
|
||||||
Transporteur: <span className="text-gray-900 font-medium">{booking.carrierName}</span>
|
Transporteur:{' '}
|
||||||
|
<span className="text-gray-900 font-medium">{booking.carrierName}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500">
|
<span className="text-gray-500">
|
||||||
Ref: <span className="font-mono text-gray-900">{booking.id.substring(0, 8).toUpperCase()}</span>
|
Ref:{' '}
|
||||||
|
<span className="font-mono text-gray-900">
|
||||||
|
{booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -263,11 +500,13 @@ export default function CarrierDocumentsPage() {
|
|||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||||
<p className="text-gray-600">Aucun document disponible pour le moment.</p>
|
<p className="text-gray-600">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>
|
<p className="text-gray-500 text-sm mt-1">
|
||||||
|
Les documents apparaîtront ici une fois ajoutés.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-100">
|
<div className="divide-y divide-gray-100">
|
||||||
{documents.map((doc) => (
|
{documents.map(doc => (
|
||||||
<div
|
<div
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
|
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
|
||||||
@ -280,9 +519,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
<span className="text-xs px-2 py-0.5 bg-sky-100 text-sky-700 rounded-full">
|
<span className="text-xs px-2 py-0.5 bg-sky-100 text-sky-700 rounded-full">
|
||||||
{documentTypeLabels[doc.type] || doc.type}
|
{documentTypeLabels[doc.type] || doc.type}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
|
||||||
{formatFileSize(doc.size)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -259,8 +259,10 @@ export default function UserDocumentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get unique bookings for add document modal
|
// Get bookings available for adding documents (PENDING or ACCEPTED)
|
||||||
const bookingsWithPendingStatus = bookings.filter(b => b.status === 'PENDING');
|
const bookingsAvailableForDocuments = bookings.filter(
|
||||||
|
b => b.status === 'PENDING' || b.status === 'ACCEPTED'
|
||||||
|
);
|
||||||
|
|
||||||
const handleAddDocumentClick = () => {
|
const handleAddDocumentClick = () => {
|
||||||
setShowAddModal(true);
|
setShowAddModal(true);
|
||||||
@ -435,7 +437,7 @@ export default function UserDocumentsPage() {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddDocumentClick}
|
onClick={handleAddDocumentClick}
|
||||||
disabled={bookingsWithPendingStatus.length === 0}
|
disabled={bookingsAvailableForDocuments.length === 0}
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -813,7 +815,7 @@ export default function UserDocumentsPage() {
|
|||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Sélectionner une réservation (en attente)
|
Sélectionner une réservation
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={selectedBookingId || ''}
|
value={selectedBookingId || ''}
|
||||||
@ -821,9 +823,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"
|
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">-- Choisir une réservation --</option>
|
<option value="">-- Choisir une réservation --</option>
|
||||||
{bookingsWithPendingStatus.map(booking => (
|
{bookingsAvailableForDocuments.map(booking => (
|
||||||
<option key={booking.id} value={booking.id}>
|
<option key={booking.id} value={booking.id}>
|
||||||
{getQuoteNumber(booking)} - {booking.origin} → {booking.destination}
|
{getQuoteNumber(booking)} - {booking.origin} → {booking.destination} ({booking.status === 'PENDING' ? 'En attente' : 'Accepté'})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -229,16 +229,6 @@ export default function AdvancedSearchPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">1. Informations Générales</h2>
|
<h2 className="text-xl font-semibold text-gray-900">1. Informations Générales</h2>
|
||||||
|
|
||||||
{/* Info banner about available routes */}
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-3">
|
|
||||||
<p className="text-sm text-blue-800">
|
|
||||||
Seuls les ports ayant des tarifs disponibles dans notre système sont proposés.
|
|
||||||
{originsData?.total && (
|
|
||||||
<span className="font-medium"> ({originsData.total} ports d'origine disponibles)</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{/* Origin Port with Autocomplete - Limited to CSV routes */}
|
{/* Origin Port with Autocomplete - Limited to CSV routes */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MapContainer, TileLayer, Polyline, Marker } from "react-leaflet";
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { MapContainer, TileLayer, Polyline, Marker, useMap } from "react-leaflet";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import L from "leaflet";
|
import L from "leaflet";
|
||||||
|
|
||||||
@ -19,22 +20,352 @@ const DefaultIcon = L.icon({
|
|||||||
});
|
});
|
||||||
L.Marker.prototype.options.icon = DefaultIcon;
|
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) {
|
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 = {
|
const center = {
|
||||||
lat: (portA.lat + portB.lat) / 2,
|
lat: (portA.lat + portB.lat) / 2,
|
||||||
lng: (portA.lng + portB.lng) / 2,
|
lng: (portA.lng + portB.lng) / 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const positions: [number, number][] = [
|
|
||||||
[portA.lat, portA.lng],
|
|
||||||
[portB.lat, portB.lng],
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height }}>
|
<div style={{ height }}>
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={[center.lat, center.lng]}
|
center={[center.lat, center.lng]}
|
||||||
zoom={4}
|
zoom={2}
|
||||||
style={{ height: "100%", width: "100%" }}
|
style={{ height: "100%", width: "100%" }}
|
||||||
scrollWheelZoom={false}
|
scrollWheelZoom={false}
|
||||||
>
|
>
|
||||||
@ -43,10 +374,25 @@ export default function PortRouteMap({ portA, portB, height = "500px" }: PortRou
|
|||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
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]} />
|
<Marker position={[portA.lat, portA.lng]} />
|
||||||
|
|
||||||
|
{/* Destination marker */}
|
||||||
<Marker position={[portB.lat, portB.lng]} />
|
<Marker position={[portB.lat, portB.lng]} />
|
||||||
|
|
||||||
<Polyline positions={positions} pathOptions={{ color: "#2563eb", weight: 3, opacity: 0.7 }} />
|
{/* Maritime route polyline */}
|
||||||
|
<Polyline
|
||||||
|
positions={positions}
|
||||||
|
pathOptions={{
|
||||||
|
color: "#2563eb",
|
||||||
|
weight: 3,
|
||||||
|
opacity: 0.8,
|
||||||
|
dashArray: "10, 6",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user