fix
This commit is contained in:
parent
1a86864d1f
commit
1d279a0e12
@ -2,6 +2,7 @@ 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 } from '../dto/carrier-documents.dto';
|
||||
|
||||
/**
|
||||
* CSV Booking Actions Controller (Public Routes)
|
||||
@ -88,4 +89,28 @@ export class CsvBookingActionsController {
|
||||
reason: reason || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get booking documents for carrier (PUBLIC - token-based)
|
||||
*
|
||||
* GET /api/v1/csv-booking-actions/documents/:token
|
||||
*/
|
||||
@Public()
|
||||
@Get('documents/:token')
|
||||
@ApiOperation({
|
||||
summary: 'Get booking documents (public)',
|
||||
description:
|
||||
'Public endpoint for carriers to access booking documents after acceptance. Returns booking summary and documents with signed download URLs.',
|
||||
})
|
||||
@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' })
|
||||
async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> {
|
||||
return this.csvBookingService.getDocumentsForCarrier(token);
|
||||
}
|
||||
}
|
||||
|
||||
84
apps/backend/src/application/dto/carrier-documents.dto.ts
Normal file
84
apps/backend/src/application/dto/carrier-documents.dto.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* Booking Summary DTO for Carrier Documents Page
|
||||
*/
|
||||
export class BookingSummaryDto {
|
||||
@ApiProperty({ description: 'Booking unique ID' })
|
||||
id: 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[];
|
||||
}
|
||||
@ -21,6 +21,7 @@ import {
|
||||
CsvBookingListResponseDto,
|
||||
CsvBookingStatsDto,
|
||||
} from '../dto/csv-booking.dto';
|
||||
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
|
||||
|
||||
/**
|
||||
* CSV Booking Document (simple class for domain)
|
||||
@ -201,6 +202,84 @@ export class CsvBookingService {
|
||||
return this.toResponseDto(booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get booking documents for carrier (public endpoint)
|
||||
* Only accessible for ACCEPTED bookings
|
||||
*/
|
||||
async getDocumentsForCarrier(token: string): Promise<CarrierDocumentsResponseDto> {
|
||||
this.logger.log(`Getting documents for carrier with token: ${token}`);
|
||||
|
||||
const booking = await this.csvBookingRepository.findByToken(token);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException('Réservation introuvable');
|
||||
}
|
||||
|
||||
// Only allow access for ACCEPTED bookings
|
||||
if (booking.status !== CsvBookingStatus.ACCEPTED) {
|
||||
throw new BadRequestException('Cette réservation n\'a pas encore été acceptée');
|
||||
}
|
||||
|
||||
// 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,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@ -220,6 +299,23 @@ export class CsvBookingService {
|
||||
const updatedBooking = await this.csvBookingRepository.update(booking);
|
||||
this.logger.log(`Booking ${booking.id} accepted`);
|
||||
|
||||
// Send document access email to carrier
|
||||
try {
|
||||
await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, {
|
||||
carrierName: booking.carrierName,
|
||||
bookingId: booking.id,
|
||||
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({
|
||||
@ -475,9 +571,9 @@ export class CsvBookingService {
|
||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||
}
|
||||
|
||||
// Verify booking is still pending
|
||||
if (booking.status !== CsvBookingStatus.PENDING) {
|
||||
throw new BadRequestException('Cannot add documents to a booking that is not pending');
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Upload new documents
|
||||
@ -506,6 +602,24 @@ 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',
|
||||
|
||||
@ -120,4 +120,37 @@ 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;
|
||||
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>;
|
||||
}
|
||||
|
||||
@ -427,4 +427,175 @@ 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;
|
||||
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}`;
|
||||
|
||||
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>
|
||||
</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>
|
||||
|
||||
<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.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.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}`);
|
||||
}
|
||||
}
|
||||
|
||||
325
apps/frontend/app/carrier/documents/[token]/page.tsx
Normal file
325
apps/frontend/app/carrier/documents/[token]/page.tsx
Normal file
@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
Loader2,
|
||||
XCircle,
|
||||
Package,
|
||||
Ship,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
type: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
interface BookingSummary {
|
||||
id: 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[];
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const hasCalledApi = useRef(false);
|
||||
|
||||
const fetchDocuments = async () => {
|
||||
if (!token) {
|
||||
setError('Lien invalide');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
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.');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCalledApi.current) return;
|
||||
hasCalledApi.current = true;
|
||||
fetchDocuments();
|
||||
}, [token]);
|
||||
|
||||
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);
|
||||
hasCalledApi.current = false;
|
||||
fetchDocuments();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
Chargement des documents...
|
||||
</h1>
|
||||
<p className="text-gray-600">Veuillez patienter</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
|
||||
<div className="bg-white p-8 rounded-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-gray-800 text-white rounded-lg hover:bg-gray-900 font-medium transition-colors"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const { booking, documents } = data;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-sky-50 to-cyan-50">
|
||||
{/* 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">
|
||||
<Ship className="w-8 h-8 text-sky-600" />
|
||||
<span className="text-xl font-bold text-gray-900">Xpeditis</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="text-sm text-sky-600 hover:text-sky-700 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-sky-600 to-cyan-600 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>
|
||||
</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.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-sky-600" />
|
||||
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-sky-100 text-sky-700 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-sky-600 text-white rounded-lg hover:bg-sky-700 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-gray-500">
|
||||
<p>© {new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user